From 7f9a75021d64cb796cfa790b423b33f9c1625203 Mon Sep 17 00:00:00 2001 From: JB Date: Sat, 25 Jul 2026 19:20:43 -0400 Subject: [PATCH] Add contact relay and DMCA workflow --- database/schema.sql | 18 +++ docs/DESIGN_SYSTEM.md | 44 +++++++ src/app.css | 8 +- src/lib/components/AppShell.svelte | 10 +- .../account/contact/[id]/+page.server.ts | 15 +++ src/routes/account/contact/[id]/+page.svelte | 18 +++ src/routes/account/notifications/+page.svelte | 1 + src/routes/contact/+page.server.ts | 107 ++++++++++++++++++ src/routes/contact/+page.svelte | 66 +++++++++++ src/routes/dmca/+page.svelte | 21 ++++ src/routes/terms/+page.svelte | 4 +- 11 files changed, 308 insertions(+), 4 deletions(-) create mode 100644 src/routes/account/contact/[id]/+page.server.ts create mode 100644 src/routes/account/contact/[id]/+page.svelte create mode 100644 src/routes/contact/+page.server.ts create mode 100644 src/routes/contact/+page.svelte create mode 100644 src/routes/dmca/+page.svelte diff --git a/database/schema.sql b/database/schema.sql index db736be..83c8a92 100644 --- a/database/schema.sql +++ b/database/schema.sql @@ -40,6 +40,24 @@ CREATE TABLE IF NOT EXISTS notifications ( ); CREATE INDEX IF NOT EXISTS notifications_user_created_idx ON notifications(user_id, created_at DESC); CREATE INDEX IF NOT EXISTS notifications_user_unread_idx ON notifications(user_id, read_at, created_at DESC); +CREATE TABLE IF NOT EXISTS contact_messages ( + id TEXT PRIMARY KEY, + sender_user_id TEXT REFERENCES users(id) ON DELETE SET NULL, + sender_name TEXT NOT NULL, + sender_email TEXT NOT NULL, + team TEXT NOT NULL CHECK(team IN ('oversight', 'administrators', 'dmca')), + subject TEXT NOT NULL, + body TEXT NOT NULL, + dmca_json TEXT, + created_at TEXT NOT NULL +); +CREATE TABLE IF NOT EXISTS contact_message_recipients ( + message_id TEXT NOT NULL REFERENCES contact_messages(id) ON DELETE CASCADE, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + PRIMARY KEY(message_id, user_id) +); +CREATE INDEX IF NOT EXISTS contact_messages_created_idx ON contact_messages(created_at DESC); +CREATE INDEX IF NOT EXISTS contact_message_recipients_user_idx ON contact_message_recipients(user_id, message_id); CREATE TABLE IF NOT EXISTS notification_preferences ( user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, category TEXT NOT NULL CHECK(category IN ('isac', 'contributions', 'comments', 'builds', 'guides', 'system')), diff --git a/docs/DESIGN_SYSTEM.md b/docs/DESIGN_SYSTEM.md index 25a1066..3f8693d 100644 --- a/docs/DESIGN_SYSTEM.md +++ b/docs/DESIGN_SYSTEM.md @@ -31,6 +31,50 @@ cannot render, `src/error.html` provides a dependency-free SHD fallback surface. Error controls retain square geometry, focus-visible treatment, pressed motion, plain-language labels, and stacked mobile recovery actions. +### Contact relay and legal requests + +Public support uses the SHD Contact Relay surface: a broad transmission form +paired with a compact destination guide. Two-to-six destinations use the shared +segmented-choice pattern. Signed-in identity details may be prefilled, but the +public form remains available to people who cannot sign in. Discord community +access is a distinct provider-colored card; it supplements rather than replaces +the durable contact channel. + +The Contact hero explicitly resets the global site-header height: semantic page +`header` elements must use content-driven height and spacing rather than inherit +the 76-pixel navigation-shell constraint. Its title, description, and Discord +card align within a minimum 230-pixel desktop band before the form grid begins. +The Contact page itself remains content-height-driven so high-resolution browser +zoom does not multiply a viewport minimum into a large empty region above the +footer. + +The application frame is a full-viewport vertical flex stack. The shared footer +uses automatic top margin, so it rests against the bottom edge on short routes +and follows normal document flow after longer content. Individual pages must +not add spacer heights solely to position the footer. The frame compensates for +`--shd-display-scale` when calculating its viewport minimum so high-resolution +browser zoom does not push a short-page footer below the visible edge. + +Community access in the shared footer uses a compact Discord-branded action +with the official symbol, provider blue surface, explicit external-link text, +and visible hover, focus, and pressed feedback. Keep provider branding separate +from the orange links reserved for SHD navigation and legal resources. Support +and legal links form a compact horizontal rail that may wrap at intermediate +widths and becomes a two-column touch-friendly grid on narrow mobile screens. + +Contact notifications use the mandatory ISAC visual identity because they are +operational inbound messages. The notification carries a concise preview and a +specific action to an authorization-checked full record. Full messages never +appear in URLs. Copyright requests add a yellow legal-detail subpanel, explicit +attestations, electronic signature, and a direct policy link. Do not visually +present a submission as a legal determination. + +The DMCA policy reuses the legal-document/index layout. Copyright and takedown +links must remain visible in the global footer alongside Contact Us. On mobile, +contact columns stack, destination segments become full-width 44-pixel rows, +attestations remain readable, and reply/recovery actions occupy the available +width. + ## Tokens The canonical tokens live in `src/app.css` under `:root`. diff --git a/src/app.css b/src/app.css index 10a2c46..024465c 100644 --- a/src/app.css +++ b/src/app.css @@ -28,8 +28,13 @@ --shd-display-scale:1; --shd-transition:transform .12s ease,filter .18s ease,opacity .18s ease,box-shadow .18s ease; } - *{box-sizing:border-box} html{scroll-behavior:smooth} body{margin:0;background:var(--shd-bg);color:var(--shd-text);font-family:Arial,Helvetica,sans-serif} button,input{font:inherit} a,a:hover,a:focus,a:focus-visible,a:active,a:visited{text-decoration:none} + *{box-sizing:border-box} html{scroll-behavior:smooth} body{min-height:calc(100vh / var(--shd-display-scale));display:flex;flex-direction:column;margin:0;background:var(--shd-bg);color:var(--shd-text);font-family:Arial,Helvetica,sans-serif} button,input{font:inherit} a,a:hover,a:focus,a:focus-visible,a:active,a:visited{text-decoration:none} .sr-only{position:absolute!important;width:1px!important;height:1px!important;padding:0!important;margin:-1px!important;overflow:hidden!important;clip:rect(0,0,0,0)!important;white-space:nowrap!important;border:0!important} + .site-footer{flex:0 0 auto;margin-top:auto} + .contact-page.contact-page{margin:0 auto} + .discord-contact.discord-contact{grid-template-columns:30px minmax(0,1fr) auto;align-items:center;align-content:normal;gap:11px}.discord-contact>img{width:26px;height:20px;object-fit:contain}.discord-contact>span{display:grid;gap:5px}.discord-contact>span small{font-size:6px;font-weight:900;letter-spacing:1.2px}.discord-contact>span strong{font-size:10px;letter-spacing:.8px}.discord-contact.discord-contact>b{grid-column:auto;grid-row:auto;align-self:center} + .footer-links .footer-discord{min-height:38px;display:flex;align-items:center;gap:9px;margin-bottom:2px;padding:7px 9px;border:1px solid #5865f2;background:#171a32;color:#fff}.footer-discord img{width:20px;height:16px;object-fit:contain}.footer-discord span{display:grid;gap:3px;font-size:7px;line-height:1}.footer-discord small{color:#aeb6ff;font-size:5px;font-weight:900;letter-spacing:.8px}.footer-links .footer-discord:hover,.footer-links .footer-discord:focus-visible{border-color:#929bff;background:#202446;color:#fff}.footer-links .footer-discord:active{transform:translateY(1px);filter:brightness(.88)} + .footer-nav{width:max-content;max-width:520px;height:auto;display:flex;flex-wrap:wrap;align-items:center;gap:0;margin:0}.footer-nav a{display:inline-flex;align-items:center;min-height:24px;padding:0 9px;border-left:1px solid var(--shd-border);color:var(--shd-orange);font-size:6px;font-weight:900;letter-spacing:.8px}.footer-nav a:first-child{padding-left:0;border-left:0}.footer-nav a:hover,.footer-nav a:focus-visible{color:var(--shd-orange-hover)}@media(max-width:800px){.footer-nav{width:100%;max-width:none}.footer-nav a{min-height:30px}}@media(max-width:500px){.footer-nav{display:grid;grid-template-columns:1fr 1fr}.footer-nav a{padding:0 8px;border-top:1px solid var(--shd-border);border-left:0}.footer-nav a:nth-child(even){border-left:1px solid var(--shd-border)}.footer-nav a:nth-child(-n+2){border-top:0}} @media(min-width:2200px){:root{--shd-display-scale:1.15}html{zoom:var(--shd-display-scale)}} @media(min-width:2800px){:root{--shd-display-scale:1.3}} @media(min-width:3400px){:root{--shd-display-scale:1.5}} @@ -111,6 +116,7 @@ .isac-modal-backdrop{position:fixed;z-index:50;inset:0;display:grid;place-items:center;padding:20px;background:#050607d9;backdrop-filter:blur(3px)}.isac-confirm-modal{width:min(680px,100%);max-height:calc(100vh - 40px);overflow:auto;padding:16px;border:1px solid #5a6061;border-top:2px solid var(--shd-orange);background:#131617;box-shadow:0 24px 80px #000}.access-confirm-grid{display:grid;grid-template-columns:1fr 1fr;gap:8px}.access-confirm-grid>div{min-height:120px;align-content:start;display:grid;gap:5px;padding:10px;border:1px solid var(--shd-border);background:#0d1011}.access-confirm-grid>div>span{margin-bottom:3px;color:var(--shd-text-dim);font-size:7px;letter-spacing:1px}.access-confirm-grid b{display:flex;justify-content:space-between;align-items:center;gap:8px;padding:6px 7px;border-left:2px solid #596164;background:#151819;color:#aeb3b4;font-size:8px}.access-confirm-grid b span{color:inherit}.access-confirm-grid b em{font-style:normal;color:#747c7e;font-size:6px;letter-spacing:.8px}.access-confirm-grid b.access-add{border-left-color:var(--shd-success);background:#101713;color:#9bd4ac}.access-confirm-grid b.access-add em{color:var(--shd-success)}.access-confirm-grid b.access-remove{border-left-color:var(--shd-danger);background:#211414;color:#dca096;text-decoration:line-through}.access-confirm-grid b.access-remove em{color:var(--shd-danger);text-decoration:none}.access-confirm-grid small{color:var(--shd-text-dim);font-size:7px}@media(max-width:560px){.access-confirm-grid{grid-template-columns:1fr}} .login-investigation-list>div{grid-template-columns:1fr 1fr 1.2fr auto;align-items:center}.login-investigation-list>div>div{display:grid;gap:4px}.login-investigation-list small{color:var(--shd-text-dim);font-size:6px;letter-spacing:.4px}.deletion-purge-option{display:grid!important;grid-template-columns:20px 1fr;align-items:start;gap:8px;margin:14px 0!important;padding:10px;border:1px solid #70423b;background:#211414}.deletion-purge-option input{width:auto;margin:2px 0 0;accent-color:var(--shd-danger)}.deletion-purge-option span{display:grid;gap:4px}.deletion-purge-option b{color:var(--shd-danger);font-size:8px}.deletion-purge-option small{margin:0;color:#b18c85;font-size:7px} .site-footer{position:relative;z-index:2;min-height:150px;display:grid;grid-template-columns:1fr auto minmax(300px,.9fr);align-items:center;gap:28px;padding:28px max(24px,5vw);border-top:1px solid var(--shd-border);background:#080a0b}.footer-brand{width:max-content}.footer-brand .brand-mark{width:30px;height:30px;font-size:7px}.site-footer>div>p{margin:8px 0 0;color:var(--shd-text-dim);font:11px Georgia,serif}.footer-links{display:grid;gap:8px}.footer-links a{color:var(--shd-orange);font-size:7px;font-weight:900;letter-spacing:1px}.fan-disclaimer{margin:0;color:#555d5f;font-size:6px;line-height:1.6;letter-spacing:.55px}.legal-page{max-width:1320px;margin:auto;padding:55px clamp(18px,5vw,72px) 90px}.legal-hero{padding-bottom:30px;border-bottom:1px solid var(--shd-border)}.legal-hero h1{margin:18px 0 14px;font-size:clamp(48px,7vw,82px);line-height:.9;letter-spacing:-3px}.legal-hero h1 em{color:var(--shd-orange);font-style:normal}.legal-hero>p:last-child{max-width:720px;color:var(--shd-text-muted);font:15px/1.6 Georgia,serif}.legal-layout{display:grid;grid-template-columns:210px minmax(0,1fr);gap:28px;margin-top:24px}.legal-index{position:sticky;top:95px;height:max-content;display:grid;margin:0;border:1px solid var(--shd-border)}.legal-index a{padding:11px;border-top:1px solid var(--shd-border-soft);color:var(--shd-text-muted);font-size:7px;font-weight:900;letter-spacing:.8px}.legal-index a:first-child{border-top:0}.legal-index a:hover{color:var(--shd-orange);background:#171b1c}.legal-document{display:grid}.legal-document section{scroll-margin-top:90px;display:grid;grid-template-columns:42px 1fr;gap:14px;padding:24px 0;border-top:1px solid var(--shd-border)}.legal-document section:first-child{border-top:0;padding-top:0}.legal-document section>span{color:var(--shd-orange);font-size:9px;font-weight:900}.legal-document h2{font-size:18px;letter-spacing:.6px}.legal-document p{color:#a1a7a8;font:13px/1.7 Georgia,serif}.legal-document a{color:var(--shd-orange)}@media(max-width:800px){.site-footer{grid-template-columns:1fr}.legal-layout{grid-template-columns:1fr}.legal-index{position:static;grid-template-columns:1fr 1fr}.login-investigation-list>div{grid-template-columns:1fr 1fr}}@media(max-width:500px){.legal-index,.login-investigation-list>div{grid-template-columns:1fr}} +.legal-primary{width:max-content;color:#111!important;margin-top:8px}.contact-page{width:100%;max-width:1500px;margin:auto;padding:34px clamp(18px,4vw,58px) 52px}.contact-hero{position:relative;height:auto;min-height:230px;display:grid;grid-template-columns:minmax(0,1fr) 310px;align-items:center;gap:40px;padding:18px 0 30px;border-bottom:1px solid var(--shd-border);background:transparent}.contact-hero>div{min-width:0}.contact-hero h1{margin:18px 0 14px;font-size:clamp(52px,7vw,88px);line-height:.85;letter-spacing:-4px}.contact-hero h1 em{color:var(--shd-orange);font-style:normal}.contact-hero>div>p:last-child{max-width:690px;margin-bottom:0;color:var(--shd-text-muted);font:15px/1.6 Georgia,serif}.discord-contact{min-height:82px;display:grid;grid-template-columns:1fr auto;align-content:center;gap:4px 10px;padding:15px 17px;border:1px solid #6571dc;background:#171a32;color:#aeb6ff}.discord-contact span{font-size:6px;font-weight:900;letter-spacing:1.2px}.discord-contact strong{font-size:10px;letter-spacing:.8px}.discord-contact b{grid-column:2;grid-row:1/3;align-self:center;font-size:18px}.discord-contact:hover,.discord-contact:focus-visible{border-color:#929bff;background:#202446;color:#fff}.contact-layout{display:grid;grid-template-columns:minmax(0,1fr) 320px;align-items:start;gap:12px;margin-top:12px}.contact-console,.contact-guidance{border:1px solid var(--shd-border);background:var(--shd-surface)}.contact-console{padding:16px}.contact-console-head{min-height:45px;display:flex;align-items:start;justify-content:space-between;padding-bottom:10px;margin-bottom:13px;border-bottom:1px solid var(--shd-border)}.contact-console-head>div{display:grid;gap:4px}.contact-console-head span{color:var(--shd-text-dim);font-size:6px;letter-spacing:1.2px}.contact-console-head strong{font-size:13px}.contact-console-head>b{color:var(--shd-success);font-size:7px;letter-spacing:1px}.contact-console .segmented-choice>div{height:40px}.contact-console .field{margin:10px 0}.contact-console textarea{line-height:1.5}.contact-identity{display:grid;grid-template-columns:1fr 1fr;gap:9px}.contact-honeypot{position:absolute;left:-10000px;width:1px;height:1px;overflow:hidden}.dmca-contact-fields{margin-top:15px;padding:12px;border:1px solid #74552a;border-left:2px solid var(--quality-high-end);background:#131108}.dmca-contact-fields>header{height:auto;display:flex;align-items:center;justify-content:space-between;padding:0 0 9px;border-bottom:1px solid #493a22;background:transparent}.dmca-contact-fields>header>div{display:grid;gap:4px}.dmca-contact-fields>header span{color:#82755f;font-size:6px;letter-spacing:1px}.dmca-contact-fields>header strong{color:var(--quality-high-end);font-size:10px}.dmca-contact-fields>header a{color:var(--shd-orange);font-size:7px;font-weight:900}.dmca-contact-fields>p{margin:10px 0;color:#9b927f;font:11px/1.55 Georgia,serif}.legal-attestation{min-height:46px;display:grid;grid-template-columns:auto 1fr;align-items:center;gap:9px;margin-top:7px;padding:8px;border:1px solid #3c382e;background:#0d0f0d;color:#b5b0a3;font-size:8px;line-height:1.45;cursor:pointer}.legal-attestation input{width:auto;margin:0;accent-color:var(--shd-orange)}.legal-attestation:has(input:checked){border-color:#7c5c29;background:#1b170d}.contact-guidance{height:max-content;display:grid}.contact-guidance>div{position:relative;min-height:126px;padding:16px 16px 14px 50px;border-top:1px solid var(--shd-border)}.contact-guidance>div:first-child{border-top:0}.contact-guidance>div>span{position:absolute;left:15px;top:17px;color:var(--shd-orange);font-size:8px;font-weight:900}.contact-guidance h2{font-size:13px;letter-spacing:.8px}.contact-guidance p{margin:0;color:var(--shd-text-muted);font:11px/1.55 Georgia,serif}.contact-guidance a{display:inline-block;margin-top:10px;color:var(--shd-orange);font-size:7px;font-weight:900}.contact-guidance>small{padding:14px;border-top:1px solid var(--shd-border);color:var(--shd-danger);font-size:6px;line-height:1.5;letter-spacing:.6px}.contact-success{min-height:300px;display:flex;align-items:center;justify-content:center;gap:16px;text-align:left}.contact-success>span{width:48px;height:48px;display:grid;place-items:center;border:1px solid var(--shd-success);color:var(--shd-success);font-size:18px}.contact-success strong{font-size:13px;letter-spacing:1px}.contact-success p{max-width:430px;margin:7px 0 0;color:var(--shd-text-muted);font:13px/1.55 Georgia,serif}.contact-message-page{width:100%;max-width:1200px;min-height:calc(100vh - var(--shd-header-height));margin:auto;padding:36px clamp(18px,4vw,55px) 70px}.contact-back{display:inline-block;margin-bottom:12px;color:var(--shd-orange);font-size:7px;font-weight:900;letter-spacing:.8px}.contact-message{border:1px solid var(--shd-border);border-top:2px solid var(--shd-orange);background:var(--shd-surface)}.contact-message>header{position:static;height:62px;display:flex;justify-content:space-between;padding:0 17px;border-bottom:1px solid var(--shd-border);background:#0d1011}.contact-message>header>div{display:grid;gap:4px}.contact-message>header span{color:var(--shd-text-dim);font-size:6px;letter-spacing:1px}.contact-message>header strong{font-size:10px}.contact-message>header time{color:var(--shd-text-muted);font-size:7px}.contact-message-title{padding:28px clamp(20px,5vw,55px);border-bottom:1px solid var(--shd-border)}.contact-message-title h1{margin:12px 0;font-size:clamp(30px,5vw,48px)}.contact-message-title>p:last-child{color:var(--shd-text-muted);font-size:7px;letter-spacing:.8px}.contact-message-title a,.contact-message section a{color:var(--shd-orange)}.contact-message>section{padding:24px clamp(20px,5vw,55px);border-bottom:1px solid var(--shd-border)}.contact-message>section h2{font-size:9px;letter-spacing:1px}.contact-message>section>p{margin:12px 0 0;color:#c0c4c5;font:14px/1.7 Georgia,serif;white-space:pre-wrap}.contact-dmca-record dl{display:grid;gap:10px}.contact-dmca-record dl>div{display:grid;grid-template-columns:160px 1fr;gap:12px;padding-top:10px;border-top:1px solid var(--shd-border-soft)}.contact-dmca-record dt{color:var(--shd-text-dim);font-size:6px;font-weight:900;letter-spacing:.8px}.contact-dmca-record dd{margin:0;color:#c0c4c5;font:12px/1.6 Georgia,serif;white-space:pre-wrap}.contact-message>footer{display:flex;justify-content:flex-end;padding:14px}.contact-message>footer .primary{padding:13px 16px}@media(max-width:900px){.contact-hero,.contact-layout{grid-template-columns:1fr}.contact-hero{min-height:0;align-items:start;gap:20px;padding-top:8px}.discord-contact{width:100%}}@media(max-width:600px){.contact-page{padding-top:24px;padding-bottom:36px}.contact-hero h1{letter-spacing:-2.5px}.contact-identity{grid-template-columns:1fr}.contact-console .segmented-choice>div{height:auto;grid-template-columns:1fr}.contact-console .segmented-choice span{min-height:44px}.dmca-contact-fields>header{align-items:flex-start;flex-direction:column;gap:8px}.contact-dmca-record dl>div{grid-template-columns:1fr}.contact-message>footer .primary{width:100%}} @media(max-width:1100px){.effect-editor{grid-template-columns:1fr}.selection-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(max-width:900px){.gear-identity,.gear-ranges{grid-template-columns:1fr 1fr}}@media(max-width:560px){.effect-band{grid-template-columns:48px 1fr}.effect-band .field:last-child{grid-column:2}.split-actions{align-items:stretch}.split-actions,.split-actions>div{width:100%}.split-actions>div:last-child button{flex:1}.gear-identity,.gear-ranges,.selection-grid{grid-template-columns:1fr}.range-band{grid-template-columns:48px 1fr}.range-band .field:last-child{grid-column:2}} .dashboard-row>.role-summary{display:flex;flex-wrap:wrap;align-items:center;gap:3px}.role-summary b{flex:0 0 auto;width:max-content;padding:3px 5px;border:1px solid #6e5226;color:var(--shd-orange);font-size:6px;letter-spacing:.6px} .game-icon{display:block;width:24px;height:24px;object-fit:contain}.weapon-editor{border-top-color:#d07a2c}.weapon-identity-grid{display:grid;grid-template-columns:1.6fr repeat(5,minmax(0,1fr));gap:8px}.weapon-fixed-grid{display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:8px}.damage-type-picker{min-width:0;margin:0;padding:0;border:0}.damage-type-picker legend{color:#858b8d;font-size:8px;font-weight:bold;letter-spacing:1px}.damage-type-picker>div{height:32px;display:grid;grid-template-columns:repeat(3,1fr);margin-top:5px;border:1px solid #363b3c;background:#0d0f10}.damage-type-picker label{min-width:0;border-left:1px solid #303536;cursor:pointer}.damage-type-picker label:first-child{border-left:0}.damage-type-picker input{position:absolute;opacity:0;pointer-events:none}.damage-type-picker span{height:100%;display:flex;align-items:center;justify-content:center;gap:3px;color:#737a7c}.damage-type-picker .game-icon{width:17px;height:17px;opacity:.62}.damage-type-picker b{overflow:hidden;font-size:5px;letter-spacing:.25px;text-overflow:ellipsis}.damage-type-picker label:hover span{background:#171b1c;color:#ecebe5}.damage-type-picker label:active span{transform:translateY(1px);filter:brightness(.88)}.damage-type-picker input:checked+span{background:#211a0e;color:#f6a821;box-shadow:inset 0 -2px #f6a821}.damage-type-picker input:checked+span .game-icon{opacity:1}.damage-type-picker input:focus-visible+span{outline:2px solid #f6a821;outline-offset:2px}.exotic-toggle>span{min-height:32px;margin-top:5px;padding:0 9px;display:flex;align-items:center;gap:7px;border:1px solid #363b3c;background:#0d0f10;color:#d3d5d1;font-size:8px}.exotic-toggle input{width:auto;margin:0;accent-color:#f6a821}.weapon-tier-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:8px;margin-top:9px}.weapon-tier-card{min-width:0;margin:0;padding:7px;border:1px solid #3b4142;background:#101314}.weapon-tier-card legend{padding:0 7px;color:#f6a821;font-size:8px;font-weight:900;letter-spacing:1.2px}.weapon-range-row{display:grid;grid-template-columns:65px 1fr 1fr;gap:5px;align-items:end;padding:5px;border-top:1px solid #282e2f}.weapon-range-row:first-of-type{border-top:0}.weapon-range-row>strong{align-self:center;display:flex;align-items:center;gap:4px;color:#d7d9d5;font-size:9px}.weapon-range-row>strong .game-icon{width:20px;height:20px}.weapon-range-row>strong small{display:block;color:#596164;font-size:5px;letter-spacing:.5px}.weapon-range-row label{color:#737a7c;font-size:6px;letter-spacing:.7px}.weapon-range-row input{margin-top:3px;padding:7px;font-size:8px}.weapon-talent-pools{display:grid;grid-template-columns:1fr 1fr;gap:9px}.compact-selection{grid-template-columns:repeat(2,minmax(0,1fr));max-height:210px;overflow:auto}.weapon-base-bonuses,.weapon-augment-effects{display:grid;gap:5px;margin-top:7px}.base-bonus-row,.augment-effect{display:grid;grid-template-columns:35px minmax(180px,1fr) 130px 30px;gap:6px;align-items:center;padding:5px;border:1px solid #303536;background:#101314}.base-bonus-row>strong,.augment-effect>strong{height:100%;display:grid;place-items:center;background:#171b1c;color:#f6a821;font-size:8px}.base-bonus-row .field,.augment-effect .field{margin:0}.base-bonus-row button,.augment-effect button{height:30px;border:1px solid #563734;background:#1d1212;color:#da746c;cursor:pointer}.base-bonus-row button:disabled{opacity:.3;cursor:not-allowed}.augment-effect:not(:has(>button)){grid-template-columns:35px minmax(180px,1fr) 130px}.augment-sequence{display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:5px;margin-top:7px}.augment-sequence>div{min-height:42px;display:grid;place-items:center;padding:5px;border:1px solid #303536;background:#0d1011}.augment-sequence b{color:#f6a821;font-size:8px}.augment-sequence span{color:#727a7c;font-size:7px}.augment-sequence .exotic-step{border-color:#875d25;background:#211a0e}.weapon-row{min-width:1180px;grid-template-columns:1.3fr .7fr 1.2fr .6fr .6fr .5fr .5fr .3fr}.weapon-row>span small{color:#596164}.weapon-damage-label{display:flex!important;align-items:center;gap:4px}.weapon-damage-label .game-icon{width:16px;height:16px}.weapon-dashboard-table{margin-top:10px} diff --git a/src/lib/components/AppShell.svelte b/src/lib/components/AppShell.svelte index 385cf55..52cab94 100644 --- a/src/lib/components/AppShell.svelte +++ b/src/lib/components/AppShell.svelte @@ -133,7 +133,15 @@ diff --git a/src/routes/account/contact/[id]/+page.server.ts b/src/routes/account/contact/[id]/+page.server.ts new file mode 100644 index 0000000..9331dd9 --- /dev/null +++ b/src/routes/account/contact/[id]/+page.server.ts @@ -0,0 +1,15 @@ +import { error } from '@sveltejs/kit'; +import { getDatabase } from '$lib/server/database'; +import { getSessionUser } from '$lib/server/auth/session'; +import type { PageServerLoad } from './$types'; + +export const load: PageServerLoad = async ({ cookies, params }) => { + const user = await getSessionUser(cookies); + if (!user) error(401, 'Sign in to view this contact message.'); + const database = await getDatabase(); + const message = await (await database.prepare(`SELECT contact_messages.* FROM contact_messages + JOIN contact_message_recipients ON contact_message_recipients.message_id=contact_messages.id + WHERE contact_messages.id=? AND contact_message_recipients.user_id=?`)).get(params.id, user.id); + if (!message) error(404, 'Contact message not found.'); + return { message: { ...message, dmca: message.dmca_json ? JSON.parse(String(message.dmca_json)) : null } }; +}; diff --git a/src/routes/account/contact/[id]/+page.svelte b/src/routes/account/contact/[id]/+page.svelte new file mode 100644 index 0000000..d06260b --- /dev/null +++ b/src/routes/account/contact/[id]/+page.svelte @@ -0,0 +1,18 @@ + + +{message.subject} — Contact Relay — SHD.build +
+ ← RETURN TO INBOX +
+
SHD CONTACT RELAY{message.team === 'dmca' ? 'DMCA / COPYRIGHT' : String(message.team).toUpperCase()}
+

INBOUND TRANSMISSION

{message.subject}

FROM {message.sender_name} · {message.sender_email}

+

MESSAGE

{message.body}

+ {#if message.dmca} +

DMCA NOTICE DETAILS

COPYRIGHTED WORK
{message.dmca.work}
MATERIAL LOCATION
{message.dmca.locations}
CLAIMANT CONTACT
{message.dmca.contact}
ELECTRONIC SIGNATURE
{message.dmca.signature}
ATTESTATIONS
Good-faith authorization statement confirmed · Accuracy and authority statement confirmed under penalty of perjury
+ {/if} + +
+
diff --git a/src/routes/account/notifications/+page.svelte b/src/routes/account/notifications/+page.svelte index 1c910a3..ad29144 100644 --- a/src/routes/account/notifications/+page.svelte +++ b/src/routes/account/notifications/+page.svelte @@ -34,6 +34,7 @@ if (notification.category === 'contributions' && /(approved|deployed)/.test(text)) return { label: 'VIEW PUBLISHED DATA', href: notification.link }; if (notification.category === 'guides') return { label: 'OPEN GUIDE', href: notification.link }; if (notification.category === 'builds') return { label: 'OPEN BUILD', href: notification.link }; + if (notification.source === 'SHD CONTACT RELAY') return { label: notification.title.startsWith('DMCA NOTICE') ? 'REVIEW COPYRIGHT REQUEST' : 'OPEN CONTACT MESSAGE', href: notification.link }; if (notification.link === '/account') return { label: 'OPEN AGENT PROFILE', href: notification.link }; return { label: 'OPEN RELATED PAGE', href: notification.link }; }; diff --git a/src/routes/contact/+page.server.ts b/src/routes/contact/+page.server.ts new file mode 100644 index 0000000..fe95b33 --- /dev/null +++ b/src/routes/contact/+page.server.ts @@ -0,0 +1,107 @@ +import { fail } from '@sveltejs/kit'; +import { randomUUID } from 'node:crypto'; +import { getDatabase } from '$lib/server/database'; +import { getSessionUser } from '$lib/server/auth/session'; +import { createNotification } from '$lib/server/notifications'; +import type { Actions, PageServerLoad } from './$types'; + +const submissions = new Map(); +const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +function clean(value: FormDataEntryValue | null, maximum: number) { + return String(value ?? '').normalize('NFKC').trim().slice(0, maximum); +} + +function allowSubmission(key: string) { + const cutoff = Date.now() - 60 * 60 * 1000; + const recent = (submissions.get(key) ?? []).filter((time) => time > cutoff); + if (recent.length >= 3) return false; + recent.push(Date.now()); + submissions.set(key, recent); + return true; +} + +async function recipients(team: string) { + const database = await getDatabase(); + const admin = team === 'administrators' || team === 'dmca'; + return (await database.prepare(`SELECT DISTINCT users.id FROM users + LEFT JOIN user_roles ON user_roles.user_id=users.id + LEFT JOIN roles ON roles.id=user_roles.role_id + LEFT JOIN role_permissions ON role_permissions.role_id=roles.id + LEFT JOIN permissions role_permission ON role_permission.id=role_permissions.permission_id + LEFT JOIN user_permissions ON user_permissions.user_id=users.id + LEFT JOIN permissions direct_permission ON direct_permission.id=user_permissions.permission_id + WHERE NOT EXISTS (SELECT 1 FROM deleted_accounts WHERE deleted_accounts.user_id=users.id) + AND (${admin + ? "users.role='admin' OR role_permission.name='system.admin' OR direct_permission.name='system.admin'" + : "users.role='admin' OR roles.id IN ('role-moderator','role-data-approver') OR role_permission.name IN ('system.admin','data.approve','users.moderate','avatars.review') OR direct_permission.name IN ('system.admin','data.approve','users.moderate','avatars.review')"})`)) + .all(); +} + +export const load: PageServerLoad = async ({ cookies, url }) => { + const user = await getSessionUser(cookies); + const initialTeam = url.searchParams.get('team') === 'dmca' ? 'dmca' : 'oversight'; + if (!user) return { sender: null, initialTeam }; + const database = await getDatabase(); + const email = await (await database.prepare('SELECT email FROM user_emails WHERE user_id=?')).get(user.id); + return { sender: { name: user.displayName, email: typeof email?.email === 'string' ? email.email : '' }, initialTeam }; +}; + +export const actions: Actions = { + send: async ({ request, cookies, getClientAddress }) => { + const form = await request.formData(); + if (clean(form.get('website'), 200)) return { contactSent: true }; + const user = await getSessionUser(cookies); + let address = 'unknown'; + try { address = getClientAddress(); } catch { /* Development adapters may not expose a client address. */ } + if (!allowSubmission(user ? `user:${user.id}` : `ip:${address}`)) { + return fail(429, { contactError: 'This contact channel has received several recent transmissions. Try again later.' }); + } + + const team = clean(form.get('team'), 30); + const senderName = clean(form.get('senderName'), 100); + const senderEmail = clean(form.get('senderEmail'), 254).toLocaleLowerCase(); + const subject = clean(form.get('subject'), 140); + const body = clean(form.get('body'), 8000); + if (!['oversight', 'administrators', 'dmca'].includes(team)) return fail(400, { contactError: 'Choose a valid destination team.' }); + if (senderName.length < 2 || !emailPattern.test(senderEmail)) return fail(400, { contactError: 'Provide your name and a valid reply email address.' }); + if (subject.length < 3 || body.length < 10) return fail(400, { contactError: 'Provide a subject and a message of at least 10 characters.' }); + + let dmca: Record | null = null; + if (team === 'dmca') { + dmca = { + work: clean(form.get('copyrightedWork'), 4000), + locations: clean(form.get('contentLocations'), 4000), + contact: clean(form.get('claimantContact'), 2000), + signature: clean(form.get('signature'), 200), + goodFaith: form.get('goodFaith') === '1', + accurate: form.get('accurate') === '1' + }; + if (String(dmca.work).length < 5 || String(dmca.locations).length < 5 || String(dmca.contact).length < 10 || String(dmca.signature).length < 2 || !dmca.goodFaith || !dmca.accurate) { + return fail(400, { contactError: 'DMCA notices require the work, material location, complete contact information, electronic signature, and both required statements.' }); + } + } + + const targetUsers = await recipients(team); + if (!targetUsers.length) return fail(503, { contactError: 'No authorized recipient is currently assigned to this channel. Please use the Discord community link for assistance.' }); + const database = await getDatabase(); + const messageId = randomUUID(); + const createdAt = new Date().toISOString(); + await (await database.prepare(`INSERT INTO contact_messages(id,sender_user_id,sender_name,sender_email,team,subject,body,dmca_json,created_at) + VALUES(?,?,?,?,?,?,?,?,?)`)).run(messageId, user?.id ?? null, senderName, senderEmail, team, subject, body, dmca ? JSON.stringify(dmca) : null, createdAt); + for (const recipient of targetUsers) { + const userId = String(recipient.id); + await (await database.prepare('INSERT INTO contact_message_recipients(message_id,user_id) VALUES(?,?)')).run(messageId, userId); + await createNotification({ + userId, + category: 'isac', + source: 'SHD CONTACT RELAY', + title: `${team === 'dmca' ? 'DMCA NOTICE' : team === 'oversight' ? 'OVERSIGHT CONTACT' : 'ADMIN CONTACT'} · ${subject}`, + body: `${senderName} (${senderEmail}) submitted a new ${team === 'dmca' ? 'copyright request' : 'contact message'}.\n\n${body.slice(0, 1200)}`, + link: `/account/contact/${messageId}`, + mandatory: true + }); + } + return { contactSent: true, contactToken: messageId }; + } +}; diff --git a/src/routes/contact/+page.svelte b/src/routes/contact/+page.svelte new file mode 100644 index 0000000..06f8ebc --- /dev/null +++ b/src/routes/contact/+page.svelte @@ -0,0 +1,66 @@ + + +Contact SHD.build + +
+
+

SHD COMMUNICATION NETWORK

CONTACT SHD.

Route a message to the team best equipped to respond. Your transmission will enter the appropriate team’s secure SHD.build inbox.

+ + + DISCORD COMMUNITYJOIN THE SHD NETWORK + ↗ + +
+ +
+
+
OUTBOUND TRANSMISSIONNEW MESSAGE
SECURE
+ {#if form?.contactSent} +
✓
TRANSMISSION RECEIVED

Your message has been delivered to the selected team. Keep your reply email available for follow-up.

+ {:else} + + {#snippet children(pending)} + +
+ + +
+ + + + {#if team === 'dmca'} +
+
COPYRIGHT CLAIM DETAILSDMCA NOTICE
VIEW DMCA POLICY →
+

Use this channel only if you own the copyright or are authorized to act for its owner. All fields below are required for a formal notice.

+ + + + + + +
+ {/if} + {#if form?.contactError}{/if} +
+ {/snippet} +
+ {/if} +
+ +
+
diff --git a/src/routes/dmca/+page.svelte b/src/routes/dmca/+page.svelte new file mode 100644 index 0000000..c494a15 --- /dev/null +++ b/src/routes/dmca/+page.svelte @@ -0,0 +1,21 @@ + + DMCA & Copyright Policy — SHD.build + + + +
+ + +
diff --git a/src/routes/terms/+page.svelte b/src/routes/terms/+page.svelte index 85316b3..651554e 100644 --- a/src/routes/terms/+page.svelte +++ b/src/routes/terms/+page.svelte @@ -23,11 +23,11 @@
02

ACCOUNTS & SECURITY

You are responsible for activity performed through your account and for maintaining control of your authentication methods. You must provide accurate information, may not impersonate another person, and may not attempt to access another account. We may restrict accounts to protect users, enforce these terms, or comply with law.

Available authentication methods may include passkeys, verified email magic links, local username and password credentials, Discord, and other providers added later. Third-party authentication remains subject to that provider’s terms and privacy practices.

03

USER CONTENT

You retain ownership of original content you submit. You grant SHD.build a non-exclusive, worldwide, royalty-free license to host, reproduce, format, display, and distribute that content as necessary to operate the service. You represent that you have the rights required to post it.

Builds, guides, and comments may remain available after account deletion under a “Deleted Agent” attribution so community references are not broken. You may not upload unlawful, infringing, malicious, or privacy-invasive content.

04

COMMUNITY CONDUCT

Do not harass others; publish illegal or infringing material; distribute malware; evade access restrictions; scrape or overload the service; manipulate ratings; spam; or exploit security vulnerabilities. Community Oversight may remove content, restrict access, or preserve relevant records when reasonably necessary for safety, support, abuse prevention, or legal obligations.

-
05

INFORMATION WE PROCESS

Account data may include internal account identifiers, private login username, public display name, password hash, verified email, linked-provider identifiers and profile details, passkey public credentials, avatar, roles, permissions, builds, guides, favorites, ratings, comments, notification messages, read state, and delivery preferences. Passwords are stored only as salted Argon2id hashes. Private passkey material remains on your authenticator.

For login security and support, events may include the time, success status, authentication provider, IP address, user-agent string, normalized browser, operating system and device type, approximate country/region/city when supplied by the hosting proxy, failure reason, automated risk flags, provider or passkey credential reference, associated session identifier, and session end time or reason. We do not sell this information. It is used for authentication, abuse prevention, troubleshooting, moderation, and account support.

Browser and email notification delivery is optional except that operational ISAC notices remain available in the in-app inbox. Email frequency is configurable by notification category and defaults to off. OAuth and email providers process information under their own policies.

+
05

INFORMATION WE PROCESS

Account data may include internal account identifiers, private login username, public display name, password hash, verified email, linked-provider identifiers and profile details, passkey public credentials, avatar, roles, permissions, builds, guides, favorites, ratings, comments, notification messages, read state, and delivery preferences. Passwords are stored only as salted Argon2id hashes. Private passkey material remains on your authenticator.

For login security and support, events may include the time, success status, authentication provider, IP address, user-agent string, normalized browser, operating system and device type, approximate country/region/city when supplied by the hosting proxy, failure reason, automated risk flags, provider or passkey credential reference, associated session identifier, and session end time or reason. We do not sell this information. It is used for authentication, abuse prevention, troubleshooting, moderation, and account support.

Contact submissions include the sender’s name, reply email, destination, subject, message, account identifier when signed in, and submission time. Copyright requests may additionally include claimant contact details, identified works and URLs, attestations, and electronic signature. These records are shared only with authorized recipients and retained as reasonably necessary to respond, document action, resolve disputes, enforce policy, and meet legal obligations.

Browser and email notification delivery is optional except that operational ISAC notices remain available in the in-app inbox. Email frequency is configurable by notification category and defaults to off. OAuth and email providers process information under their own policies.

06

ESSENTIAL COOKIES

SHD.build currently uses only first-party cookies that are strictly necessary to provide authentication and protect sign-in flows. We do not currently use analytics, advertising, behavioral-tracking, or personalization cookies. These essential cookies cannot be disabled through a preference control. You may prevent cookies through your browser, but account sign-in will not function correctly.

The informational notice remembers its dismissal in browser local storage under shd-essential-cookie-notice. It is not sent with web requests and can be removed through your browser’s site-data controls. OAuth state remains in a short-lived HTTP-only cookie because it must be securely validated by the server and unavailable to browser scripts.

If non-essential technologies are introduced later, this policy and the site’s consent controls will be updated before those technologies are enabled where consent is required.

07

RETENTION & DELETION

IP addresses, user-agent strings, normalized device information, and credential references in login history are retained for no more than 30 days, then automatically removed from the event. Non-sensitive facts such as event time, provider, outcome, failure category, and whether a session was created may remain for security and support history.

Deleting your account removes active sessions, credentials, verified email, provider profiles, passkeys, permissions, and uploaded avatar. You may also select immediate purging of retained login-event history during self-deletion. Public contributions may remain attributed to “Deleted Agent.” Backups and records required for fraud prevention, dispute resolution, or law may persist for a limited period where applicable.

08

THIRD-PARTY SERVICES

SHD.build may connect to Discord, email delivery services, and future identity providers. Their services are governed by their own terms. Links to other sites do not imply endorsement. You authorize the minimum provider data requested when choosing a linked sign-in method.

-
09

FAN-SITE & LEGAL DISCLAIMER

SHD.build is an unofficial fan-created service and is not affiliated with, endorsed by, sponsored by, or approved by Ubisoft Entertainment or its affiliates.

Tom Clancy’s The Division, Tom Clancy’s The Division Resurgence, Ubisoft, and related names, trademarks, logos, characters, artwork, game data, and other intellectual property are owned by Ubisoft and/or its licensors. Their use is for identification, commentary, and community reference. No ownership is claimed.

The service and community information are provided “as is” and “as available,” without warranties of accuracy, availability, fitness for a particular purpose, or non-infringement to the extent permitted by law. Build data may become outdated after game updates. SHD.build is not responsible for third-party services or decisions made using community content.

These terms may be updated as the service changes. Material revisions will be identified by a new effective date. Nothing here limits rights that cannot legally be waived.

For authoritative information about Ubisoft intellectual property, consult Ubisoft’s official Terms of Use and the official The Division Resurgence site.

+
09

FAN-SITE & LEGAL DISCLAIMER

SHD.build is an unofficial fan-created service and is not affiliated with, endorsed by, sponsored by, or approved by Ubisoft Entertainment or its affiliates.

Tom Clancy’s The Division, Tom Clancy’s The Division Resurgence, Ubisoft, and related names, trademarks, logos, characters, artwork, game data, and other intellectual property are owned by Ubisoft and/or its licensors. Their use is for identification, commentary, and community reference. No ownership is claimed.

The service and community information are provided “as is” and “as available,” without warranties of accuracy, availability, fitness for a particular purpose, or non-infringement to the extent permitted by law. Build data may become outdated after game updates. SHD.build is not responsible for third-party services or decisions made using community content.

Copyright owners and authorized agents may use our DMCA & Copyright Policy and copyright request form. Other questions may be sent through Contact Us.

These terms may be updated as the service changes. Material revisions will be identified by a new effective date. Nothing here limits rights that cannot legally be waived.

For authoritative information about Ubisoft intellectual property, consult Ubisoft’s official Terms of Use and the official The Division Resurgence site.