fuzzyworld

tools / files / separator-page / index.html

index.html

25.4 KB · history · blame

1<!DOCTYPE html>
2<html>
3
4<head>
5 <meta charset="utf-8" />
6 <title>Separator</title>
7 <link rel="icon" id="favicon" />
8 <script src="https://cdn.jsdelivr.net/npm/showdown/dist/showdown.min.js"></script>
9 <script src="https://apps.fuzzyworld.net/tools/separator-page/lz-string.js"></script>
10 <style>
11 body {
12 font-family: Arial, sans-serif;
13 margin: 0;
14 padding: 0;
15 display: flex;
16 flex-direction: column;
17 align-items: center;
18 justify-content: center;
19 height: 100%;
20 background-color: #f9f9f9;
21 }
22
23 .container {
24 width: 90%;
25 max-width: 600px;
26 background: #fff;
27 border: 1px solid #ddd;
28 border-radius: 8px;
29 box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
30 padding: 16px;
31 }
32
33 .header {
34 display: flex;
35 align-items: center;
36 gap: 12px;
37 margin-bottom: 8px;
38 }
39
40 .icon-preview {
41 width: 32px;
42 height: 32px;
43 border: 1px solid #ddd;
44 border-radius: 4px;
45 background-size: contain;
46 background-repeat: no-repeat;
47 background-position: center;
48 }
49
50 .title {
51 font-size: 1.5em;
52 font-weight: bold;
53 width: 100%;
54 outline: none;
55 border: none;
56 background: none;
57 }
58
59 .title[contenteditable="true"] {
60 border: 1px dashed #aaa;
61 padding: 4px;
62 border-radius: 4px;
63 }
64
65 .description-container {
66 position: relative;
67 }
68
69 .description {
70 font-size: 1em;
71 line-height: 1.5;
72 color: #555;
73 outline: none;
74 width: 100%;
75 display: none;
76 height: max-content;
77 }
78
79 .description-rendered {
80 font-size: 1em;
81 line-height: 1.5;
82 color: #555;
83 width: 100%;
84 }
85
86 .description[contenteditable="true"] {
87 border: 1px dashed #aaa;
88 padding: 8px;
89 border-radius: 4px;
90 display: block;
91 }
92
93 .toolbar {
94 margin-top: 16px;
95 display: flex;
96 flex-direction: column;
97 gap: 8px;
98 }
99
100 .info {
101 font-size: 0.9em;
102 color: #666;
103 text-align: center;
104 margin-top: 12px;
105 }
106
107 .drop-area {
108 border: 2px dashed #aaa;
109 padding: 8px;
110 text-align: center;
111 color: #666;
112 font-size: 0.9em;
113 border-radius: 4px;
114 }
115
116 .drop-area.dragover {
117 background-color: #eef;
118 border-color: #666;
119 }
120
121 .custom-icon-input {
122 display: flex;
123 gap: 8px;
124 align-items: center;
125 }
126
127 .custom-icon-input input {
128 flex-grow: 1;
129 }
130
131 .edit-button {
132 position: absolute;
133 top: 0;
134 right: 0;
135 background: #ddd;
136 border: none;
137 padding: 4px;
138 cursor: pointer;
139 border-radius: 4px;
140 }
141
142 .edit-button:hover {
143 background: #ccc;
144 }
145
146 .previous-icons {
147 display: flex;
148 flex-wrap: wrap;
149 gap: 8px;
150 margin-top: 8px;
151 }
152
153 .previous-icon {
154 width: 32px;
155 height: 32px;
156 border: 1px solid #ddd;
157 border-radius: 4px;
158 cursor: pointer;
159 background-size: cover;
160 background-position: center;
161 transition: transform 0.2s;
162 }
163
164 .previous-icon:hover {
165 transform: scale(1.1);
166 border-color: #999;
167 }
168
169 .previous-icons-container {
170 margin: 16px 0;
171 }
172
173 .previous-icons-container h3 {
174 margin: 0 0 8px 0;
175 font-size: 1.1em;
176 color: #333;
177 }
178 </style>
179</head>
180
181<body>
182
183 <div class="container">
184 <div class="header">
185 <div id="icon-preview" class="icon-preview" title="Icon Preview"></div>
186 <div id="title" class="title" contenteditable="true">Separator</div>
187 </div>
188
189 <hr />
190
191 <div class="description-container">
192 <textarea id="description" class="description" placeholder="Write your Markdown here..."># Info
193
194This is a separator page!
195## TODO
196
1971. Read this page
1981. Understand this page
1991. The page name, icon, and content are editable
2001. To edit the description, click the "Edit" button
2011. Enter Markdown text
2021. Click "Done" button to return to "view mode"
2031. Markdown contents will be rendered as HTML
2041. Content links will always open in a new tab
2051. All the information about a tab is stored under a specific id in local storage. Copy-pasting the url in a new tab will work, as long as you're in the same browser.
2061. Your data is stored in local storage, under the tab id.
2071. Local storage is accessible by any other script that's executing on the separator page (or on the same domain), so
208technically *some* scripts can get your local separator page info
209 </textarea>
210 <div id="description-rendered" class="description-rendered"></div>
211 <button id="edit-description" class="edit-button">Edit</button>
212 </div>
213
214 <hr />
215
216 <div class="previous-icons-container">
217 <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;">
218 <h3 style="margin: 0;">Previous Icons (shift-click to remove)</h3>
219 <div style="display: flex; gap: 8px;">
220 <button id="reset-icons"
221 style="padding: 4px 8px; font-size: 0.8em; background: #ddd; border: none; border-radius: 4px; cursor: pointer;">Reset</button>
222 </div>
223 </div>
224 <div class="previous-icons" id="previous-icons">
225 <!-- Previous icons will be dynamically added here -->
226 </div>
227 </div>
228
229 <div class="toolbar">
230 <div class="drop-area" id="drop-area">Drop a PNG here or paste an image URL</div>
231 <div class="custom-icon-input">
232 <input id="icon-url-input" type="text" placeholder="Paste an image URL here" />
233 <button id="apply-icon-button">Apply</button>
234 </div>
235 </div>
236
237 <div class="info">
238 Need icons? Check out <a href="https://flaticon.com" target="_blank">Flaticon</a>.
239 <br />
240 The default icons came from
241 <a href="https://www.flaticon.com/authors/icongeek26" target="_blank">icongeek26</a>'s
242 <a href="https://www.flaticon.com/search?author_id=296&style_id=1001&type=standard" target="_blank">
243 Outline collection</a>.
244 <br />
245 <a href="https://code.fuzzyworld.net/tools/tree/separator-page/" target="_blank">Source repo</a>.
246 </div>
247 </div>
248
249 <script>
250 const converter = new showdown.Converter();
251
252 /** @type {string} */
253 const tabId = new URLSearchParams(window.location.search).get("tab") || Math.floor(Date.now() / 1000).toString();
254
255 /** @type {string} */
256 const localStorageKey = `separator-tab-${tabId}`;
257
258 /** @type {HTMLElement} */
259 const iconPreview = document.getElementById("icon-preview");
260
261 /** @type {HTMLElement} */
262 const titleElement = document.getElementById("title");
263
264 /** @type {HTMLTextAreaElement} */
265 const descriptionElement = document.getElementById("description");
266
267 /** @type {HTMLElement} */
268 const descriptionRendered = document.getElementById("description-rendered");
269
270 /** @type {HTMLButtonElement} */
271 const editDescriptionButton = document.getElementById("edit-description");
272
273 /** @type {HTMLInputElement} */
274 const iconUrlInput = document.getElementById("icon-url-input");
275
276 /** @type {HTMLButtonElement} */
277 const applyIconButton = document.getElementById("apply-icon-button");
278
279 /** @type {HTMLDivElement} */
280 const dropArea = document.getElementById("drop-area");
281
282 /** @type {HTMLLinkElement} */
283 const faviconElement = document.getElementById("favicon");
284
285 /** @type {HTMLDivElement} */
286 const previousIconsContainer = document.getElementById("previous-icons");
287
288 /** @type {HTMLButtonElement} */
289 const resetIconsButton = document.getElementById("reset-icons");
290
291 // Global storage key for previous icons (shared across all tabs)
292 const previousIconsKey = "separator-previous-icons";
293 const maxPreviousIcons = 100; // Limit to 100 recent icons
294
295 // Default icons from the icons folder
296 const defaultIcons = [
297 "./icons/template-icon-0.png",
298 "./icons/template-icon-1.png",
299 "./icons/template-icon-2.png",
300 "./icons/template-icon-3.png",
301 "./icons/template-icon-4.png",
302 "./icons/template-icon-5.png",
303 "./icons/template-icon-6.png",
304 "./icons/template-icon-7.png",
305 "./icons/template-icon-8.png",
306 "./icons/template-icon-9.png",
307 "./icons/template-icon-10.png",
308 "./icons/template-icon-11.png",
309 "./icons/template-icon-12.png",
310 "./icons/template-icon-13.png"
311 ];
312
313 // Import a tab from the old address (pavelsafronov.gitlab.io). Browsers keep storage per site, so the
314 // redirect page there reads this tab's data and passes it along as #migrate=<URI-encoded JSON>.
315 // The payload is only trusted when we arrive from that site, because the description is rendered as HTML.
316 function handleMigrationData() {
317 const prefix = "#migrate=";
318 if (!window.location.hash.startsWith(prefix)) {
319 return;
320 }
321 const cleanUrl = `${window.location.origin}${window.location.pathname}?tab=${tabId}`;
322 try {
323 const cameFromOldSite = document.referrer.startsWith("https://pavelsafronov.gitlab.io/");
324 if (!cameFromOldSite) {
325 console.warn('Ignoring migration data: not from the old site. Referrer:', document.referrer);
326 return;
327 }
328 const payload = JSON.parse(decodeURIComponent(window.location.hash.substring(prefix.length)));
329 const toCurrentSite = (icon) => {
330 // Built-in icons were stored as absolute URLs on the old site, where they no longer exist.
331 const match = typeof icon === "string" && icon.match(/\/separator-page\/icons\/(template-icon-\d+\.png)$/);
332 return match ? new URL(`icons/${match[1]}`, window.location.href).href : icon;
333 };
334
335 // Keep the user's icon history too, behind whatever this site already has.
336 if (Array.isArray(payload.previousIcons)) {
337 const existing = JSON.parse(localStorage.getItem(previousIconsKey) || "[]");
338 const merged = [...existing, ...payload.previousIcons.map(toCurrentSite)];
339 const unique = [...new Set(merged)].slice(0, maxPreviousIcons);
340 localStorage.setItem(previousIconsKey, JSON.stringify(unique));
341 }
342
343 // Never overwrite a tab that already exists here.
344 const data = payload.data;
345 if (data && String(payload.tab) === tabId && !localStorage.getItem(localStorageKey)) {
346 data.icon = toCurrentSite(data.icon);
347 localStorage.setItem(localStorageKey, JSON.stringify(data));
348 console.debug('Imported tab from the old site:', data);
349 }
350 } catch (error) {
351 console.warn('Failed to import migration data:', error);
352 } finally {
353 history.replaceState(null, "", cleanUrl);
354 }
355 }
356
357 handleMigrationData();
358
359 // Load saved data
360 const savedData = JSON.parse(localStorage.getItem(localStorageKey) || "null");
361
362 // Function to handle legacy hash-based data
363 function handleLegacyHashData() {
364 console.debug('Checking for legacy hash data...');
365 const hash = window.location.hash;
366 if (hash && hash.length > 1) {
367 try {
368 const base64String = hash.substring(1); // Remove the '#' character
369 const decompressed = LZString.decompressFromBase64(base64String);
370 if (decompressed) {
371 const legacyData = JSON.parse(decompressed);
372 console.debug('Loaded legacy hash data:', legacyData);
373
374 // Extract name and description from legacy format
375 const convertedData = {
376 title: legacyData.name || "Separator",
377 description: legacyData.description || "",
378 icon: null // Will be set below
379 };
380
381 // Use the first previous icon or first default icon as fallback
382 const previousIcons = JSON.parse(localStorage.getItem(previousIconsKey) || "[]");
383 const fallbackIcon = previousIcons.length > 0 ? previousIcons[0] : defaultIcons[0];
384 convertedData.icon = fallbackIcon;
385
386 // Clear the hash and update URL
387 const newUrl = `${window.location.origin}${window.location.pathname}?tab=${tabId}`;
388 history.replaceState(null, "", newUrl);
389
390 // Save the converted data to localStorage
391 localStorage.setItem(localStorageKey, JSON.stringify(convertedData));
392
393 return convertedData;
394 }
395 } catch (error) {
396 console.warn('Failed to parse legacy hash data:', error);
397 }
398 }
399 console.debug('No legacy hash data found.');
400 return null;
401 }
402
403 // Check for legacy hash data FIRST, before any URL manipulation
404 const legacyData = handleLegacyHashData();
405
406 const searchParams = new URLSearchParams(window.location.search);
407 if (!searchParams.has("tab")) {
408 const newUrl = `${window.location.origin}${window.location.pathname}?tab=${tabId}`;
409 history.replaceState(null, "", newUrl);
410 }
411
412 if (searchParams.has("name")) {
413 const name = searchParams.get("name");
414 if (name) {
415 titleElement.innerText = name;
416 document.title = name;
417 }
418 }
419 if (legacyData) {
420 renderAll(legacyData);
421 console.debug('Initialized with legacy data:', legacyData);
422 } else if (savedData) {
423 renderAll(savedData);
424 console.debug('Loaded saved data:', savedData);
425 } else {
426 handleIconChange(defaultIcons[0]); // Set the first default icon as the current icon
427 saveTabData();
428 }
429
430 // Initialize previous icons display
431 initializeDefaultIcons();
432 renderPreviousIcons();
433
434 function initializeDefaultIcons() {
435 const existingIcons = JSON.parse(localStorage.getItem(previousIconsKey) || "[]");
436
437 // If no icons exist, populate with defaults
438 if (existingIcons.length === 0) {
439 localStorage.setItem(previousIconsKey, JSON.stringify(defaultIcons));
440 console.log('Initialized with default icons');
441 handleIconChange(defaultIcons[0]); // Set the first default icon as the current icon
442 }
443 }
444
445 function resetToDefaultIcons() {
446 const confirmMessage = "Are you sure you want to reset to default icons?\n\nThis will permanently delete ALL your previous icons and replace them with the original template icons.\n\nThis action cannot be undone.";
447
448 if (!confirm(confirmMessage)) {
449 return; // User cancelled, do nothing
450 }
451
452 localStorage.setItem(previousIconsKey, JSON.stringify(defaultIcons));
453 renderPreviousIcons();
454
455 // Show feedback
456 const originalText = resetIconsButton.textContent;
457 resetIconsButton.textContent = "Reset!";
458 resetIconsButton.style.background = "#4CAF50";
459 setTimeout(() => {
460 resetIconsButton.textContent = originalText;
461 resetIconsButton.style.background = "#ddd";
462 }, 2000);
463
464 console.log('Reset to default icons');
465 }
466
467 function updateFavicon(iconUrl) {
468 console.debug('Updating favicon with URL:', iconUrl);
469 faviconElement.href = iconUrl;
470 }
471
472 function savePreviousIcon(iconUrl) {
473 // Skip default favicon and data URLs for data:image/x-icon
474 if (iconUrl.startsWith('data:image/x-icon')) {
475 return;
476 }
477
478 let previousIcons = JSON.parse(localStorage.getItem(previousIconsKey) || "[]");
479
480 // Remove the icon if it already exists to avoid duplicates
481 previousIcons = previousIcons.filter(icon => icon !== iconUrl);
482
483 // Add the new icon to the beginning
484 previousIcons.unshift(iconUrl);
485
486 // Limit to maxPreviousIcons
487 if (previousIcons.length > maxPreviousIcons) {
488 previousIcons = previousIcons.slice(0, maxPreviousIcons);
489 }
490
491 localStorage.setItem(previousIconsKey, JSON.stringify(previousIcons));
492 renderPreviousIcons();
493 }
494
495 function removePreviousIcon(iconUrl) {
496 let previousIcons = JSON.parse(localStorage.getItem(previousIconsKey) || "[]");
497 previousIcons = previousIcons.filter(icon => icon !== iconUrl);
498 localStorage.setItem(previousIconsKey, JSON.stringify(previousIcons));
499 renderPreviousIcons();
500 }
501
502 function renderPreviousIcons() {
503 const previousIcons = JSON.parse(localStorage.getItem(previousIconsKey) || "[]");
504 previousIconsContainer.innerHTML = "";
505
506 previousIcons.forEach((iconUrl, index) => {
507 const iconElement = document.createElement("div");
508 iconElement.className = "previous-icon";
509 iconElement.style.backgroundImage = `url('${iconUrl}')`;
510 iconElement.title = `Click to use • Shift+Click to remove • Ctrl+Click to download`;
511 iconElement.addEventListener("click", async (event) => {
512 if (event.shiftKey) {
513 removePreviousIcon(iconUrl);
514 } else if (event.ctrlKey || event.metaKey) {
515 // Download the individual icon
516 event.preventDefault();
517 const filename = `icon-${index + 1}.png`;
518 await downloadImageAsFile(iconUrl, filename);
519 } else {
520 handleIconChange(iconUrl);
521 }
522 });
523 previousIconsContainer.appendChild(iconElement);
524 });
525 }
526
527 async function downloadImageAsFile(url, filename) {
528 try {
529 // If it's a data URL, we can download it directly
530 if (url.startsWith('data:')) {
531 const link = document.createElement('a');
532 link.href = url;
533 link.download = filename;
534 document.body.appendChild(link);
535 link.click();
536 document.body.removeChild(link);
537 return true;
538 }
539
540 // For external URLs, we need to fetch and convert
541 const response = await fetch(url);
542 const blob = await response.blob();
543
544 // Convert to PNG if it's not already
545 const canvas = document.createElement('canvas');
546 const ctx = canvas.getContext('2d');
547 const img = new Image();
548
549 return new Promise((resolve) => {
550 img.onload = () => {
551 canvas.width = img.width;
552 canvas.height = img.height;
553 ctx.drawImage(img, 0, 0);
554
555 canvas.toBlob((pngBlob) => {
556 const link = document.createElement('a');
557 link.href = URL.createObjectURL(pngBlob);
558 link.download = filename;
559 document.body.appendChild(link);
560 link.click();
561 document.body.removeChild(link);
562 URL.revokeObjectURL(link.href);
563 resolve(true);
564 }, 'image/png');
565 };
566
567 img.onerror = () => {
568 console.warn(`Failed to load image: ${url}`);
569 resolve(false);
570 };
571
572 img.crossOrigin = 'anonymous';
573 img.src = URL.createObjectURL(blob);
574 });
575 } catch (error) {
576 console.warn(`Failed to download ${url}:`, error);
577 return false;
578 }
579 }
580
581 function saveTabData() {
582 const data = {
583 id: tabId,
584 icon: faviconElement.href,
585 title: titleElement.innerText,
586 description: descriptionElement.value,
587 };
588 console.debug('Saving tab data to localStorage:', data);
589 renderAll(data);
590 localStorage.setItem(localStorageKey, JSON.stringify(data));
591 }
592
593 function renderAll(data) {
594 iconPreview.style.backgroundImage = `url('${data.icon}')`;
595 if (titleElement.innerText !== data.title) {
596 titleElement.innerText = data.title;
597 }
598 document.title = titleElement.innerText || "Separator";
599 renderDescription(data.description);
600 updateFavicon(data.icon);
601 }
602
603 function renderDescription(description) {
604 descriptionElement.value = description;
605 descriptionElement.style.height = "";
606 descriptionElement.style.height = `${descriptionElement.scrollHeight}px`;
607 const html = converter.makeHtml(description);
608 descriptionRendered.innerHTML = html;
609 // update all rendered links to open in a new tab
610 const links = descriptionRendered.querySelectorAll("a");
611 links.forEach(link => {
612 link.setAttribute("target", "_blank");
613 link.setAttribute("rel", "noopener noreferrer");
614 });
615 }
616
617 function handleIconChange(iconUrl) {
618 iconPreview.style.backgroundImage = `url('${iconUrl}')`;
619 updateFavicon(iconUrl);
620 savePreviousIcon(iconUrl);
621 saveTabData();
622 }
623
624 // Toggle edit mode for description
625 editDescriptionButton.addEventListener("click", () => {
626 const isEditing = descriptionElement.style.display === "block";
627
628 console.debug('Toggling edit mode:', isEditing ? 'Done' : 'Edit');
629 descriptionElement.style.display = isEditing ? "none" : "block";
630 descriptionRendered.style.display = isEditing ? "block" : "none";
631 editDescriptionButton.innerText = isEditing ? "Edit" : "Done";
632
633 // renderDescription();
634 saveTabData();
635 });
636
637 // Drop area handling
638 dropArea.addEventListener("dragover", (event) => {
639 event.preventDefault();
640 dropArea.classList.add("dragover");
641 });
642
643 dropArea.addEventListener("dragleave", () => {
644 dropArea.classList.remove("dragover");
645 });
646
647 dropArea.addEventListener("drop", (event) => {
648 event.preventDefault();
649 dropArea.classList.remove("dragover");
650 const file = event.dataTransfer.files[0];
651 if (file && file.type.startsWith("image/")) {
652 const reader = new FileReader();
653 reader.onload = () => handleIconChange(reader.result);
654 reader.readAsDataURL(file);
655 }
656 });
657
658 // Clipboard paste handling
659 document.addEventListener("paste", (event) => {
660 const clipboardItems = event.clipboardData.items;
661 for (const item of clipboardItems) {
662 if (item.type.startsWith("image/")) {
663 const blob = item.getAsFile();
664 const reader = new FileReader();
665 reader.onload = () => handleIconChange(reader.result);
666 reader.readAsDataURL(blob);
667 break;
668 }
669 }
670 });
671
672 // Apply icon from URL
673 applyIconButton.addEventListener("click", () => {
674 const iconUrl = iconUrlInput.value.trim();
675 if (iconUrl) {
676 handleIconChange(iconUrl);
677 }
678 });
679
680 // Reset icons to defaults
681 resetIconsButton.addEventListener("click", resetToDefaultIcons);
682
683 // Title and description event listeners
684 titleElement.addEventListener("input", () => {
685 saveTabData();
686 });
687 </script>
688
689</body>
690
691</html>