mirror of
https://github.com/Ry3yr/Fake-Social-MediaWriter.git
synced 2026-08-19 17:20:03 -04:00
84d9affe28
Fix VideoBsky upload
1253 lines
40 KiB
HTML
1253 lines
40 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8" />
|
|
<title>Bluesky Poster</title>
|
|
<a href="javascript:(function() { var queryString = window.location.search; window.location.href = 'mtdreact.html' + queryString; })();">
|
|
React
|
|
</a>
|
|
|
|
<style>
|
|
body {
|
|
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
|
max-width: 1200px;
|
|
margin: 0 auto;
|
|
padding: 20px;
|
|
background-color: #f5f5f5;
|
|
color: #333;
|
|
}
|
|
|
|
h2 {
|
|
color: #0085ff;
|
|
border-bottom: 2px solid #0085ff;
|
|
padding-bottom: 10px;
|
|
}
|
|
|
|
.card {
|
|
background: white;
|
|
border-radius: 8px;
|
|
padding: 20px;
|
|
margin-bottom: 20px;
|
|
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
|
}
|
|
|
|
label {
|
|
display: block;
|
|
margin: 10px 0 5px;
|
|
font-weight: bold;
|
|
}
|
|
|
|
input[type="text"], input[type="password"], textarea {
|
|
width: 100%;
|
|
padding: 8px;
|
|
border: 1px solid #ccc;
|
|
border-radius: 4px;
|
|
box-sizing: border-box;
|
|
font-family: inherit;
|
|
}
|
|
|
|
button {
|
|
background-color: #0085ff;
|
|
color: white;
|
|
border: none;
|
|
padding: 10px 15px;
|
|
border-radius: 4px;
|
|
cursor: pointer;
|
|
font-size: 16px;
|
|
margin: 10px 0;
|
|
}
|
|
|
|
button:hover {
|
|
background-color: #0066cc;
|
|
}
|
|
|
|
button:disabled {
|
|
background-color: #cccccc;
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
#duplicateWarning {
|
|
color: red;
|
|
font-weight: bold;
|
|
margin: 10px 0;
|
|
display: none;
|
|
padding: 10px;
|
|
background-color: #ffeeee;
|
|
border-radius: 4px;
|
|
}
|
|
|
|
.textboxes-container {
|
|
display: flex;
|
|
gap: 20px;
|
|
margin-bottom: 15px;
|
|
}
|
|
|
|
.textbox-wrapper {
|
|
flex: 1;
|
|
}
|
|
|
|
.textbox-wrapper h3 {
|
|
margin-top: 0;
|
|
color: #0085ff;
|
|
}
|
|
|
|
#hiddenText {
|
|
opacity: 0.3;
|
|
background-color: #f9f9f9;
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
.char-counter {
|
|
text-align: right;
|
|
font-size: 0.9em;
|
|
color: #666;
|
|
margin-top: 5px;
|
|
}
|
|
|
|
.char-counter.error {
|
|
color: #dc3545;
|
|
}
|
|
|
|
.post-preview {
|
|
border: 1px solid #ddd;
|
|
padding: 15px;
|
|
margin: 15px 0;
|
|
background: #f9f9f9;
|
|
border-radius: 4px;
|
|
min-height: 60px;
|
|
}
|
|
|
|
.hashtag {
|
|
color: #0085ff;
|
|
font-weight: bold;
|
|
}
|
|
|
|
.link {
|
|
color: #0066cc;
|
|
text-decoration: underline;
|
|
}
|
|
|
|
.media-section {
|
|
margin: 20px 0;
|
|
padding: 15px;
|
|
background-color: #f8f9fa;
|
|
border-radius: 8px;
|
|
}
|
|
|
|
.media-controls {
|
|
display: flex;
|
|
gap: 10px;
|
|
margin-top: 10px;
|
|
}
|
|
|
|
.media-preview {
|
|
margin-top: 15px;
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 10px;
|
|
}
|
|
|
|
.media-preview img, .media-preview video {
|
|
max-width: 100px;
|
|
max-height: 100px;
|
|
object-fit: cover;
|
|
border-radius: 4px;
|
|
}
|
|
|
|
.media-item {
|
|
background: white;
|
|
border: 1px solid #ddd;
|
|
border-radius: 4px;
|
|
padding: 10px;
|
|
margin-bottom: 10px;
|
|
}
|
|
|
|
.remove-media-btn {
|
|
background: #dc3545;
|
|
padding: 5px 10px;
|
|
font-size: 0.9em;
|
|
margin-top: 5px;
|
|
}
|
|
|
|
.features-section {
|
|
margin: 15px 0;
|
|
padding: 15px;
|
|
background: #fff3cd;
|
|
border-radius: 4px;
|
|
border: 1px solid #ffeaa7;
|
|
}
|
|
|
|
.feature-item {
|
|
margin: 10px 0;
|
|
}
|
|
|
|
.handle-display {
|
|
background: #e7f3ff;
|
|
padding: 10px;
|
|
border-radius: 4px;
|
|
margin-bottom: 15px;
|
|
font-weight: bold;
|
|
}
|
|
|
|
.hidden {
|
|
display: none !important;
|
|
}
|
|
|
|
.loading {
|
|
display: inline-block;
|
|
width: 20px;
|
|
height: 20px;
|
|
border: 2px solid #fff;
|
|
border-top: 2px solid transparent;
|
|
border-radius: 50%;
|
|
animation: spin 1s linear infinite;
|
|
margin-left: 10px;
|
|
}
|
|
|
|
@keyframes spin {
|
|
0% { transform: rotate(0deg); }
|
|
100% { transform: rotate(360deg); }
|
|
}
|
|
|
|
.info-box {
|
|
background: #e7f3ff;
|
|
border-left: 4px solid #0085ff;
|
|
padding: 10px;
|
|
margin: 10px 0;
|
|
border-radius: 4px;
|
|
}
|
|
|
|
#jsonPreview {
|
|
border: 1px solid #999;
|
|
background: #f0f0f0;
|
|
padding: 10px;
|
|
margin-top: 15px;
|
|
font-family: monospace;
|
|
font-size: 0.9em;
|
|
white-space: pre-wrap;
|
|
min-height: 100px;
|
|
}
|
|
|
|
#debugLog {
|
|
border: 1px solid #ccc;
|
|
background: #f9f9f9;
|
|
padding: 10px;
|
|
margin-top: 15px;
|
|
font-family: monospace;
|
|
font-size: 0.9em;
|
|
max-height: 250px;
|
|
overflow-y: auto;
|
|
white-space: pre-wrap;
|
|
}
|
|
|
|
details {
|
|
margin: 15px 0;
|
|
padding: 10px;
|
|
background: #f8f9fa;
|
|
border-radius: 4px;
|
|
}
|
|
|
|
summary {
|
|
cursor: pointer;
|
|
font-weight: bold;
|
|
}
|
|
|
|
.duration-warning {
|
|
color: #dc3545;
|
|
font-size: 0.9em;
|
|
margin-top: 5px;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="card">
|
|
<h2>Bluesky Poster</h2>
|
|
|
|
<details>
|
|
<summary>Troubleshooting</summary>
|
|
<p>This Bluesky poster works entirely in your browser. No server-side code is involved for authentication or posting.</p>
|
|
<p><strong>Security:</strong> Your credentials are sent only to Bluesky's official API at bsky.social</p>
|
|
<p><strong>Media:</strong> Supports images (JPEG, PNG, GIF, WebP) and videos (MP4, WebM, max 2 minutes, 20MB each)</p>
|
|
</details>
|
|
|
|
<div id="authStatus" class="info-box hidden"></div>
|
|
|
|
<!-- Login Section -->
|
|
<div id="loginSection">
|
|
<div class="info-box">
|
|
<strong>Security Note:</strong> This runs entirely in your browser. Credentials are only sent to Bluesky's official API.
|
|
</div>
|
|
|
|
<form id="loginForm">
|
|
<label for="handle">Bluesky Handle</label>
|
|
<input type="text" id="handle" name="handle" placeholder="username.bsky.social" required />
|
|
|
|
<label for="password">Password</label>
|
|
<input type="password" id="password" name="password" placeholder="Enter your Bluesky password" required />
|
|
|
|
<div style="margin-top: 15px;">
|
|
<button type="submit" id="loginBtn">
|
|
<span id="loginText">Login to Bluesky</span>
|
|
<span id="loginLoading" class="loading hidden"></span>
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
|
|
<!-- Post Section -->
|
|
<div id="postSection" class="hidden">
|
|
<div id="handleDisplay" class="handle-display">
|
|
Logged in as: <span id="currentHandle"></span>
|
|
</div>
|
|
|
|
<div class="textboxes-container">
|
|
<div class="textbox-wrapper">
|
|
<h3>Post Content (Will be sent to Bluesky):</h3>
|
|
<textarea id="mainText" rows="10" placeholder="Edit content here - this gets sent to Bluesky"></textarea>
|
|
</div>
|
|
<div class="textbox-wrapper">
|
|
<h3>Hidden Content (For preview & duplicate check - Never changes):</h3>
|
|
<textarea id="hiddenText" rows="10" readonly></textarea>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="char-counter" id="charCounter">0/300</div>
|
|
|
|
<div class="media-section">
|
|
<h4>Media Upload</h4>
|
|
<div class="info-box">
|
|
<p><strong>Supported formats:</strong> JPEG, PNG, GIF, WebP (images) • MP4, WebM (videos, max 2 minutes)</p>
|
|
<p><strong>Max size:</strong> 20MB per file • Max 4 files</p>
|
|
</div>
|
|
|
|
<div class="media-controls">
|
|
<input type="file" id="mediaInput" accept="image/*,video/mp4,video/webm,video/quicktime" multiple />
|
|
<button type="button" id="addMediaBtn">Add Media</button>
|
|
<button type="button" id="clearMediaBtn">Clear All Media</button>
|
|
</div>
|
|
|
|
<div id="mediaPreview" class="media-preview"></div>
|
|
<div id="mediaList" class="media-list"></div>
|
|
</div>
|
|
|
|
<div class="features-section">
|
|
<h4>Post Features:</h4>
|
|
|
|
<div class="feature-item">
|
|
<label>
|
|
<input type="checkbox" id="autoDetectHashtags" checked />
|
|
Auto-detect #hashtags (as Bluesky facets)
|
|
</label>
|
|
</div>
|
|
|
|
<div class="feature-item">
|
|
<label>
|
|
<input type="checkbox" id="autoDetectLinks" checked />
|
|
Auto-detect links (as Bluesky facets)
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="post-preview" id="postPreview">Preview will appear here...</div>
|
|
|
|
<div id="duplicateWarning">This content has already been posted recently!</div>
|
|
|
|
<div style="margin-top: 20px;">
|
|
<button type="button" id="logoutBtn" style="background-color: #6c757d; margin-right: 10px;">Logout</button>
|
|
<button type="button" id="postButton" onclick="postToBluesky()">
|
|
<span id="postTextBtn">Post to Bluesky</span>
|
|
<span id="postLoading" class="loading hidden"></span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<h3>📦 JSON that will be saved:</h3>
|
|
<div id="jsonPreview">{}</div>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<h3>📦 Past JSON from Local Storage</h3>
|
|
<button onclick="loadLocalStoragePosts()">Load Local Storage Items</button>
|
|
<button onclick="clearLocalStoragePosts()" style="background-color: #dc3545; margin-left: 10px;">Clear Storage</button>
|
|
<div id="localStoragePosts" style="margin-top: 15px; max-height: 300px; overflow-y: auto;"></div>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<h3>🪵 Debug Log</h3>
|
|
<div id="debugLog"><strong>Debug Log:</strong><br /></div>
|
|
</div>
|
|
|
|
<script>
|
|
const PDS_HOST = "https://bsky.social";
|
|
const MAX_POST_LENGTH = 300;
|
|
const MAX_MEDIA_SIZE = 20971520; // 20MB (Bluesky's actual limit)
|
|
const MAX_MEDIA_COUNT = 4;
|
|
const MAX_VIDEO_DURATION = 120; // 2 minutes in seconds
|
|
const SUPPORTED_IMAGE_TYPES = ["image/jpeg", "image/png", "image/gif", "image/webp"];
|
|
const SUPPORTED_VIDEO_TYPES = ["video/mp4", "video/webm", "video/quicktime"];
|
|
const SUPPORTED_MEDIA_TYPES = [...SUPPORTED_IMAGE_TYPES, ...SUPPORTED_VIDEO_TYPES];
|
|
|
|
let state = {
|
|
accessJwt: null,
|
|
refreshJwt: null,
|
|
did: null,
|
|
handle: null,
|
|
repo: null,
|
|
};
|
|
|
|
let mediaFiles = [];
|
|
let alceaId = null;
|
|
let uploadedMediaIds = [];
|
|
|
|
// DOM elements
|
|
const authStatus = document.getElementById("authStatus");
|
|
const loginSection = document.getElementById("loginSection");
|
|
const postSection = document.getElementById("postSection");
|
|
const handleDisplay = document.getElementById("handleDisplay");
|
|
const currentHandleSpan = document.getElementById("currentHandle");
|
|
const jsonPreview = document.getElementById("jsonPreview");
|
|
const duplicateWarning = document.getElementById("duplicateWarning");
|
|
const postButton = document.getElementById("postButton");
|
|
const mainText = document.getElementById("mainText");
|
|
const hiddenText = document.getElementById("hiddenText");
|
|
|
|
function logToDom(message) {
|
|
const logDiv = document.getElementById("debugLog");
|
|
const timestamp = new Date().toISOString();
|
|
logDiv.innerHTML += `[${timestamp}] ${message}\n`;
|
|
logDiv.scrollTop = logDiv.scrollHeight;
|
|
}
|
|
|
|
function showMessage(message, type = "info") {
|
|
authStatus.textContent = message;
|
|
authStatus.className = `info-box ${type}`;
|
|
authStatus.classList.remove("hidden");
|
|
|
|
logToDom(`${type.toUpperCase()}: ${message}`);
|
|
|
|
if (type === "success") {
|
|
setTimeout(() => {
|
|
authStatus.classList.add("hidden");
|
|
}, 5000);
|
|
}
|
|
}
|
|
|
|
function showLoading(elementId, show) {
|
|
const textElement = document.getElementById(elementId + "Text");
|
|
const loadingElement = document.getElementById(elementId + "Loading");
|
|
const buttonElement = document.getElementById(elementId + "Btn") || document.getElementById("postButton");
|
|
|
|
if (textElement) textElement.classList.toggle("hidden", show);
|
|
if (loadingElement) loadingElement.classList.toggle("hidden", !show);
|
|
if (buttonElement) buttonElement.disabled = show;
|
|
}
|
|
|
|
function detectFacets(text) {
|
|
const facets = [];
|
|
const autoDetectHashtags = document.getElementById("autoDetectHashtags").checked;
|
|
const autoDetectLinks = document.getElementById("autoDetectLinks").checked;
|
|
|
|
if (autoDetectHashtags) {
|
|
const hashtagRegex = /#(\w+)/g;
|
|
let match;
|
|
while ((match = hashtagRegex.exec(text)) !== null) {
|
|
const start = match.index;
|
|
const end = start + match[0].length;
|
|
facets.push({
|
|
index: {
|
|
byteStart: start,
|
|
byteEnd: end,
|
|
},
|
|
features: [
|
|
{
|
|
$type: "app.bsky.richtext.facet#tag",
|
|
tag: match[1],
|
|
},
|
|
],
|
|
});
|
|
}
|
|
}
|
|
|
|
if (autoDetectLinks) {
|
|
const urlRegex = /(https?:\/\/[^\s<>"'()]+[^\s<>"'().,;:])/gi;
|
|
let match;
|
|
while ((match = urlRegex.exec(text)) !== null) {
|
|
const start = match.index;
|
|
const end = start + match[0].length;
|
|
|
|
try {
|
|
new URL(match[0]);
|
|
facets.push({
|
|
index: {
|
|
byteStart: start,
|
|
byteEnd: end,
|
|
},
|
|
features: [
|
|
{
|
|
$type: "app.bsky.richtext.facet#link",
|
|
uri: match[0],
|
|
},
|
|
],
|
|
});
|
|
} catch (e) {
|
|
// Invalid URL, skip
|
|
}
|
|
}
|
|
}
|
|
|
|
facets.sort((a, b) => a.index.byteStart - b.index.byteStart);
|
|
return facets;
|
|
}
|
|
|
|
function updateCharCounter() {
|
|
const textarea = document.getElementById("mainText");
|
|
const counter = document.getElementById("charCounter");
|
|
const postBtn = document.getElementById("postButton");
|
|
const length = textarea.value.length;
|
|
|
|
counter.textContent = `${length}/${MAX_POST_LENGTH}`;
|
|
|
|
if (length > MAX_POST_LENGTH) {
|
|
counter.classList.add("error");
|
|
if (postBtn) postBtn.disabled = true;
|
|
} else {
|
|
counter.classList.remove("error");
|
|
if (postBtn) postBtn.disabled = length === 0 && mediaFiles.length === 0;
|
|
}
|
|
|
|
updatePreview();
|
|
}
|
|
|
|
function updatePreview() {
|
|
const preview = document.getElementById("postPreview");
|
|
|
|
// ALWAYS use hiddenText for preview generation
|
|
const previewText = hiddenText.value.trim();
|
|
const sendText = mainText.value.trim();
|
|
|
|
if (!previewText && mediaFiles.length === 0) {
|
|
preview.innerHTML = "Preview will appear here...";
|
|
return;
|
|
}
|
|
|
|
let html = previewText || "";
|
|
|
|
// Convert newlines to <br> for preview
|
|
html = html.replace(/\n/g, "<br>");
|
|
|
|
// Convert URLs to links for preview display
|
|
const urlRegex = /(https?:\/\/[^\s<>"'()]+[^\s<>"'().,;:])/gi;
|
|
let match;
|
|
const urlMatches = [];
|
|
let newText = html;
|
|
|
|
while ((match = urlRegex.exec(html)) !== null) {
|
|
urlMatches.push({
|
|
url: match[0],
|
|
index: match.index,
|
|
originalMatch: match[0]
|
|
});
|
|
}
|
|
|
|
for (let i = urlMatches.length - 1; i >= 0; i--) {
|
|
const { url, index, originalMatch } = urlMatches[i];
|
|
let displayUrl = url;
|
|
let trailingPunctuation = '';
|
|
const lastChar = url.slice(-1);
|
|
if (/[.,;:]$/.test(lastChar)) {
|
|
displayUrl = url.slice(0, -1);
|
|
trailingPunctuation = lastChar;
|
|
}
|
|
const link = `<a href="${displayUrl}" target="_blank" rel="noopener noreferrer">${displayUrl}</a>${trailingPunctuation}`;
|
|
newText = newText.substring(0, index) +
|
|
link +
|
|
newText.substring(index + originalMatch.length);
|
|
}
|
|
|
|
html = newText;
|
|
|
|
// Show note if texts differ
|
|
if (previewText !== sendText) {
|
|
html = `<div style="background: #fff3cd; padding: 5px; margin-bottom: 10px; border-radius: 3px; border: 1px solid #ffeaa7;">
|
|
<small>⚠️ Preview shows hidden text. Actual post will send: "${sendText.substring(0, 50)}${sendText.length > 50 ? '...' : ''}"</small>
|
|
</div>` + html;
|
|
}
|
|
|
|
if (mediaFiles.length > 0) {
|
|
html += `<br><br><strong>Media:</strong> ${mediaFiles.length} file(s) attached`;
|
|
mediaFiles.forEach((file, index) => {
|
|
const mediaType = file.type.startsWith("image/") ? "Image" : "Video";
|
|
html += `<br>• ${file.name} (${mediaType}, ${(file.size / (1024 * 1024)).toFixed(2)}MB)`;
|
|
if (file.altText) {
|
|
html += ` - Alt: ${file.altText}`;
|
|
}
|
|
if (file.duration) {
|
|
html += ` - Duration: ${Math.round(file.duration)}s`;
|
|
if (file.duration > MAX_VIDEO_DURATION) {
|
|
html += ` <span class="duration-warning">(Exceeds ${MAX_VIDEO_DURATION}s limit!)</span>`;
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
preview.innerHTML = html;
|
|
}
|
|
|
|
function updateMediaPreview() {
|
|
const mediaPreviewDiv = document.getElementById("mediaPreview");
|
|
const mediaListDiv = document.getElementById("mediaList");
|
|
|
|
mediaListDiv.innerHTML = "";
|
|
mediaPreviewDiv.innerHTML = "";
|
|
|
|
if (mediaFiles.length === 0) {
|
|
mediaPreviewDiv.innerHTML = "<em>No media files added</em>";
|
|
return;
|
|
}
|
|
|
|
let html = "";
|
|
mediaFiles.forEach((file, index) => {
|
|
const altText = file.altText || file.name;
|
|
const sizeMB = (file.size / (1024 * 1024)).toFixed(2);
|
|
const mediaType = file.type.startsWith("image/") ? "Image" : "Video";
|
|
const durationInfo = file.duration ? ` - ${Math.round(file.duration)}s` : '';
|
|
|
|
html += `
|
|
<div class="media-item">
|
|
<strong>${index + 1}. ${file.name}</strong> (${mediaType}, ${sizeMB}MB${durationInfo})
|
|
${file.duration && file.duration > MAX_VIDEO_DURATION ?
|
|
`<div class="duration-warning">⚠️ Video exceeds ${MAX_VIDEO_DURATION}s limit!</div>` : ''}
|
|
<div>
|
|
<label>Alt Text:</label>
|
|
<input type="text" class="media-alt-text" data-index="${index}" value="${altText}" placeholder="Describe this media for accessibility" />
|
|
<button type="button" class="remove-media-btn" data-index="${index}">Remove</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
if (file.type.startsWith("image/")) {
|
|
const reader = new FileReader();
|
|
reader.onload = (e) => {
|
|
const img = document.createElement("img");
|
|
img.src = e.target.result;
|
|
img.style.maxWidth = "100px";
|
|
img.style.maxHeight = "100px";
|
|
img.style.margin = "5px";
|
|
img.style.objectFit = "cover";
|
|
mediaPreviewDiv.appendChild(img);
|
|
};
|
|
reader.readAsDataURL(file);
|
|
} else if (file.type.startsWith("video/")) {
|
|
const reader = new FileReader();
|
|
reader.onload = (e) => {
|
|
const video = document.createElement("video");
|
|
video.src = e.target.result;
|
|
video.style.maxWidth = "100px";
|
|
video.style.maxHeight = "100px";
|
|
video.style.margin = "5px";
|
|
video.controls = true;
|
|
video.muted = true;
|
|
video.preload = "metadata";
|
|
|
|
video.addEventListener('loadedmetadata', () => {
|
|
file.duration = video.duration;
|
|
updatePreview();
|
|
});
|
|
|
|
mediaPreviewDiv.appendChild(video);
|
|
};
|
|
reader.readAsDataURL(file);
|
|
}
|
|
});
|
|
|
|
mediaListDiv.innerHTML = html;
|
|
|
|
document.querySelectorAll(".media-alt-text").forEach((input) => {
|
|
input.addEventListener("input", (e) => {
|
|
const index = parseInt(e.target.dataset.index);
|
|
mediaFiles[index].altText = e.target.value.trim();
|
|
updatePreview();
|
|
});
|
|
});
|
|
|
|
document.querySelectorAll(".remove-media-btn").forEach((button) => {
|
|
button.addEventListener("click", (e) => {
|
|
const index = parseInt(e.target.dataset.index);
|
|
mediaFiles.splice(index, 1);
|
|
updateMediaPreview();
|
|
updatePreview();
|
|
updateCharCounter();
|
|
});
|
|
});
|
|
}
|
|
|
|
function handleMediaUpload() {
|
|
const files = document.getElementById("mediaInput").files;
|
|
if (!files || files.length === 0) return;
|
|
|
|
for (let i = 0; i < files.length; i++) {
|
|
const file = files[i];
|
|
|
|
if (!SUPPORTED_MEDIA_TYPES.includes(file.type)) {
|
|
showMessage(`Unsupported file type: ${file.type}. Supported: JPEG, PNG, GIF, WebP, MP4, WebM`, "error");
|
|
continue;
|
|
}
|
|
|
|
if (file.size > MAX_MEDIA_SIZE) {
|
|
const fileSizeMB = (file.size / (1024 * 1024)).toFixed(2);
|
|
const maxSizeMB = (MAX_MEDIA_SIZE / (1024 * 1024)).toFixed(2);
|
|
showMessage(
|
|
`File ${file.name} is too large (${fileSizeMB}MB). Maximum size is ${maxSizeMB}MB`,
|
|
"error"
|
|
);
|
|
continue;
|
|
}
|
|
|
|
if (mediaFiles.length >= MAX_MEDIA_COUNT) {
|
|
showMessage(`Maximum ${MAX_MEDIA_COUNT} media files allowed per post`, "error");
|
|
break;
|
|
}
|
|
|
|
file.altText = file.name;
|
|
mediaFiles.push(file);
|
|
|
|
// Check video duration if it's a video
|
|
if (file.type.startsWith("video/")) {
|
|
checkVideoDuration(file);
|
|
}
|
|
}
|
|
|
|
document.getElementById("mediaInput").value = "";
|
|
updateMediaPreview();
|
|
updatePreview();
|
|
updateCharCounter();
|
|
}
|
|
|
|
function checkVideoDuration(videoFile) {
|
|
const video = document.createElement('video');
|
|
video.preload = 'metadata';
|
|
|
|
video.onloadedmetadata = function() {
|
|
videoFile.duration = video.duration;
|
|
if (video.duration > MAX_VIDEO_DURATION) {
|
|
showMessage(`Video ${videoFile.name} exceeds 2-minute limit (${Math.round(video.duration)}s)`, "error");
|
|
}
|
|
updatePreview();
|
|
};
|
|
|
|
video.onerror = function() {
|
|
// Couldn't load metadata, will check during upload
|
|
videoFile.duration = null;
|
|
};
|
|
|
|
video.src = URL.createObjectURL(videoFile);
|
|
}
|
|
|
|
function clearAllMedia() {
|
|
mediaFiles = [];
|
|
uploadedMediaIds = [];
|
|
document.getElementById("mediaInput").value = "";
|
|
updateMediaPreview();
|
|
updatePreview();
|
|
updateCharCounter();
|
|
showMessage("All media files cleared", "success");
|
|
}
|
|
|
|
// ================= DUPLICATE CHECKING LOGIC =================
|
|
async function checkForDuplicates() {
|
|
try {
|
|
const response = await fetch('/other/extra/scripts/fakesocialmedia/0ld/data_akkoma.json?t=' + Date.now());
|
|
const recentPosts = await response.json();
|
|
|
|
// ALWAYS use hiddenText for duplicate checking (first 60 chars)
|
|
const currentText = hiddenText.value.trim();
|
|
const previewText = currentText.slice(0, 60);
|
|
|
|
logToDom(`Checking against ${recentPosts.length} recent posts...`);
|
|
const latestPosts = recentPosts.slice(0, 5);
|
|
let duplicateFound = false;
|
|
|
|
for (const post of latestPosts) {
|
|
if (currentText.includes(post.preview) || post.preview.includes(previewText)) {
|
|
duplicateFound = true;
|
|
logToDom(`⚠️ Duplicate found: ${post.preview} (posted at ${post.date})`);
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (duplicateFound) {
|
|
postButton.disabled = true;
|
|
duplicateWarning.style.display = 'block';
|
|
logToDom("Post button disabled due to duplicate content");
|
|
} else {
|
|
postButton.disabled = false;
|
|
duplicateWarning.style.display = 'none';
|
|
logToDom("No duplicates found - posting enabled");
|
|
}
|
|
} catch (error) {
|
|
logToDom("Error checking for duplicates: " + error.message);
|
|
}
|
|
}
|
|
// ============================================================
|
|
|
|
async function blueskyCreateSession(identifier, password) {
|
|
try {
|
|
const response = await fetch(`${PDS_HOST}/xrpc/com.atproto.server.createSession`, {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify({
|
|
identifier: identifier,
|
|
password: password,
|
|
}),
|
|
});
|
|
|
|
const data = await response.json();
|
|
|
|
if (response.ok) {
|
|
return {
|
|
success: true,
|
|
accessJwt: data.accessJwt,
|
|
refreshJwt: data.refreshJwt,
|
|
did: data.did,
|
|
handle: data.handle,
|
|
};
|
|
} else {
|
|
return {
|
|
success: false,
|
|
error: data.message || `Login failed (HTTP ${response.status})`,
|
|
};
|
|
}
|
|
} catch (error) {
|
|
return {
|
|
success: false,
|
|
error: "Network error: " + error.message,
|
|
};
|
|
}
|
|
}
|
|
|
|
async function uploadBlob(accessJwt, file) {
|
|
try {
|
|
const response = await fetch(`${PDS_HOST}/xrpc/com.atproto.repo.uploadBlob`, {
|
|
method: "POST",
|
|
headers: {
|
|
Authorization: `Bearer ${accessJwt}`,
|
|
"Content-Type": file.type,
|
|
},
|
|
body: file,
|
|
});
|
|
|
|
const data = await response.json();
|
|
|
|
if (response.ok) {
|
|
return {
|
|
success: true,
|
|
blob: data.blob,
|
|
};
|
|
} else {
|
|
return {
|
|
success: false,
|
|
error: data.message || `Upload failed (HTTP ${response.status})`,
|
|
};
|
|
}
|
|
} catch (error) {
|
|
return {
|
|
success: false,
|
|
error: "Network error: " + error.message,
|
|
};
|
|
}
|
|
}
|
|
|
|
async function blueskyCreatePost(accessJwt, repo, text, mediaFiles = []) {
|
|
try {
|
|
const postData = {
|
|
repo: repo,
|
|
collection: "app.bsky.feed.post",
|
|
record: {
|
|
text: text,
|
|
createdAt: new Date().toISOString(),
|
|
$type: "app.bsky.feed.post",
|
|
},
|
|
};
|
|
|
|
// Add facets for hashtags and links (from the text being sent)
|
|
const facets = detectFacets(text);
|
|
if (facets.length > 0) {
|
|
postData.record.facets = facets;
|
|
}
|
|
|
|
// Handle media embeddings
|
|
if (mediaFiles.length > 0) {
|
|
const embed = {
|
|
$type: "app.bsky.embed.images",
|
|
images: [],
|
|
};
|
|
|
|
for (const file of mediaFiles) {
|
|
const uploadResult = await uploadBlob(accessJwt, file);
|
|
|
|
if (!uploadResult.success) {
|
|
return {
|
|
success: false,
|
|
error: `Failed to upload ${file.name}: ${uploadResult.error}`,
|
|
};
|
|
}
|
|
|
|
embed.images.push({
|
|
image: uploadResult.blob,
|
|
alt: file.altText || file.name,
|
|
});
|
|
|
|
// Store the blob reference for JSON
|
|
uploadedMediaIds.push(uploadResult.blob.ref['$link'] || uploadResult.blob.cid || 'unknown');
|
|
}
|
|
|
|
postData.record.embed = embed;
|
|
}
|
|
|
|
const response = await fetch(`${PDS_HOST}/xrpc/com.atproto.repo.createRecord`, {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
Authorization: `Bearer ${accessJwt}`,
|
|
},
|
|
body: JSON.stringify(postData),
|
|
});
|
|
|
|
const data = await response.json();
|
|
|
|
if (response.ok) {
|
|
return {
|
|
success: true,
|
|
uri: data.uri,
|
|
cid: data.cid,
|
|
facetsCount: facets.length,
|
|
};
|
|
} else {
|
|
return {
|
|
success: false,
|
|
error: data.message || `Post failed (HTTP ${response.status})`,
|
|
};
|
|
}
|
|
} catch (error) {
|
|
return {
|
|
success: false,
|
|
error: "Network error: " + error.message,
|
|
};
|
|
}
|
|
}
|
|
|
|
async function handleLogin(event) {
|
|
event.preventDefault();
|
|
|
|
const handle = document.getElementById("handle").value.trim();
|
|
const password = document.getElementById("password").value.trim();
|
|
|
|
if (!handle || !password) {
|
|
showMessage("Please enter both handle and password", "error");
|
|
return;
|
|
}
|
|
|
|
showLoading("login", true);
|
|
|
|
const result = await blueskyCreateSession(handle, password);
|
|
|
|
if (result.success) {
|
|
state.accessJwt = result.accessJwt;
|
|
state.refreshJwt = result.refreshJwt;
|
|
state.did = result.did;
|
|
state.handle = result.handle;
|
|
state.repo = result.did;
|
|
|
|
currentHandleSpan.textContent = result.handle;
|
|
handleDisplay.classList.remove("hidden");
|
|
loginSection.classList.add("hidden");
|
|
postSection.classList.remove("hidden");
|
|
|
|
// Load latest •acws post and check for duplicates
|
|
await loadLatestAcwsPost();
|
|
|
|
showMessage(`✅ Successfully logged in as ${result.handle}`, "success");
|
|
|
|
// Clear sensitive URL parameters
|
|
if (window.history.replaceState) {
|
|
const url = new URL(window.location);
|
|
if (url.searchParams.has("pass")) {
|
|
url.searchParams.delete("pass");
|
|
url.searchParams.delete("user");
|
|
window.history.replaceState({}, "", url);
|
|
showMessage("Password removed from URL for security", "info");
|
|
}
|
|
}
|
|
} else {
|
|
showMessage(`❌ ${result.error}`, "error");
|
|
}
|
|
|
|
showLoading("login", false);
|
|
}
|
|
|
|
async function loadLatestAcwsPost() {
|
|
try {
|
|
// First fetch data_alcea.json to get the total count for ID generation
|
|
const countResponse = await fetch('data_alcea.json?t=' + Date.now());
|
|
const countData = await countResponse.json();
|
|
const totalCount = countData.length;
|
|
|
|
// Then fetch data_part_alcea.json for the actual content
|
|
const url = 'data_part_alcea.json?t=' + Date.now();
|
|
const res = await fetch(url);
|
|
const data = await res.json();
|
|
|
|
let latest = null;
|
|
for (let i = 0; i < data.length; i++) {
|
|
const entry = data[i];
|
|
const dateKey = Object.keys(entry)[0];
|
|
const valueObj = entry[dateKey];
|
|
if (valueObj && typeof valueObj.value === "string" && valueObj.value.includes("•acws")) {
|
|
latest = valueObj.value;
|
|
// Use totalCount from data_alcea.json for ID generation
|
|
alceaId = totalCount - i; // Reverse the order
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (latest) {
|
|
// Set BOTH textareas to the same initial value
|
|
mainText.value = latest;
|
|
hiddenText.value = latest;
|
|
logToDom(`Loaded latest •acws entry (ID: ${alceaId}, based on data_alcea.json count)`);
|
|
updateCharCounter();
|
|
await checkForDuplicates();
|
|
} else {
|
|
logToDom("No •acws entry found in data_part_alcea.json");
|
|
}
|
|
} catch (err) {
|
|
logToDom("Error loading JSON files: " + err.message);
|
|
}
|
|
}
|
|
|
|
async function postToBluesky() {
|
|
// ALWAYS use mainText for sending to Bluesky
|
|
const postText = mainText.value.trim();
|
|
|
|
// ALWAYS use hiddenText for preview in JSON
|
|
const preview = hiddenText.value.slice(0, 60);
|
|
|
|
if (!postText && mediaFiles.length === 0) {
|
|
showMessage("Please enter post content or add media", "error");
|
|
return;
|
|
}
|
|
|
|
if (postText.length > MAX_POST_LENGTH) {
|
|
showMessage(`Post exceeds ${MAX_POST_LENGTH} characters`, "error");
|
|
return;
|
|
}
|
|
|
|
// Check video durations before posting
|
|
for (const file of mediaFiles) {
|
|
if (file.type.startsWith("video/") && file.duration && file.duration > MAX_VIDEO_DURATION) {
|
|
showMessage(`Video ${file.name} exceeds 2-minute limit (${Math.round(file.duration)}s)`, "error");
|
|
return;
|
|
}
|
|
}
|
|
|
|
logToDom("Starting Bluesky post process...");
|
|
showLoading("post", true);
|
|
|
|
try {
|
|
// Clear previous media IDs
|
|
uploadedMediaIds = [];
|
|
|
|
const result = await blueskyCreatePost(state.accessJwt, state.did, postText, mediaFiles);
|
|
|
|
if (result.success) {
|
|
const now = new Date().toISOString();
|
|
|
|
// Convert Bluesky URI to web URL
|
|
const webUrl = result.uri
|
|
.replace('at://', 'https://bsky.app/profile/')
|
|
.replace('/app.bsky.feed.post/', '/post/');
|
|
|
|
const logObject = {
|
|
date: now,
|
|
url: webUrl,
|
|
preview: preview, // ALWAYS from hiddenText
|
|
mode: "bluesky",
|
|
mediaType: "text/plain",
|
|
isReply: false,
|
|
replyTo: null,
|
|
replyLocalId: null,
|
|
mentionUsed: null,
|
|
visibility: "public",
|
|
alceaId: alceaId,
|
|
mediaCount: mediaFiles.length,
|
|
mediaIds: uploadedMediaIds
|
|
};
|
|
|
|
jsonPreview.textContent = JSON.stringify(logObject, null, 2);
|
|
|
|
showMessage(`✅ Posted to Bluesky!`, "success");
|
|
logToDom(`✅ Posted. URL: ${webUrl} | Media: ${mediaFiles.length} file(s) | Facets: ${result.facetsCount || 0} | alceaId: ${alceaId}`);
|
|
logToDom(`📝 Sent text: "${postText.substring(0, 100)}${postText.length > 100 ? '...' : ''}"`);
|
|
logToDom(`📋 Preview from hidden text: "${preview}"`);
|
|
|
|
// Save to server (same endpoint as before)
|
|
await fetch("/other/extra/scripts/fakesocialmedia/0ld/save_post.php", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(logObject),
|
|
});
|
|
logToDom("📁 JSON sent to save_post.php");
|
|
|
|
// Save to localStorage
|
|
try {
|
|
const existingPosts = JSON.parse(localStorage.getItem('blueskyPosts') || '[]');
|
|
existingPosts.push(logObject);
|
|
localStorage.setItem('blueskyPosts', JSON.stringify(existingPosts));
|
|
logToDom("💾 JSON appended to localStorage");
|
|
} catch (storageError) {
|
|
logToDom("⚠️ Could not save to localStorage: " + storageError.message);
|
|
}
|
|
|
|
// Clear ONLY mainText (the editable one)
|
|
mainText.value = "";
|
|
// Keep hiddenText unchanged for future reference
|
|
updateCharCounter();
|
|
updateMediaPreview();
|
|
|
|
// Auto-load the updated local storage
|
|
setTimeout(loadLocalStoragePosts, 500);
|
|
|
|
} else {
|
|
showMessage(`❌ ${result.error}`, "error");
|
|
}
|
|
} catch (err) {
|
|
logToDom("❌ Error: " + err.message);
|
|
showMessage("❌ Error posting to Bluesky: " + err.message, "error");
|
|
}
|
|
|
|
showLoading("post", false);
|
|
}
|
|
|
|
function handleLogout() {
|
|
state = {
|
|
accessJwt: null,
|
|
refreshJwt: null,
|
|
did: null,
|
|
handle: null,
|
|
repo: null,
|
|
};
|
|
|
|
mediaFiles = [];
|
|
uploadedMediaIds = [];
|
|
alceaId = null;
|
|
|
|
loginSection.classList.remove("hidden");
|
|
postSection.classList.add("hidden");
|
|
handleDisplay.classList.add("hidden");
|
|
|
|
document.getElementById("loginForm").reset();
|
|
mainText.value = "";
|
|
hiddenText.value = "";
|
|
updateMediaPreview();
|
|
updateCharCounter();
|
|
duplicateWarning.style.display = 'none';
|
|
|
|
showMessage("Logged out successfully", "success");
|
|
}
|
|
|
|
function loadLocalStoragePosts() {
|
|
try {
|
|
const posts = JSON.parse(localStorage.getItem('blueskyPosts') || '[]');
|
|
const container = document.getElementById('localStoragePosts');
|
|
|
|
if (posts.length === 0) {
|
|
container.innerHTML = '<div class="info-box">No posts found in local storage</div>';
|
|
logToDom("No posts found in local storage");
|
|
return;
|
|
}
|
|
|
|
let html = `<p>Found ${posts.length} posts in local storage:</p>`;
|
|
|
|
posts.forEach((post, index) => {
|
|
const mediaInfo = post.mediaCount > 0 ? `📷 ${post.mediaCount} media file(s)` : 'No media';
|
|
html += `
|
|
<div class="media-item" style="border: 1px solid #ddd; padding: 10px; margin: 10px 0; background: white;">
|
|
<strong>#${index + 1}:</strong> ${post.date}<br>
|
|
<strong>Preview:</strong> ${post.preview}<br>
|
|
<strong>URL:</strong> <a href="${post.url}" target="_blank">${post.url.substring(0, 50)}...</a><br>
|
|
<strong>Mode:</strong> ${post.mode}<br>
|
|
<strong>Media:</strong> ${mediaInfo}<br>
|
|
<strong>alceaId:</strong> ${post.alceaId}<br>
|
|
<button onclick="viewPostDetails(${index})" style="margin-top: 5px; padding: 3px 8px; font-size: 0.8em;">View Details</button>
|
|
<div id="postDetails-${index}" style="display: none; margin-top: 10px; padding: 10px; background: #f9f9f9;">
|
|
<pre>${JSON.stringify(post, null, 2)}</pre>
|
|
</div>
|
|
</div>
|
|
`;
|
|
});
|
|
|
|
container.innerHTML = html;
|
|
logToDom(`Loaded ${posts.length} posts from local storage`);
|
|
} catch (error) {
|
|
logToDom("Error loading local storage posts: " + error.message);
|
|
}
|
|
}
|
|
|
|
function viewPostDetails(index) {
|
|
const detailsElement = document.getElementById(`postDetails-${index}`);
|
|
if (detailsElement.style.display === 'none') {
|
|
detailsElement.style.display = 'block';
|
|
} else {
|
|
detailsElement.style.display = 'none';
|
|
}
|
|
}
|
|
|
|
function clearLocalStoragePosts() {
|
|
if (confirm("Are you sure you want to clear all posts from local storage?")) {
|
|
localStorage.removeItem('blueskyPosts');
|
|
document.getElementById('localStoragePosts').innerHTML =
|
|
'<div class="info-box">Local storage cleared</div>';
|
|
logToDom("Local storage cleared");
|
|
}
|
|
}
|
|
|
|
function init() {
|
|
// Set up event listeners
|
|
document.getElementById("loginForm").addEventListener("submit", handleLogin);
|
|
document.getElementById("logoutBtn").addEventListener("click", handleLogout);
|
|
|
|
// Update preview when mainText changes
|
|
mainText.addEventListener("input", function() {
|
|
updateCharCounter();
|
|
updatePreview();
|
|
// DON'T update hiddenText - it stays unchanged
|
|
});
|
|
|
|
document.getElementById("mediaInput").addEventListener("change", handleMediaUpload);
|
|
document.getElementById("addMediaBtn").addEventListener("click", () => document.getElementById("mediaInput").click());
|
|
document.getElementById("clearMediaBtn").addEventListener("click", clearAllMedia);
|
|
|
|
document.getElementById("autoDetectHashtags").addEventListener("change", updatePreview);
|
|
document.getElementById("autoDetectLinks").addEventListener("change", updatePreview);
|
|
|
|
// Check URL parameters for auto-login
|
|
const urlParams = new URLSearchParams(window.location.search);
|
|
const handle = urlParams.get("user");
|
|
const password = urlParams.get("pass");
|
|
|
|
if (handle) document.getElementById("handle").value = handle;
|
|
if (password) document.getElementById("password").value = password;
|
|
|
|
updateCharCounter();
|
|
updatePreview();
|
|
logToDom("Bluesky Poster initialized");
|
|
}
|
|
|
|
document.addEventListener("DOMContentLoaded", init);
|
|
</script>
|
|
</body>
|
|
</html> |