fix: preserve imported dark image variants

This commit is contained in:
2026-09-16 15:14:09 +03:30
parent 083a4e0b7b
commit 385703e5b5
5 changed files with 20 additions and 9 deletions

View File

@@ -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 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. It refuses repeated imports or edited portfolios. Creating an owner is independent.
For an existing, edited installation, migrate only references to the old bundled For an existing, edited installation, migrate only references to the old bundled

View File

@@ -11,13 +11,11 @@ interface ProjectThumbnailProps {
className?: string; className?: string;
} }
const getDarkImagePath = (src: string) => src.replace(/(\.[^./]+)$/, '-dark$1');
export const ProjectThumbnail: FC<ProjectThumbnailProps> = ({ image, title, className }) => { export const ProjectThumbnail: FC<ProjectThumbnailProps> = ({ image, title, className }) => {
const { theme } = useTheme(); const { theme } = useTheme();
const darkImage = getDarkImagePath(image.src); const darkImage = image.darkSrc ?? image.src;
const [unavailableDarkImage, setUnavailableDarkImage] = useState<string | null>(null); const [unavailableDarkImage, setUnavailableDarkImage] = useState<string | null>(null);
const useDarkImage = theme === 'dark' && unavailableDarkImage !== darkImage; const useDarkImage = theme === 'dark' && darkImage !== image.src && unavailableDarkImage !== darkImage;
return ( return (
<Image <Image

View File

@@ -10,8 +10,10 @@ export const linkSchema = z.string().max(2048).refine(value => {
if (/^\/media\/[a-zA-Z0-9.-]+$/.test(value)) return true; if (/^\/media\/[a-zA-Z0-9.-]+$/.test(value)) return true;
try { return ['https:', 'http:', 'mailto:', 'tel:'].includes(new URL(value).protocol); } catch { return false; } try { return ['https:', 'http:', 'mailto:', 'tel:'].includes(new URL(value).protocol); } catch { return false; }
}, 'Use an https, http, mailto, tel, or uploaded-media URL'); }, '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({ 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), width: z.number().int().min(1).max(20000), height: z.number().int().min(1).max(20000),
}).strict(); }).strict();
const strings = z.array(text).max(200); const strings = z.array(text).max(200);

View File

@@ -55,11 +55,20 @@ function readBundle(directory?: string) {
return { content, files }; return { content, files };
} }
function rewriteMedia<T>(value: T, urls: Map<string, string>): T { function rewriteMedia<T>(value: T, urls: Map<string, string>, strict = true): T {
const darkVariants = new Map<string, string>();
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) => { 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 (typeof item !== 'string') return item;
if (urls.has(item)) return urls.get(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; return item;
}) as T; }) as T;
} }
@@ -80,7 +89,7 @@ export function importLegacy(connection: DatabaseConnection, directory?: string,
const imported = rewriteMedia(bundle.content, urls); const imported = rewriteMedia(bundle.content, urls);
const current = readContent(connection); const current = readContent(connection);
// Existing uploads and unrelated edits survive a media-only migration. // 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'); const uploads = path.join(destination, 'uploads');
mkdirSync(uploads, { recursive: true, mode: 0o700 }); mkdirSync(uploads, { recursive: true, mode: 0o700 });
for (const file of bundle.files) { for (const file of bundle.files) {

View File

@@ -1,5 +1,6 @@
export interface PortfolioImage { export interface PortfolioImage {
src: string; src: string;
darkSrc?: string;
width: number; width: number;
height: number; height: number;
blurDataURL?: string; blurDataURL?: string;