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
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

View File

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

View File

@@ -55,11 +55,20 @@ function readBundle(directory?: string) {
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) => {
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) {

View File

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