fix: preserve imported dark image variants
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user