diff --git a/SELF_HOSTING.md b/SELF_HOSTING.md index 2023b2b..67da18f 100644 --- a/SELF_HOSTING.md +++ b/SELF_HOSTING.md @@ -138,7 +138,8 @@ npm run maintenance -- import-legacy /absolute/path/to/private-bundle ``` The import validates the bundle before writing, preserves project IDs, copies -images into persistent uploads, and replaces only untouched sample content. +images into persistent uploads, preserves matching dark-mode image variants, +and replaces only untouched sample content. It refuses repeated imports or edited portfolios. Creating an owner is independent. For an existing, edited installation, migrate only references to the old bundled diff --git a/src/components/ProjectThumbnail.tsx b/src/components/ProjectThumbnail.tsx index aa00373..0c2c0ef 100644 --- a/src/components/ProjectThumbnail.tsx +++ b/src/components/ProjectThumbnail.tsx @@ -11,13 +11,11 @@ interface ProjectThumbnailProps { className?: string; } -const getDarkImagePath = (src: string) => src.replace(/(\.[^./]+)$/, '-dark$1'); - export const ProjectThumbnail: FC = ({ image, title, className }) => { const { theme } = useTheme(); - const darkImage = getDarkImagePath(image.src); + const darkImage = image.darkSrc ?? image.src; const [unavailableDarkImage, setUnavailableDarkImage] = useState(null); - const useDarkImage = theme === 'dark' && unavailableDarkImage !== darkImage; + const useDarkImage = theme === 'dark' && darkImage !== image.src && unavailableDarkImage !== darkImage; return ( { if (/^\/media\/[a-zA-Z0-9.-]+$/.test(value)) return true; try { return ['https:', 'http:', 'mailto:', 'tel:'].includes(new URL(value).protocol); } catch { return false; } }, 'Use an https, http, mailto, tel, or uploaded-media URL'); +const imagePathSchema = z.string().regex(/^\/(?:images|media)\/[a-zA-Z0-9/_().-]+$/).refine(value => !value.includes('..'), 'Invalid image path'); export const imageSchema = z.object({ - src: z.string().regex(/^\/(?:images|media)\/[a-zA-Z0-9/_().-]+$/).refine(value => !value.includes('..'), 'Invalid image path'), + src: imagePathSchema, + darkSrc: imagePathSchema.optional(), width: z.number().int().min(1).max(20000), height: z.number().int().min(1).max(20000), }).strict(); const strings = z.array(text).max(200); diff --git a/src/server/legacy-import.ts b/src/server/legacy-import.ts index 25d6305..36edb3e 100644 --- a/src/server/legacy-import.ts +++ b/src/server/legacy-import.ts @@ -55,11 +55,20 @@ function readBundle(directory?: string) { return { content, files }; } -function rewriteMedia(value: T, urls: Map): T { +function rewriteMedia(value: T, urls: Map, strict = true): T { + const darkVariants = new Map(); + for (const [source, target] of urls) { + const dark = urls.get(source.replace(/(\.[^./]+)$/, '-dark$1')); + if (dark) darkVariants.set(target, dark); + } return JSON.parse(JSON.stringify(value), (_key, item) => { + if (item && typeof item === 'object' && typeof item.src === 'string') { + const darkSrc = darkVariants.get(item.src); + if (darkSrc && !item.darkSrc) return { ...item, darkSrc }; + } if (typeof item !== 'string') return item; if (urls.has(item)) return urls.get(item); - if ((item.startsWith('/images/') && item !== '/images/portfolio-placeholder.svg') || item.startsWith('/media/')) throw new ContentError(`Missing bundle media: ${item}`); + if (strict && ((item.startsWith('/images/') && item !== '/images/portfolio-placeholder.svg') || item.startsWith('/media/'))) throw new ContentError(`Missing bundle media: ${item}`); return item; }) as T; } @@ -80,7 +89,7 @@ export function importLegacy(connection: DatabaseConnection, directory?: string, const imported = rewriteMedia(bundle.content, urls); const current = readContent(connection); // Existing uploads and unrelated edits survive a media-only migration. - const migrated = JSON.parse(JSON.stringify(current), (_key, item) => typeof item === 'string' ? urls.get(item) ?? item : item) as ContentSnapshot; + const migrated = rewriteMedia(current, urls, false); const uploads = path.join(destination, 'uploads'); mkdirSync(uploads, { recursive: true, mode: 0o700 }); for (const file of bundle.files) { diff --git a/src/types/media.ts b/src/types/media.ts index 6360387..1ddacb7 100644 --- a/src/types/media.ts +++ b/src/types/media.ts @@ -1,5 +1,6 @@ export interface PortfolioImage { src: string; + darkSrc?: string; width: number; height: number; blurDataURL?: string;