diff --git a/docs/DESIGN_SYSTEM.md b/docs/DESIGN_SYSTEM.md index 1edf4ea..cf10163 100644 --- a/docs/DESIGN_SYSTEM.md +++ b/docs/DESIGN_SYSTEM.md @@ -1047,6 +1047,20 @@ 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. +### Weapon and gear renders + +Weapon and gear editors offer an optional item-render upload separate from the +icon system. Operators may save a definition without artwork, then add, replace, +or remove the render in a later edit. The upload accepts JPEG, PNG, WebP, and +AVIF up to 5 MiB. The server center-crops each upload to 1000 × 1000 and stores +it as WebP. It does not call remove.bg, trim the scene, or add transparency. + +Show the current square render in the upload control and as a compact square +thumbnail before the item name in ISAC registry rows. Keep the image decorative +when the adjacent text identifies the item. The control states the crop and +format contract so contributors do not confuse a render with a transparent +game-data icon. + 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”; diff --git a/scripts/managed-images-import.mjs b/scripts/managed-images-import.mjs index ad15b2c..f9e1422 100644 --- a/scripts/managed-images-import.mjs +++ b/scripts/managed-images-import.mjs @@ -22,10 +22,13 @@ for (const image of bundle.images) { || typeof image.contentBase64 !== 'string') { throw new Error('The bundle contains an invalid managed image.'); } + const isItemRender = ['weapon_render', 'gear_render'].includes(image.row.source_entity_type); if (image.row.source_entity_type && ( typeof image.row.source_entity_id !== 'string' || !/^[0-9a-f-]{36}$/i.test(image.row.source_entity_id) - || !image.row.storage_key.startsWith(`${image.row.source_entity_type}-`) + || (isItemRender + ? !image.row.storage_key.startsWith('library-') + : !image.row.storage_key.startsWith(`${image.row.source_entity_type}-`)) )) throw new Error('The bundle contains an invalid linked ISAC image.'); } diff --git a/src/app.css b/src/app.css index f6e8bf0..189342c 100644 --- a/src/app.css +++ b/src/app.css @@ -94,6 +94,8 @@ .avatar-picker{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:6px;margin-bottom:10px}.avatar-preset-form,.avatar-preset-form fieldset{min-width:0;height:100%}.avatar-preset{display:grid;gap:5px;width:100%;height:100%;min-width:0;padding:4px;background:#0b0e0f;border:1px solid #303536;color:#747c7e}.avatar-preset:hover,.avatar-preset:focus-visible{border-color:#8a672b;color:#f6a821}.avatar-preset.selected{border-color:#f6a821;color:#f6a821;box-shadow:inset 0 -2px #f6a821}.avatar-preset img{display:block;width:100%;aspect-ratio:1;object-fit:cover}.avatar-preset span{min-width:0;overflow-wrap:anywhere;font-size:var(--shd-type-telemetry);line-height:1.25;text-transform:uppercase;letter-spacing:.4px}.avatar-upload{min-width:0;margin-top:8px}.account-console .image-drop-input{grid-template-columns:64px minmax(0,1fr)}.account-console .image-upload-actions{grid-column:1/-1;display:grid;grid-template-columns:1fr 1fr;width:100%}.account-console .image-upload-actions button{min-width:0} .image-drop-input{display:grid;grid-template-columns:64px 1fr auto;align-items:center;gap:10px;min-height:82px;padding:8px;border:1px dashed #4b5152;background:#0b0e0f;cursor:pointer;transition:border-color .15s,background .15s,transform .1s}.image-drop-input:hover,.image-drop-input:focus-visible,.image-drop-input.dragging{border-color:var(--shd-orange);background:#15130e;outline:none}.image-drop-input.dragging{transform:translateY(1px);box-shadow:inset 0 0 0 1px #f6a82155}.image-file-input{position:absolute!important;width:1px!important;height:1px!important;overflow:hidden!important;clip:rect(0 0 0 0)!important;white-space:nowrap!important}.image-upload-preview{display:grid;place-items:center;width:64px;height:64px;border:1px solid #343a3b;background:#070909;color:#62696b;font-size:var(--shd-type-telemetry);text-align:center}.image-upload-preview img{display:block;width:100%;height:100%;object-fit:contain}.image-drop-copy{display:grid;gap:4px;min-width:0}.image-drop-copy b{color:#c6cbcc;font-size:var(--shd-type-telemetry);letter-spacing:1px}.image-drop-copy span{overflow:hidden;color:#747c7e;font-size:var(--shd-type-telemetry);text-overflow:ellipsis;white-space:nowrap}.image-upload-actions{display:flex;gap:5px}.image-upload-actions button{min-width:92px;padding:8px}.image-upload-error{display:block;margin-top:5px;color:var(--shd-danger)}@media(max-width:700px){.image-drop-input{grid-template-columns:56px 1fr}.image-upload-preview{width:56px;height:56px}.image-upload-actions{grid-column:1/-1}.image-upload-actions button{flex:1}} .icon-upload-field .image-drop-input{grid-template-columns:1fr;gap:6px;text-align:center}.icon-upload-field .image-upload-preview{width:100%;height:72px}.icon-upload-field .image-upload-preview img{width:64px;height:64px}.icon-upload-field .image-upload-actions{display:grid;grid-template-columns:1fr 1fr;width:100%}.icon-upload-field .image-upload-actions button{min-width:0;padding:7px 3px;font-size:var(--shd-type-telemetry)} +.game-data-render-upload{margin:8px 0;padding:8px;border:1px solid var(--shd-border);background:#0d1011}.game-data-render-upload legend{padding:0 5px;color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.8px}.game-data-render-upload .image-drop-input{grid-template-columns:84px minmax(0,1fr) auto}.game-data-render-upload .image-upload-preview{width:84px;height:84px}.game-data-render-upload .image-upload-preview img{object-fit:cover}.game-data-render-upload .image-drop-copy{justify-self:stretch;text-align:center}.game-data-render-upload .render-remove{display:flex;gap:6px;align-items:center;margin-top:6px;color:var(--shd-danger);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.6px}.game-data-render-upload .render-remove input{width:auto;margin:0;accent-color:var(--shd-danger)}.game-data-render-upload>small{display:block;margin-top:6px;color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);line-height:1.35} +.registry-render-identity{display:flex;align-items:center;gap:8px;min-width:0}.registry-render-identity>div{display:grid;gap:2px;min-width:0}.registry-render{width:42px;height:42px;flex:0 0 auto;object-fit:cover;border:1px solid var(--shd-border);background:#090b0c} .proposal-submitted,.data-access-banner{display:flex;align-items:center;gap:12px;margin-bottom:9px;padding:9px 11px;border-left:3px solid var(--shd-success);background:#101713;color:#a6cbb0}.proposal-submitted b,.data-access-banner b{font-size:var(--shd-type-telemetry);letter-spacing:1px}.proposal-submitted span,.data-access-banner span{font-size:var(--shd-type-telemetry);color:#7e9d86}.data-access-banner{border-left-color:#657174;background:#111617;color:#aeb7b9}.data-access-banner.contributor{border-left-color:var(--shd-orange);background:#19150e;color:#d8bc87}.data-access-error{margin:-3px 0 9px}.data-readonly form[method=post] button[type=submit],.data-readonly form[method=post] button:not([type]){filter:grayscale(1);opacity:.45}.admin-sidebar nav a.has-pending{grid-template-columns:minmax(0,1fr) 18px 18px}.admin-sidebar nav a.has-pending em{grid-column:2;display:grid;place-items:center;min-width:18px;height:18px;padding:0 5px;border:1px solid #a26c19;background:#211a0e;color:#ffc45d;font-size:var(--shd-type-telemetry);font-style:normal;font-weight:900;text-align:center}.admin-sidebar nav a.has-pending b{grid-column:3;justify-self:end}.admin-sidebar nav a.has-pending:hover em,.admin-sidebar nav a.has-pending.active em{border-color:var(--shd-orange);background:#2b2111;color:#fff1d0}.moderation-row{grid-template-columns:minmax(240px,1.5fr) minmax(140px,.8fr) 150px 80px 75px}.moderation-review{width:min(1120px,100%);max-height:calc(100vh - 32px);overflow:auto}.moderation-metadata{display:grid;grid-template-columns:repeat(4,1fr);gap:6px;margin:10px 0}.moderation-metadata span{display:grid;gap:4px;padding:8px;border:1px solid var(--shd-border);color:var(--shd-text-muted);font-size:var(--shd-type-telemetry)}.moderation-metadata b{color:var(--shd-text);font-size:var(--shd-type-telemetry)}.moderation-review details{margin:10px 0;border:1px solid var(--shd-border);background:#0b0e0f}.moderation-review summary{padding:9px;color:var(--shd-orange);font-size:var(--shd-type-telemetry);cursor:pointer}.moderation-review pre{max-height:260px;overflow:auto;margin:0;padding:10px;border-top:1px solid var(--shd-border);color:#9ca6a8;font-size:var(--shd-type-telemetry);white-space:pre-wrap}.proposal-change-preview{margin-top:10px}.proposal-preview-head{display:flex;align-items:center;justify-content:space-between;padding:8px 10px;border:1px solid var(--shd-border);background:#0d1011}.proposal-preview-head>div{display:grid;gap:3px}.proposal-preview-head span{font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:1px}.proposal-preview-head small{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry)}.proposal-preview-head>b{color:var(--shd-orange);font-size:var(--shd-type-telemetry)}.proposal-compare-grid{display:grid;grid-template-columns:1fr 1fr;gap:7px;margin-top:7px}.proposal-editor-preview{min-width:0;border:1px solid var(--shd-border);background:#111415}.proposal-editor-preview.before{border-top:2px solid #596164}.proposal-editor-preview.after{border-top:2px solid var(--shd-orange)}.proposal-editor-preview>header{display:flex;align-items:center;justify-content:space-between;min-height:32px;padding:6px 9px;border-bottom:1px solid var(--shd-border);background:#0d1011}.proposal-editor-preview>header span,.proposal-editor-preview>header b{font-size:var(--shd-type-telemetry);letter-spacing:.9px}.proposal-editor-preview>header b{color:var(--shd-text-muted)}.proposal-editor-preview.after>header b{color:var(--shd-orange)}.proposal-attribute-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:6px;padding:8px}.preview-field{min-width:0;display:grid;align-content:start;gap:5px}.preview-field.identity{grid-column:span 4}.preview-field>span{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);letter-spacing:.8px}.preview-field>strong,.preview-field.range>div{min-height:34px;display:flex;align-items:center;padding:7px 8px;border:1px solid #363b3c;background:#0d0f10;color:var(--shd-text);font-size:var(--shd-type-telemetry);overflow-wrap:anywhere}.preview-field.identity>strong{min-height:43px;font-size:13px}.preview-field.range{grid-column:span 2}.preview-field.range>div{display:grid;grid-template-columns:1fr auto 1fr;gap:6px}.preview-field.range strong:last-child{text-align:right}.preview-field.range i{color:#555d5f;font-size:var(--shd-type-telemetry);font-style:normal}.proposal-empty-state{min-height:180px;display:grid;place-content:center;gap:6px;text-align:center}.proposal-empty-state b{color:var(--shd-orange);font-size:var(--shd-type-telemetry);letter-spacing:1px}.proposal-empty-state span{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry)}.proposal-editor-preview.generic{margin-top:7px}.proposal-field-summary{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:6px;padding:8px}.proposal-field-summary>div{min-width:0;display:grid;align-content:start;gap:5px;padding:8px;border:1px solid #303536;background:#0d0f10}.proposal-field-summary span{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);letter-spacing:.8px}.proposal-field-summary strong{color:var(--shd-text);font-size:var(--shd-type-telemetry);overflow-wrap:anywhere}.proposal-field-summary small{max-height:42px;overflow:hidden;color:#687174;font-size:var(--shd-type-telemetry);overflow-wrap:anywhere}.decision-tabs{display:grid;grid-template-columns:1fr 1fr;gap:5px;margin:10px 0}.decision-tabs button{padding:9px;border:1px solid var(--shd-border);background:#111415;color:var(--shd-text-muted)}.decision-tabs button.active{border-color:var(--shd-orange);color:var(--shd-orange)}@media(max-width:800px){.moderation-row{grid-template-columns:1fr}.moderation-metadata,.proposal-compare-grid{grid-template-columns:1fr}.proposal-field-summary{grid-template-columns:1fr 1fr}}@media(max-width:520px){.proposal-attribute-grid,.proposal-field-summary{grid-template-columns:1fr}.preview-field.identity,.preview-field.range{grid-column:auto}} .account-tabs{width:100%;height:auto;margin:0 0 16px;display:grid;grid-template-columns:repeat(3,1fr);border:1px solid var(--shd-border)}.account-tabs button,.account-subtabs button{min-width:0;height:34px;padding:0 5px;border:0;border-left:1px solid var(--shd-border);border-bottom:2px solid transparent;background:#0d1011;color:#6f7779;font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.8px;cursor:pointer;transition:var(--shd-transition)}.account-tabs button:first-child,.account-subtabs button:first-child{border-left:0}.account-tabs button:hover,.account-tabs button:focus-visible,.account-subtabs button:hover,.account-subtabs button:focus-visible{color:var(--shd-text);background:#171b1c}.account-tabs button.active,.account-subtabs button.active{border-bottom-color:var(--shd-orange);background:#211a0e;color:var(--shd-orange)}.account-tabs button:active,.account-subtabs button:active{transform:translateY(1px);filter:brightness(.85)}.account-tabs button:focus-visible,.account-subtabs button:focus-visible{position:relative;z-index:1;outline:2px solid var(--shd-orange);outline-offset:2px}.account-panel{min-height:290px}.account-subtabs{display:grid;grid-template-columns:1fr 1fr;margin:0 0 10px;border:1px solid var(--shd-border)}.account-subtabs button{height:31px}.access-subtabs{display:grid;grid-template-columns:repeat(4,1fr);margin:-2px 0 17px;border:1px solid var(--shd-border);background:#0b0e0f}.access-subtabs button{min-width:0;height:42px;display:grid;place-content:center;gap:3px;padding:4px;border:0;border-left:1px solid var(--shd-border);border-bottom:2px solid transparent;background:transparent;color:var(--shd-text-dim);cursor:pointer;transition:var(--shd-transition)}.access-subtabs button:first-child{border-left:0}.access-subtabs button span{font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}.access-subtabs button b{font-size:var(--shd-type-telemetry)}.access-subtabs button:hover,.access-subtabs button:focus-visible{background:#171b1c;color:var(--shd-text)}.access-subtabs button.active{border-bottom-color:var(--shd-orange);background:#211a0e;color:var(--shd-orange)}.access-subtabs button:active{transform:translateY(1px);filter:brightness(.85)}.access-subtabs button:focus-visible{position:relative;z-index:1;outline:2px solid var(--shd-orange);outline-offset:2px}.account-retention{color:var(--shd-text-muted);font:12px/1.55 var(--shd-font-editorial)}.danger-eyebrow{color:var(--shd-danger)} .account-alert.error-alert{background:#261513;border-color:#75443b;border-left-color:#df8069}.account-alert.error-alert b{color:#df8069}.account-alert.error-alert span{color:#d2aaa1} diff --git a/src/lib/components/GameDataRenderUploadField.svelte b/src/lib/components/GameDataRenderUploadField.svelte new file mode 100644 index 0000000..830a5cc --- /dev/null +++ b/src/lib/components/GameDataRenderUploadField.svelte @@ -0,0 +1,16 @@ + + +
diff --git a/src/lib/server/managed-images.test.ts b/src/lib/server/managed-images.test.ts index 24fd70c..93526b1 100644 --- a/src/lib/server/managed-images.test.ts +++ b/src/lib/server/managed-images.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; +import sharp from 'sharp'; const { rmMock } = vi.hoisted(() => ({ rmMock: vi.fn() })); @@ -8,7 +9,7 @@ vi.mock('node:fs/promises', async (importOriginal) => ({ })); import { GAME_ICON_TYPES } from './game-data-icons'; -import { ManagedImageDeletionError, deleteManagedImage } from './managed-images'; +import { ManagedImageDeletionError, deleteManagedImage, processManagedImage } from './managed-images'; type Row = Record{editing.id ? 'MODIFY GEAR' : 'NEW GEAR'}