feat: visual polish & UX — fade-in, theme toggle, scroll-to-top, footer, cursor, badge

- Add FadeIn component (IntersectionObserver scroll-reveal + load animation)
- Add dark/light theme toggle (useSyncExternalStore + localStorage)
- Add scroll-to-top button (appears after 300px scroll)
- Add configurable footer with social links (gallery.yaml)
- Add crosshair cursor on photo grid items
- Add 'X photos' count badge on subpage cover images
- Add light theme color palette in tokens.css
This commit is contained in:
ralksta
2026-02-14 11:47:26 +01:00
parent e26f091595
commit 52fa16c1c8
57 changed files with 4790 additions and 1984 deletions

View File

@@ -12,27 +12,24 @@ import { getConfig } from './config';
let _key: Buffer | null = null;
function getKey(): Buffer {
if (!_key) {
const apiKey = getConfig().immich.apiKey;
_key = crypto.createHash('sha256').update(apiKey).digest();
}
return _key;
if (!_key) {
const apiKey = getConfig().immich.apiKey;
_key = crypto.createHash('sha256').update(apiKey).digest();
}
return _key;
}
/**
* Encode an asset ID into an opaque URL-safe token.
*/
export function encodeAssetId(assetId: string): string {
const key = getKey();
// Deterministic IV from asset ID (same input → same token)
const iv = crypto.createHash('md5').update(assetId).digest();
const cipher = crypto.createCipheriv('aes-256-cbc', key, iv);
const encrypted = Buffer.concat([
cipher.update(assetId, 'utf8'),
cipher.final(),
]);
// Compact: base64url(iv + ciphertext)
return Buffer.concat([iv, encrypted]).toString('base64url');
const key = getKey();
// Deterministic IV from asset ID (same input → same token)
const iv = crypto.createHash('md5').update(assetId).digest();
const cipher = crypto.createCipheriv('aes-256-cbc', key, iv);
const encrypted = Buffer.concat([cipher.update(assetId, 'utf8'), cipher.final()]);
// Compact: base64url(iv + ciphertext)
return Buffer.concat([iv, encrypted]).toString('base64url');
}
/**
@@ -40,23 +37,22 @@ export function encodeAssetId(assetId: string): string {
* Returns null if the token is invalid.
*/
export function decodeAssetId(token: string): string | null {
try {
const key = getKey();
const data = Buffer.from(token, 'base64url');
if (data.length < 17) return null; // iv(16) + at least 1 byte
try {
const key = getKey();
const data = Buffer.from(token, 'base64url');
if (data.length < 17) return null; // iv(16) + at least 1 byte
const iv = data.subarray(0, 16);
const encrypted = data.subarray(16);
const decipher = crypto.createDecipheriv('aes-256-cbc', key, iv);
const decrypted = Buffer.concat([
decipher.update(encrypted),
decipher.final(),
]).toString('utf8');
const iv = data.subarray(0, 16);
const encrypted = data.subarray(16);
const decipher = crypto.createDecipheriv('aes-256-cbc', key, iv);
const decrypted = Buffer.concat([decipher.update(encrypted), decipher.final()]).toString(
'utf8',
);
// Validate it looks like a UUID
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
return uuidRegex.test(decrypted) ? decrypted : null;
} catch {
return null;
}
// Validate it looks like a UUID
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
return uuidRegex.test(decrypted) ? decrypted : null;
} catch {
return null;
}
}