feat: better previews and consistent previews

This commit is contained in:
2026-02-17 06:47:57 +01:00
parent 4ce1654f47
commit b2db7c6df0
15 changed files with 508 additions and 1241 deletions

View File

@@ -193,10 +193,31 @@
}
.editor-toolbar {
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.editor-toolbar-left {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
justify-content: flex-start;
}
.editor-toolbar-center {
display: flex;
align-items: center;
justify-content: center;
}
.editor-toolbar-right {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 6px;
min-width: 0;
}
.editor-mode-toggle {
@@ -233,7 +254,6 @@
border: none;
cursor: pointer;
transition: background-color 0.15s;
margin-left: auto;
}
.gallery-button:hover {
@@ -261,107 +281,26 @@
flex: 1;
background-color: var(--vscode-input-background);
border-radius: 4px;
padding: 16px;
overflow-y: auto;
font-size: 14px;
line-height: 1.6;
overflow: hidden;
position: relative;
}
/* Hydration loading overlay */
.preview-hydrating-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(30, 30, 30, 0.85);
.editor-preview-frame {
width: 100%;
height: 100%;
border: none;
background: var(--vscode-editor-background);
}
.editor-preview-loading {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
z-index: 10;
border-radius: 4px;
}
.preview-hydrating-content {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
color: var(--vscode-foreground);
font-size: 14px;
}
.preview-hydrating-spinner {
width: 32px;
height: 32px;
border: 3px solid var(--vscode-panel-border);
border-top-color: var(--vscode-focusBorder);
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.editor-preview .preview-content {
max-width: 800px;
margin: 0 auto;
}
.editor-preview h1,
.editor-preview h2,
.editor-preview h3 {
margin-top: 1.5em;
margin-bottom: 0.5em;
color: var(--vscode-foreground);
}
.editor-preview h1 { font-size: 2em; }
.editor-preview h2 { font-size: 1.5em; }
.editor-preview h3 { font-size: 1.25em; }
.editor-preview code {
background-color: var(--vscode-textCodeBlock-background);
padding: 2px 6px;
border-radius: 3px;
font-family: var(--vscode-editor-font-family);
}
.editor-preview pre {
background-color: var(--vscode-textCodeBlock-background);
padding: 12px;
border-radius: 6px;
overflow-x: auto;
}
.editor-preview pre code {
background: none;
padding: 0;
}
.editor-preview blockquote {
border-left: 3px solid var(--vscode-textBlockQuote-border);
padding-left: 16px;
margin-left: 0;
color: var(--vscode-descriptionForeground);
}
.editor-preview a {
color: var(--vscode-textLink-foreground);
}
.editor-preview a:hover {
color: var(--vscode-textLink-activeForeground);
}
.editor-preview img {
max-width: 100%;
border-radius: 6px;
cursor: pointer;
}
.editor-field-row {
display: flex;
gap: 12px;

View File

@@ -16,10 +16,8 @@ import { ImportAnalysisView } from '../ImportAnalysisView';
import { MetadataDiffPanel } from '../MetadataDiffPanel';
import { GitDiffView } from '../GitDiffView/GitDiffView';
import { AutoSaveManager, getContrastColor } from '../../utils';
import { parseMacros, getMacro } from '../../macros/registry';
import { InsertModal } from '../InsertModal';
import { AISuggestionsModal, AISuggestions } from '../AISuggestionsModal/AISuggestionsModal';
import { marked } from 'marked';
import './Editor.css';
/** Get display name for media: prefer title over originalName */
@@ -116,380 +114,6 @@ const resolveMediaUrls = (content: string, mediaList: MediaData[]): string => {
});
};
// Render a macro synchronously for preview
const renderMacroSync = (name: string, params: Record<string, string>, postId?: string): string => {
const macro = getMacro(name);
if (!macro) {
return `<span class="macro-error">Unknown macro: ${name}</span>`;
}
try {
const result = macro.render(params, { postId, isPreview: true });
// If it returns a promise, show loading state (shouldn't happen for gallery)
if (result instanceof Promise) {
return `<div class="macro-loading">Loading ${name}...</div>`;
}
return result;
} catch (e) {
return `<span class="macro-error">Error rendering ${name}</span>`;
}
};
// Simple markdown to HTML converter for preview
export const markdownToHtml = (markdown: string, postId?: string): string => {
// First, render macros
const macros = parseMacros(markdown);
let result = markdown;
// Replace macros from end to start to preserve positions
for (let i = macros.length - 1; i >= 0; i--) {
const macro = macros[i];
const rendered = renderMacroSync(macro.name, macro.params, postId);
result = result.slice(0, macro.start) + rendered + result.slice(macro.end);
}
return marked.parse(result, {
gfm: true,
breaks: false,
async: false,
}) as string;
};
/**
* Hydrate gallery elements in the preview with actual linked media
*/
const hydrateGalleries = async (
container: HTMLElement,
postId: string,
onImageClick: (index: number, images: { src: string; alt: string }[]) => void
) => {
const galleries = container.querySelectorAll('.macro-gallery[data-post-id]');
for (const gallery of galleries) {
const galleryPostId = gallery.getAttribute('data-post-id');
if (!galleryPostId || galleryPostId !== postId) continue;
const galleryContainer = gallery.querySelector('.gallery-container');
if (!galleryContainer) continue;
try {
// Load linked media for this post
const linkedData = await window.electronAPI?.postMedia.getMediaDataForPost(postId);
if (!linkedData || linkedData.length === 0) {
galleryContainer.innerHTML = '<div class="gallery-empty">No media linked to this post</div>';
continue;
}
// Filter to images only (media is nested in the link object)
const images = linkedData.filter(link => link.media?.mimeType?.startsWith('image/'));
if (images.length === 0) {
galleryContainer.innerHTML = '<div class="gallery-empty">No images linked to this post</div>';
continue;
}
// Build gallery grid (column count is handled via CSS class on parent)
galleryContainer.innerHTML = images.map((link, index) => `
<div class="gallery-item" data-index="${index}">
<img
src="bds-media://${link.media.id}"
alt="${link.media.alt || link.media.originalName}"
title="${link.media.title || link.media.originalName}"
/>
</div>
`).join('');
// Set up lightbox click handlers
const items = galleryContainer.querySelectorAll('.gallery-item');
const imageData = images.map(link => ({
src: `bds-media://${link.media.id}`,
alt: link.media.alt || link.media.originalName,
}));
items.forEach((item, index) => {
item.addEventListener('click', () => onImageClick(index, imageData));
});
} catch (error) {
console.error('Failed to hydrate gallery:', error);
galleryContainer.innerHTML = '<div class="gallery-error">Failed to load gallery</div>';
}
}
};
const FULL_MONTH_NAMES = [
'January', 'February', 'March', 'April', 'May', 'June',
'July', 'August', 'September', 'October', 'November', 'December'
];
// Track photo_archive hydration state to prevent duplicate runs
const photoArchiveHydratingCache = new WeakSet<HTMLElement>();
/**
* Hydrate photo_archive elements in the preview with actual media from the given year/month.
*/
const hydratePhotoArchive = async (
container: HTMLElement,
onImageClick: (index: number, images: { src: string; alt: string }[]) => void
) => {
// Match both year-based and recent-based archives
const archives = container.querySelectorAll('.macro-photo-archive[data-year], .macro-photo-archive[data-recent]');
if (archives.length === 0) {
return;
}
// Check if we're already hydrating (prevent duplicate runs)
if (photoArchiveHydratingCache.has(container)) {
console.log('[photo_archive] Skipping duplicate hydration for container');
return;
}
photoArchiveHydratingCache.add(container);
try {
await doHydratePhotoArchive(container, onImageClick, archives);
} finally {
// Clear the hydrating flag after a delay to allow for content changes
setTimeout(() => photoArchiveHydratingCache.delete(container), 500);
}
};
/**
* Internal implementation of photo_archive hydration
*/
const doHydratePhotoArchive = async (
_container: HTMLElement,
onImageClick: (index: number, images: { src: string; alt: string }[]) => void,
archives: NodeListOf<Element>
) => {
// Collect media for archive rendering based on current macros.
type ImageData = { id: string; originalName: string; alt?: string; mimeType: string; createdAt?: Date };
const archiveData: Array<{
element: Element;
mode: 'single-month' | 'full-year' | 'recent';
year?: number;
month?: number;
images?: ImageData[];
// Map key is "YYYY-MM" for recent mode, or month number (1-12) for year mode
monthlyImages?: Map<string | number, ImageData[]>;
showYearInLabel?: boolean;
}> = [];
console.log(`[photo_archive] Processing ${archives.length} archive macro(s)`);
for (const archive of archives) {
const recentStr = archive.getAttribute('data-recent');
const yearStr = archive.getAttribute('data-year');
const monthStr = archive.getAttribute('data-month');
if (recentStr) {
// Recent mode: get last N months with images
const recentCount = parseInt(recentStr, 10) || 10;
console.log(`[photo_archive] Recent mode: fetching last ${recentCount} months with images`);
// Fetch all images (no filter)
const allMedia = await window.electronAPI?.media.filter({});
const allImages = (allMedia || []).filter(m => m.mimeType?.startsWith('image/'));
// Group by year-month and sort by most recent
const monthlyMap = new Map<string, ImageData[]>();
for (const img of allImages) {
if (!img.createdAt) continue;
const date = new Date(img.createdAt);
const year = date.getFullYear();
const month = date.getMonth() + 1; // 1-based
const key = `${year}-${String(month).padStart(2, '0')}`; // e.g. "2024-06"
if (!monthlyMap.has(key)) {
monthlyMap.set(key, []);
}
monthlyMap.get(key)!.push(img);
}
// Sort by key descending (newest first) and take top N
const sortedKeys = Array.from(monthlyMap.keys()).sort().reverse().slice(0, recentCount);
const recentMonthlyImages = new Map<string, ImageData[]>();
for (const key of sortedKeys) {
const images = monthlyMap.get(key)!;
recentMonthlyImages.set(key, images);
}
const totalImages = Array.from(recentMonthlyImages.values()).reduce((sum, imgs) => sum + imgs.length, 0);
archiveData.push({
element: archive,
mode: 'recent',
monthlyImages: recentMonthlyImages,
showYearInLabel: true
});
console.log(`[photo_archive] Recent: ${totalImages} images across ${recentMonthlyImages.size} months`);
} else if (yearStr) {
const year = parseInt(yearStr, 10);
const month = monthStr ? parseInt(monthStr, 10) : undefined;
if (!year) continue;
if (month !== undefined) {
// Single month view
const mediaItems = await window.electronAPI?.media.filter({
year,
month: month - 1, // API uses 0-based month
});
const images = (mediaItems || []).filter(m => m.mimeType?.startsWith('image/'));
archiveData.push({ element: archive, mode: 'single-month', year, month, images });
console.log(`[photo_archive] Year ${year} month ${month}: ${images.length} images`);
} else {
// Full year view - collect all months, tracking which month each image belongs to
const monthlyImages = new Map<number, ImageData[]>();
for (let m = 0; m < 12; m++) {
const mediaItems = await window.electronAPI?.media.filter({
year,
month: m,
});
const images = (mediaItems || []).filter(item => item.mimeType?.startsWith('image/'));
if (images.length > 0) {
monthlyImages.set(m + 1, images); // Store with 1-based month key
}
}
const totalImages = Array.from(monthlyImages.values()).reduce((sum, imgs) => sum + imgs.length, 0);
archiveData.push({ element: archive, mode: 'full-year', year, month: undefined, monthlyImages });
console.log(`[photo_archive] Year ${year}: ${totalImages} images across ${monthlyImages.size} months`);
}
}
}
// Render galleries
for (const { element, mode, year, month, images, monthlyImages, showYearInLabel } of archiveData) {
const archiveContainer = element.querySelector('.photo-archive-container');
if (!archiveContainer) continue;
try {
// Render the gallery
let html = '';
if (mode === 'single-month' && month !== undefined && images && year) {
// Single month view
if (images.length === 0) {
archiveContainer.innerHTML = `<div class="photo-archive-empty">No photos found for ${FULL_MONTH_NAMES[month - 1]} ${year}</div>`;
continue;
}
html = buildMonthGallery(month, year, images, onImageClick, false);
} else if (mode === 'recent' && monthlyImages) {
// Recent mode - keys are "YYYY-MM" strings
if (monthlyImages.size === 0) {
archiveContainer.innerHTML = `<div class="photo-archive-empty">No recent photos found</div>`;
continue;
}
// Sort by key descending (newest first) - keys are "YYYY-MM" strings
const sortedEntries = Array.from(monthlyImages.entries())
.sort((a, b) => (b[0] as string).localeCompare(a[0] as string));
html = sortedEntries.map(([key, imgs]) => {
// Parse "YYYY-MM" to get year and month
const [yearStr, monthStr] = (key as string).split('-');
const entryYear = parseInt(yearStr, 10);
const entryMonth = parseInt(monthStr, 10);
return `<div class="photo-archive-month-wrapper">${buildMonthGallery(entryMonth, entryYear, imgs, onImageClick, true)}</div>`;
}).join('');
} else if (mode === 'full-year' && monthlyImages && year) {
// Full year view - keys are month numbers
if (monthlyImages.size === 0) {
archiveContainer.innerHTML = `<div class="photo-archive-empty">No photos found for ${year}</div>`;
continue;
}
// Sort months ascending (January first)
const sortedMonths = Array.from(monthlyImages.entries())
.sort((a, b) => (a[0] as number) - (b[0] as number));
html = sortedMonths.map(([m, imgs]) =>
`<div class="photo-archive-month-wrapper">${buildMonthGallery(m as number, year, imgs, onImageClick, showYearInLabel || false)}</div>`
).join('');
}
archiveContainer.innerHTML = html;
// Set up click handlers for all images
setupPhotoArchiveClickHandlers(archiveContainer, onImageClick);
} catch (error) {
console.error('Failed to hydrate photo archive:', error);
archiveContainer.innerHTML = '<div class="photo-archive-error">Failed to load photo archive</div>';
}
}
console.log('[photo_archive] Hydration complete.');
};
/**
* Build HTML for a single month's gallery with rotated month label
* @param month - 1-based month number (1 = January)
* @param year - The year
* @param images - Array of image data
* @param _onImageClick - Click handler (unused in template, set up separately)
* @param showYear - Whether to include the year in the label (e.g., "January 2024")
*/
function buildMonthGallery(
month: number,
year: number,
images: { id: string; originalName: string; alt?: string }[],
_onImageClick: (index: number, images: { src: string; alt: string }[]) => void,
showYear: boolean = false
): string {
const monthName = FULL_MONTH_NAMES[month - 1];
const labelText = showYear ? `${monthName} ${year}` : monthName;
return `
<div class="photo-archive-month" data-month="${month}" data-year="${year}">
<div class="photo-archive-month-label">
<span>${labelText}</span>
</div>
<div class="photo-archive-gallery gallery-lightbox">
${images.map((img, index) => `
<div class="photo-archive-item" data-index="${index}" data-media-id="${img.id}">
<img
src="bds-media://${img.id}"
alt="${img.alt || img.originalName}"
title="${img.title || img.originalName}"
/>
</div>
`).join('')}
</div>
</div>
`;
}
/**
* Set up click handlers for photo archive gallery items
*/
function setupPhotoArchiveClickHandlers(
container: Element,
onImageClick: (index: number, images: { src: string; alt: string }[]) => void
) {
// Find all month galleries
const monthGalleries = container.querySelectorAll('.photo-archive-month');
monthGalleries.forEach(monthGallery => {
const items = monthGallery.querySelectorAll('.photo-archive-item');
const imageData = Array.from(items).map(item => {
const img = item.querySelector('img');
return {
src: img?.getAttribute('src') || '',
alt: img?.getAttribute('alt') || '',
};
});
items.forEach((item, index) => {
item.addEventListener('click', () => onImageClick(index, imageData));
});
});
}
interface PostEditorProps {
postId: string;
}
@@ -543,17 +167,13 @@ export const PostEditor: React.FC<PostEditorProps> = ({ postId }) => {
const categoriesDropdownRef = useRef<HTMLDivElement>(null);
const [isSaving, setIsSaving] = useState(false);
const [hasPublishedVersion, setHasPublishedVersion] = useState(false);
const hydrationOverlayRef = useRef<HTMLDivElement>(null);
const isHydratingRef = useRef(false);
const previewContentRef = useRef<HTMLDivElement>(null);
const [editorMode, setEditorMode] = useState<EditorMode>(preferredEditorMode);
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const [lightboxOpen, setLightboxOpen] = useState(false);
const [lightboxIndex, setLightboxIndex] = useState(0);
const [galleryImages, setGalleryImages] = useState<{ src: string; alt: string }[]>([]);
const [showPostSearch, setShowPostSearch] = useState(false);
const [showMediaSearch, setShowMediaSearch] = useState(false);
const editorRef = useRef<unknown>(null);
const previewRef = useRef<HTMLDivElement>(null);
const isDirty = checkIsDirty(postId);
@@ -607,67 +227,30 @@ export const PostEditor: React.FC<PostEditorProps> = ({ postId }) => {
// Extract images from resolved content for lightbox
const images = useMarkdownImages(resolvedContent);
// Combine regular images with gallery images for lightbox
const allImages = useMemo(() => {
// If gallery images are set, use those; otherwise use extracted images
return galleryImages.length > 0 ? galleryImages : images;
}, [images, galleryImages]);
// Hydrate galleries and photo archives when in preview mode
useEffect(() => {
if (editorMode !== 'preview' || !previewRef.current || !previewContentRef.current) return;
if (editorMode !== 'preview') return;
let cancelled = false;
// Helper to show/hide the overlay without triggering React re-render
const showOverlay = (show: boolean) => {
if (hydrationOverlayRef.current) {
hydrationOverlayRef.current.style.display = show ? 'flex' : 'none';
}
};
// Set content immediately if not hydrating
// During hydration, we skip updating to preserve the hydrated DOM
if (!isHydratingRef.current) {
previewContentRef.current.innerHTML = markdownToHtml(resolvedContent, postId);
}
// Small delay to ensure DOM is updated
const timer = setTimeout(async () => {
if (cancelled || !previewRef.current) return;
const lightboxHandler = (index: number, imgs: { src: string; alt: string }[]) => {
setGalleryImages(imgs);
setLightboxIndex(index);
setLightboxOpen(true);
};
// Check if there are photo_archive macros that need hydration
const hasPhotoArchives = previewRef.current.querySelectorAll('.macro-photo-archive[data-year], .macro-photo-archive[data-recent]').length > 0;
if (hasPhotoArchives) {
isHydratingRef.current = true;
showOverlay(true);
}
try {
await hydrateGalleries(previewRef.current, postId, lightboxHandler);
setPreviewUrl(null);
window.electronAPI?.posts.getPreviewUrl(postId)
.then((url) => {
if (!cancelled) {
await hydratePhotoArchive(previewRef.current, lightboxHandler);
setPreviewUrl(url);
}
} finally {
// Always reset hydration state when complete - the ref is global to the component
isHydratingRef.current = false;
showOverlay(false);
}
}, 100);
})
.catch((error) => {
console.error('Failed to load post preview URL:', error);
if (!cancelled) {
setPreviewUrl(null);
}
});
return () => {
cancelled = true;
clearTimeout(timer);
};
}, [editorMode, postId, resolvedContent]);
}, [editorMode, postId]);
// Track latest values for auto-save on unmount/switch
const pendingChangesRef = useRef<{
@@ -1264,57 +847,63 @@ export const PostEditor: React.FC<PostEditorProps> = ({ postId }) => {
<div className="editor-body">
<div className="editor-toolbar">
<label>Content</label>
<div className="editor-mode-toggle">
<button
className={editorMode === 'wysiwyg' ? 'active' : ''}
onClick={() => handleEditorModeChange('wysiwyg')}
title="Visual editor"
>
Visual
</button>
<button
className={editorMode === 'markdown' ? 'active' : ''}
onClick={() => handleEditorModeChange('markdown')}
title="Markdown source"
>
Markdown
</button>
<button
className={editorMode === 'preview' ? 'active' : ''}
onClick={() => handleEditorModeChange('preview')}
title="Read-only preview"
>
Preview
</button>
<div className="editor-toolbar-left">
<label>Content</label>
</div>
{images.length > 0 && (
<button
className="gallery-button"
onClick={() => { setLightboxIndex(0); setLightboxOpen(true); }}
title={`View ${images.length} image(s)`}
>
📷 {images.length}
</button>
)}
{editorMode === 'markdown' && (
<>
<button
className="insert-post-link-button"
onClick={() => setShowPostSearch(true)}
title="Link to post (Ctrl+K)"
<div className="editor-toolbar-center">
<div className="editor-mode-toggle">
<button
className={editorMode === 'wysiwyg' ? 'active' : ''}
onClick={() => handleEditorModeChange('wysiwyg')}
title="Visual editor"
>
📝
Visual
</button>
<button
className="insert-media-button"
onClick={() => setShowMediaSearch(true)}
title="Insert image from media library"
<button
className={editorMode === 'markdown' ? 'active' : ''}
onClick={() => handleEditorModeChange('markdown')}
title="Markdown source"
>
🖼
Markdown
</button>
</>
)}
<button
className={editorMode === 'preview' ? 'active' : ''}
onClick={() => handleEditorModeChange('preview')}
title="Read-only preview"
>
Preview
</button>
</div>
</div>
<div className="editor-toolbar-right">
{images.length > 0 && (
<button
className="gallery-button"
onClick={() => { setLightboxIndex(0); setLightboxOpen(true); }}
title={`View ${images.length} image(s)`}
>
📷 {images.length}
</button>
)}
{editorMode === 'markdown' && (
<>
<button
className="insert-post-link-button"
onClick={() => setShowPostSearch(true)}
title="Link to post (Ctrl+K)"
>
📝
</button>
<button
className="insert-media-button"
onClick={() => setShowMediaSearch(true)}
title="Insert image from media library"
>
🖼
</button>
</>
)}
</div>
</div>
{editorMode === 'wysiwyg' && (
@@ -1353,27 +942,26 @@ export const PostEditor: React.FC<PostEditorProps> = ({ postId }) => {
)}
{editorMode === 'preview' && (
<div className="editor-preview markdown-body" ref={previewRef}>
<div className="preview-hydrating-overlay" ref={hydrationOverlayRef} style={{ display: 'none' }}>
<div className="preview-hydrating-content">
<div className="preview-hydrating-spinner" />
<span>Loading photo archive...</span>
</div>
</div>
<div
className="preview-content"
ref={previewContentRef}
/>
<div className="editor-preview">
{previewUrl ? (
<iframe
className="editor-preview-frame"
src={previewUrl}
title="Post preview"
/>
) : (
<div className="editor-preview-loading">Loading preview...</div>
)}
</div>
)}
</div>
{/* Lightbox for viewing images in content */}
<Lightbox
images={allImages}
images={images}
initialIndex={lightboxIndex}
isOpen={lightboxOpen}
onClose={() => { setLightboxOpen(false); setGalleryImages([]); }}
onClose={() => { setLightboxOpen(false); }}
/>
</div>
@@ -2197,6 +1785,13 @@ export const Editor: React.FC = () => {
const showMetadataDiff = activeTab?.type === 'metadata-diff';
const showGitDiff = activeTab?.type === 'git-diff';
useEffect(() => {
const activePostId = activeTab?.type === 'post' ? activeTab.id : null;
window.electronAPI?.app.setPreviewPostTarget(activePostId).catch((error) => {
console.error('Failed to sync preview post target:', error);
});
}, [activeTab]);
// Clear selectedPostId if the post doesn't exist (e.g., after project switch)
useEffect(() => {
if (activeView === 'posts' && selectedPostId && !isLoading) {

View File

@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' blob:; style-src 'self' 'unsafe-inline'; img-src 'self' data: file: blob: bds-media: bds-thumb:; worker-src 'self' blob:; font-src 'self' data:;" />
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; frame-src 'self' http://127.0.0.1:4123; script-src 'self' 'unsafe-inline' blob:; style-src 'self' 'unsafe-inline'; img-src 'self' data: file: blob: bds-media: bds-thumb:; worker-src 'self' blob:; font-src 'self' data:;" />
<title>Blogging Desktop Server</title>
</head>
<body>