diff --git a/.gitignore b/.gitignore index 6a2cd63..4cc2ac3 100644 --- a/.gitignore +++ b/.gitignore @@ -29,6 +29,7 @@ Thumbs.db /data/*.turso-* /data/backups/ /data/avatars/ +/data/game-icons/ /data/avatar-sources/ # Vite diff --git a/database/schema.sql b/database/schema.sql index 42c77c9..92ccdf7 100644 --- a/database/schema.sql +++ b/database/schema.sql @@ -404,3 +404,11 @@ CREATE TABLE IF NOT EXISTS os_protocol_talent_effects ( UNIQUE(talent_id, stat_attribute_id), UNIQUE(talent_id, sort_order) ); +CREATE TABLE IF NOT EXISTS game_data_icons ( + entity_type TEXT NOT NULL CHECK(entity_type IN ('talent', 'brand_set', 'os_protocol')), + entity_id TEXT NOT NULL, + storage_key TEXT NOT NULL UNIQUE, + byte_size INTEGER NOT NULL CHECK(byte_size > 0), + updated_at TEXT NOT NULL, + PRIMARY KEY(entity_type, entity_id) +); diff --git a/docs/DESIGN_SYSTEM.md b/docs/DESIGN_SYSTEM.md index 61e3f6c..4d04159 100644 --- a/docs/DESIGN_SYSTEM.md +++ b/docs/DESIGN_SYSTEM.md @@ -416,6 +416,34 @@ disclosure header always exposes the configured total, uses a textual title, and rotates the plus indicator when expanded. The full matrix remains keyboard operable and follows the shared hover, focus-visible, and pressed states. +### Game-data icons + +OS Protocols, brand sets, and talents use the shared game-data icon upload +control in their ISAC editors. Existing artwork is previewed and may be +replaced or explicitly removed. Registry identity cells render a compact +thumbnail before the record name and retain a neutral textual fallback when no +icon is configured. + +Source artwork must be a transparent SVG, PNG, WebP, or AVIF no larger than +2 MiB. SVG is treated only as an upload source and never served directly. +Accepted images are normalized asynchronously to a metadata-free transparent +256 × 256 WebP with `contain`, preserving the full symbol without avatar-style +cropping. Image labels remain visible in the UI, so icons supplement rather +than replace textual identity. Upload, replace, remove, pending, validation, +focus-visible, and narrow-screen states follow the shared engineered form +language. + +For compact editors such as brand sets, place the icon rail beside the record +name and its primary configuration rows instead of giving the upload control a +full-width band. Keep destructive archive controls isolated on the left side of +the action bar and use the established `.moderation-action.danger` treatment; +save and cancel remain grouped on the right. + +Archived records that support restoration replace their destructive archive +control with a green outlined `.moderation-action.restore` control in the same +left-side position. The label must explicitly say “Unarchive” or “Restore”; +never communicate restoration by color or icon alone. + The dedicated unique-talent panel uses five interactive diamonds: no filled diamonds is the base definition, and each selected upgrade adds one fifth of the base until five upgrades show exactly twice the base effect. diff --git a/docs/architecture.md b/docs/architecture.md index 95276cc..80a1915 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -163,3 +163,18 @@ The unique primary value uses `base × (1 + (level − 1) / 49)` for levels 1– talent effects use `base × (1 + upgrades / 5)` for zero to five upgrades. Server actions validate all relationships and append the full owned graph to the existing `game_data` audit history. + +# Game-data icon storage + +Talents, brand sets, and OS Protocols reference uploaded artwork through the +polymorphic `game_data_icons` metadata table. Original uploads are never +retained. The shared server pipeline accepts transparent SVG, PNG, WebP, and +AVIF sources, rejects active or externally referenced SVG content, and uses +Sharp’s asynchronous native worker pool to rasterize and normalize every +accepted icon to a transparent 256 × 256 WebP. + +Normalized files live beneath the ignored `data/game-icons/` directory and are +served by the validated `/icons/game-data/[type]/[id]` endpoint with immutable +cache headers and MIME sniffing disabled. ISAC save actions process files +before mutation, apply upload or removal after record validation, and include +only safe icon metadata—not image bytes—in their audit snapshots. diff --git a/src/app.css b/src/app.css index 4a5c50b..cf40dc3 100644 --- a/src/app.css +++ b/src/app.css @@ -117,4 +117,7 @@ .protocol-profile-registry{margin-bottom:10px;border:1px solid var(--shd-border);background:#0d1011}.protocol-profile-registry>summary{margin:0;padding:9px 10px;cursor:pointer;list-style:none}.protocol-profile-registry>summary::-webkit-details-marker{display:none}.protocol-profile-registry>summary:hover,.protocol-profile-registry>summary:focus-visible{background:#171b1c}.protocol-profile-registry>summary:active{transform:translateY(1px);filter:brightness(.88)}.protocol-profile-registry>summary>div:last-child{display:flex;align-items:center;gap:10px}.protocol-profile-registry>summary i{color:var(--shd-orange);font-size:13px;font-style:normal;transition:transform .16s ease}.protocol-profile-registry[open]>summary{border-bottom:1px solid var(--shd-border);background:#131718}.protocol-profile-registry[open]>summary i{transform:rotate(45deg)}.protocol-profile-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:5px;padding:8px}.protocol-profile-grid>button{min-width:0;padding:7px;border:1px solid var(--shd-border);background:#111415;color:var(--shd-text-muted);text-align:left;cursor:pointer}.protocol-profile-grid>button:hover,.protocol-profile-grid>button:focus-visible{border-color:#8a672b;background:#171b1c}.protocol-profile-grid>button:active{transform:translateY(1px);filter:brightness(.88)}.protocol-profile-grid>button.configured{border-left:2px solid var(--shd-success)}.protocol-profile-grid header{display:flex;align-items:center;gap:6px;padding-bottom:6px;border-bottom:1px solid #292e2f}.protocol-profile-grid header .game-icon{width:23px;height:23px}.protocol-profile-grid header div{display:grid;gap:2px}.protocol-profile-grid header b{font-size:6px;text-transform:uppercase}.protocol-profile-grid header strong{color:#d7d9d5;font-size:8px}.protocol-profile-grid header>span{margin-left:auto;color:#6b7274;font-size:5px;font-weight:900;letter-spacing:.5px}.protocol-profile-grid>button.configured header>span{color:var(--shd-success)}.protocol-profile-grid>button>div{display:grid;gap:4px;margin-top:6px}.protocol-profile-grid>button>div span{display:flex;justify-content:space-between;gap:6px}.protocol-profile-grid>button>div small{overflow:hidden;color:#626a6c;font-size:5px;text-overflow:ellipsis;white-space:nowrap}.protocol-profile-grid>button>div b{color:#b8bdbe;font-size:6px}.protocol-profile-editor{border-top-color:var(--shd-success)}.shared-secondary-editor{display:grid;grid-template-columns:repeat(3,1fr);gap:7px}.selected-secondary-profile{margin:8px 0;border:1px solid #36563f;background:#101713}.selected-secondary-profile.unconfigured{border-color:#65403a;background:#1c1111}.selected-secondary-profile header{display:flex;justify-content:space-between;padding:7px 9px;border-bottom:1px solid currentColor;color:var(--shd-success);font-size:6px;letter-spacing:.8px}.selected-secondary-profile.unconfigured header{color:var(--shd-danger)}.selected-secondary-profile>div{display:grid;grid-template-columns:repeat(3,1fr)}.selected-secondary-profile>div span{display:flex;justify-content:space-between;padding:8px 9px;border-left:1px solid #2b3d30}.selected-secondary-profile>div span:first-child{border-left:0}.selected-secondary-profile small{color:#6f7b72;font-size:6px}.selected-secondary-profile b{font-size:8px}.selected-secondary-profile p{margin:0;padding:10px;color:#a87870;font-size:8px}.protocol-primary-editor{display:grid;grid-template-columns:1fr 180px 180px;gap:7px;margin-top:7px;padding:8px;border:1px solid var(--shd-border);background:#101314}.protocol-primary-editor .field{margin:0}.protocol-primary-editor>div{display:grid;align-content:center;gap:4px;padding:0 10px;border-left:2px solid var(--shd-orange)}.protocol-primary-editor>div span,.protocol-primary-editor>div small{color:#626a6c;font-size:6px}.protocol-primary-editor>div b{color:var(--shd-orange);font-size:15px}.protocol-row{grid-template-columns:1.2fr .65fr .8fr 1fr 1.1fr .55fr .3fr}@media(max-width:1100px){.protocol-profile-grid{grid-template-columns:repeat(3,minmax(0,1fr))}}@media(max-width:1000px){.protocol-profile-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.protocol-primary-editor{grid-template-columns:1fr 1fr}.protocol-primary-editor>div{grid-column:1/3}}@media(max-width:650px){.protocol-profile-grid,.selected-secondary-profile>div,.protocol-primary-editor,.shared-secondary-editor{grid-template-columns:1fr}.selected-secondary-profile>div span{border-left:0;border-top:1px solid #2b3d30}.protocol-primary-editor>div{grid-column:auto}} .weapon-exotic-talent{margin:10px 0;padding:10px;border:1px solid #765426;border-top:2px solid var(--shd-orange);background:#151208}.exotic-talent-identity{grid-template-columns:minmax(180px,.65fr) minmax(320px,1.35fr)!important}.exotic-talent-identity textarea{min-height:64px}.exotic-talent-identity small{display:block;margin-top:5px;color:#69665e;font-size:7px}.weapon-exotic-grid{display:grid;grid-template-columns:.8fr 1.2fr;gap:8px;margin-top:8px}.exotic-effect-list{display:grid;gap:5px}.exotic-effect-row{display:grid;grid-template-columns:28px minmax(150px,1fr) 85px 85px 28px;gap:5px;align-items:center}.exotic-effect-row>strong{height:32px;display:grid;place-items:center;background:#211a0e;color:var(--shd-orange);font-size:8px}.exotic-effect-row select,.exotic-effect-row input{height:32px;min-width:0;margin:0;padding:0 7px;font-size:8px}.exotic-effect-row button{height:32px;border:1px solid #563734;background:#1d1212;color:#da746c;cursor:pointer}.exotic-effect-row button:disabled{opacity:.35;cursor:not-allowed}@media(max-width:1050px){.weapon-exotic-grid{grid-template-columns:1fr}}@media(max-width:700px){.exotic-talent-identity{grid-template-columns:1fr!important}.exotic-effect-row{grid-template-columns:28px 1fr 1fr}.exotic-effect-row select{grid-column:2/4}.exotic-effect-row button{grid-column:3}} .protocol-identity-grid{grid-template-columns:minmax(280px,1.35fr) minmax(420px,1fr);gap:12px}.protocol-classification-stack{display:grid;gap:7px}.protocol-quality-picker{min-width:0;margin:0;padding:0;border:0}.protocol-quality-picker legend{color:var(--shd-text-muted);font-size:8px;font-weight:bold;letter-spacing:1px}.protocol-quality-picker>div{height:32px;display:grid;grid-template-columns:repeat(4,1fr);margin-top:5px;border:1px solid #363b3c;background:#0d0f10}.protocol-quality-picker label{min-width:0;border-left:1px solid var(--shd-border);cursor:pointer}.protocol-quality-picker label:first-child{border-left:0}.protocol-quality-picker input{position:absolute;opacity:0;pointer-events:none}.protocol-quality-picker span{height:100%;display:flex;align-items:center;justify-content:center;gap:4px}.protocol-quality-picker span i{width:5px;height:5px;background:currentColor}.protocol-quality-picker b{font-size:6px;text-transform:uppercase}.protocol-quality-picker label:hover span{background:#171b1c;filter:brightness(1.2)}.protocol-quality-picker label:active span{transform:translateY(1px);filter:brightness(.88)}.protocol-quality-picker input:checked+span{background:#171b1c;box-shadow:inset 0 -2px currentColor}.protocol-quality-picker input:focus-visible+span{outline:2px solid var(--shd-orange);outline-offset:2px}@media(max-width:1000px){.protocol-identity-grid{grid-template-columns:1fr}}@media(max-width:650px){.protocol-quality-picker b{font-size:5px}} +.icon-editor-identity,.talent-icon-identity,.exotic-icon-identity{display:grid;grid-template-columns:150px minmax(0,1fr);gap:9px;align-items:start}.icon-editor-identity>.field{margin:0}.talent-icon-identity>.form-grid,.exotic-icon-identity>.form-grid{min-width:0}.icon-upload-field{min-width:0;margin:0;padding:7px;border:1px solid var(--shd-border);background:#0d1011}.icon-upload-field legend{padding:0 5px;color:var(--shd-text-muted);font-size:7px;font-weight:900;letter-spacing:.8px}.icon-upload-preview{height:86px;display:grid;place-items:center;margin-bottom:6px;border:1px solid #292f30;background:#111415}.icon-upload-preview img{width:72px;height:72px;object-fit:contain}.icon-upload-preview>span{color:#596164;font-size:7px;letter-spacing:.8px}.icon-upload-field>label{min-height:30px;display:grid;place-items:center;border:1px solid #424849;background:#15191a;color:#d7d9d5;font-size:6px;font-weight:900;letter-spacing:.6px;cursor:pointer}.icon-upload-field>label:hover,.icon-upload-field>label:focus-within{border-color:var(--shd-orange);color:var(--shd-orange)}.icon-upload-field>label:active{transform:translateY(1px);filter:brightness(.88)}.icon-upload-field>label input[type=file]{position:absolute;width:1px;height:1px;opacity:0}.icon-upload-field>label.icon-remove{min-height:24px;display:flex;gap:5px;margin-top:5px;border-color:#563734;background:#1d1212;color:var(--shd-danger)}.icon-upload-field>label.icon-remove input{width:auto;margin:0;accent-color:var(--shd-danger)}.icon-upload-field>small{display:block;margin-top:5px;color:#596164;font-size:5px;line-height:1.4}.registry-icon-identity{display:flex!important;align-items:center;gap:7px;min-width:0}.registry-icon-identity>img,.registry-icon-identity>span{flex:0 0 30px;width:30px;height:30px;object-fit:contain}.registry-icon-identity>span{display:grid;place-items:center;border:1px solid #303536;color:#596164}.registry-icon-identity>div{display:grid;min-width:0}.registry-icon-identity>div strong,.registry-icon-identity>div small{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}@media(max-width:700px){.icon-editor-identity,.talent-icon-identity,.exotic-icon-identity{grid-template-columns:1fr}.icon-upload-field{max-width:none}.icon-upload-preview{height:70px}} +.brand-editor-body{display:grid;grid-template-columns:122px minmax(0,1fr);gap:10px;margin-top:8px;align-items:start}.brand-editor-body>div{min-width:0}.brand-set-editor .brand-name{max-width:none;margin:0 0 7px}.brand-set-editor .icon-upload-field{padding:6px}.brand-set-editor .icon-upload-preview{height:58px;margin-bottom:5px}.brand-set-editor .icon-upload-preview img{width:50px;height:50px}.brand-set-editor .icon-upload-field>label{min-height:25px}.brand-set-editor .icon-upload-field>label.icon-remove{min-height:22px}.brand-set-editor .effect-band{padding:7px}.brand-set-editor .form-actions{margin-top:10px;padding-top:10px}.brand-archive-action{min-height:40px;padding:0 13px}.brand-archive-action>b{font-size:9px;transform:rotate(90deg)}@media(max-width:850px){.brand-editor-body{grid-template-columns:1fr}.brand-set-editor .icon-upload-field{display:grid;grid-template-columns:70px 1fr;gap:5px}.brand-set-editor .icon-upload-field legend,.brand-set-editor .icon-upload-field>small{grid-column:1/3}.brand-set-editor .icon-upload-preview{grid-row:1/3;height:55px;margin:0}.brand-set-editor .icon-upload-field>label{min-height:25px}}@media(max-width:560px){.brand-set-editor .icon-upload-field{grid-template-columns:1fr}.brand-set-editor .icon-upload-field legend,.brand-set-editor .icon-upload-field>small{grid-column:auto}.brand-set-editor .icon-upload-preview{grid-row:auto}.brand-archive-action{width:100%}} +.brand-archive-action.restore>b{transform:none}.moderation-action.restore{border:1px solid #3d6849;background:#111d15;color:var(--shd-success)}.moderation-action.restore:hover,.moderation-action.restore:focus-visible{border-color:#69b77d;background:#17271c;color:#a4e5b6} diff --git a/src/lib/components/IconUploadField.svelte b/src/lib/components/IconUploadField.svelte new file mode 100644 index 0000000..ecc9b81 --- /dev/null +++ b/src/lib/components/IconUploadField.svelte @@ -0,0 +1,26 @@ + + +
diff --git a/src/lib/server/game-data-icons.ts b/src/lib/server/game-data-icons.ts new file mode 100644 index 0000000..ef3600e --- /dev/null +++ b/src/lib/server/game-data-icons.ts @@ -0,0 +1,131 @@ +import type { Database } from '@tursodatabase/database'; +import { mkdir, readFile, unlink, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import sharp from 'sharp'; + +export const GAME_ICON_TYPES = ['talent', 'brand_set', 'os_protocol'] as const; +export type GameDataIconType = (typeof GAME_ICON_TYPES)[number]; +export const MAX_GAME_ICON_BYTES = 2 * 1024 * 1024; +export const MAX_GAME_ICON_PIXELS = 12_000_000; +export const GAME_ICON_SIZE = 256; +const ENTITY_ID_PATTERN = /^[0-9a-f-]{36}$/i; +const STORAGE_KEY_PATTERN = /^(talent|brand_set|os_protocol)-[0-9a-f-]{36}\.webp$/i; + +export class GameDataIconValidationError extends Error {} + +export function gameDataIconStorageDirectory(): string { + return path.resolve(process.env.TDR_GAME_ICON_STORAGE_PATH || path.join(process.cwd(), 'data', 'game-icons')); +} + +function assertType(type: string): asserts type is GameDataIconType { + if (!GAME_ICON_TYPES.includes(type as GameDataIconType)) throw new Error('Invalid game-data icon type'); +} + +function storageKey(type: GameDataIconType, entityId: string): string { + if (!ENTITY_ID_PATTERN.test(entityId)) throw new Error('Invalid game-data entity identifier'); + return `${type}-${entityId}.webp`; +} + +function storagePath(key: string): string { + if (!STORAGE_KEY_PATTERN.test(key)) throw new Error('Invalid game-data icon storage key'); + return path.join(gameDataIconStorageDirectory(), key); +} + +export function gameDataIconUrl(type: GameDataIconType, entityId: string, updatedAt?: unknown): string { + assertType(type); + if (!ENTITY_ID_PATTERN.test(entityId)) return ''; + const version = typeof updatedAt === 'string' ? `?v=${encodeURIComponent(updatedAt)}` : ''; + return `/icons/game-data/${type}/${entityId}${version}`; +} + +export async function processGameDataIconUpload(file: File): Promise