(feat) Discord build share
This commit is contained in:
+1
-1
@@ -31,7 +31,7 @@ ENV NODE_ENV=production \
|
|||||||
TDR_GAME_ICON_STORAGE_PATH=/app/data/game-icons
|
TDR_GAME_ICON_STORAGE_PATH=/app/data/game-icons
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
RUN apt-get update \
|
RUN apt-get update \
|
||||||
&& apt-get install -y --no-install-recommends ca-certificates curl gosu \
|
&& apt-get install -y --no-install-recommends ca-certificates curl fonts-dejavu-core gosu \
|
||||||
&& useradd --uid 10001 --gid node --no-create-home --shell /usr/sbin/nologin shd-runtime \
|
&& useradd --uid 10001 --gid node --no-create-home --shell /usr/sbin/nologin shd-runtime \
|
||||||
&& case "$TARGETARCH" in \
|
&& case "$TARGETARCH" in \
|
||||||
amd64) infisical_sha256=53abc96a3bc872ade81efe156275bd30c53f9ee8b52965b0c9cb320a9c814fbc ;; \
|
amd64) infisical_sha256=53abc96a3bc872ade81efe156275bd30c53f9ee8b52965b0c9cb320a9c814fbc ;; \
|
||||||
|
|||||||
+27
-4
@@ -1920,10 +1920,33 @@ selector:
|
|||||||
- `PUBLIC` is accessible by direct link and included in the public archive.
|
- `PUBLIC` is accessible by direct link and included in the public archive.
|
||||||
|
|
||||||
Keep visibility adjacent to the account-save action and explain its selected
|
Keep visibility adjacent to the account-save action and explain its selected
|
||||||
state in plain language. Show a share-page action only after a non-private build
|
state in plain language. Show Build Uplink after an Agent saves a Build. Public
|
||||||
has been saved. Public and unlisted build pages are read-only tactical
|
and unlisted Builds expose its active controls; private Builds show the locked
|
||||||
summaries; owner editing returns to the builder. On narrow layouts, persistence
|
state. Public and unlisted Build pages are read-only tactical summaries; owner
|
||||||
controls and shared-loadout columns stack vertically.
|
editing returns to the builder.
|
||||||
|
|
||||||
|
Build Uplink uses a compact tactical control surface. Use one bordered dark
|
||||||
|
panel with a raised telemetry header, a textual `PUBLIC LINK READY` or
|
||||||
|
`UNLISTED LINK READY` state, and a small orange signal marker. The primary
|
||||||
|
`SHARE BUILD` action opens the native Web Share sheet when the browser supports
|
||||||
|
it. Keep `COPY LINK` visible as a separate explicit action for every eligible
|
||||||
|
Build. When Web Share is unavailable, promote Copy Link to the primary action
|
||||||
|
instead of presenting a control that cannot run. Supporting copy may state that
|
||||||
|
Discord renders the Build preview after an Agent posts the link. It must not
|
||||||
|
imply that SHD.build posts to Discord or operates a bot.
|
||||||
|
|
||||||
|
Private and unsaved Builds use the locked Uplink state. Show `PRIVATE BUILD` or
|
||||||
|
`UNSAVED BUILD`, `PUBLIC LINK UNAVAILABLE`, and a short instruction to change
|
||||||
|
visibility or save. Render no active Share or Copy Link control in that state.
|
||||||
|
Do not expose a crawler-preview action or suggest that owner access makes a
|
||||||
|
private Build shareable.
|
||||||
|
|
||||||
|
Uplink actions retain textual pending, success, cancellation, unsupported, and
|
||||||
|
failure feedback. They use at least 44-pixel targets, orange focus-visible
|
||||||
|
treatment, pressed feedback, and disabled duplicate-action protection. Stack
|
||||||
|
Share above Copy Link at phone widths and remove pressed motion under
|
||||||
|
`prefers-reduced-motion`. On narrow layouts, persistence controls and
|
||||||
|
shared-loadout columns also stack vertically.
|
||||||
|
|
||||||
### Tiered compatibility editors
|
### Tiered compatibility editors
|
||||||
|
|
||||||
|
|||||||
@@ -187,6 +187,40 @@ boundary. An owned Build page links to `/guides/new?build=<id>`, where the
|
|||||||
server verifies ownership and initializes a removable Build block in a new
|
server verifies ownership and initializes a removable Build block in a new
|
||||||
private Guide.
|
private Guide.
|
||||||
|
|
||||||
|
### Discord-compatible Build sharing experiment
|
||||||
|
|
||||||
|
Persisted public and unlisted Builds expose absolute Open Graph metadata for
|
||||||
|
their canonical `/builds/[slug]` URL. Their `og:image` and Twitter card metadata
|
||||||
|
reference the same-origin 1200 × 630 PNG at `/builds/[slug]/preview.png` so
|
||||||
|
Discord and other link unfurlers can render a Build summary. Private Builds set
|
||||||
|
`noindex,nofollow`, expose no crawler image, and provide no usable sharing
|
||||||
|
control.
|
||||||
|
|
||||||
|
The preview endpoint derives its image from canonical Build facts and the
|
||||||
|
current known-stat summary in the shared Build projection. It includes the Build
|
||||||
|
title, author, visibility, direct URL, equipped weapon names, core totals, and
|
||||||
|
up to three supporting Attributes. The renderer performs no external fetches. It
|
||||||
|
normalizes and bounds every text field by grapheme count, escapes text before
|
||||||
|
inserting it into XML, builds a text-only SVG, and rasterizes that SVG to PNG
|
||||||
|
with Sharp. Production installs DejaVu Sans from the Debian package and the
|
||||||
|
renderer names that fixed family explicitly.
|
||||||
|
|
||||||
|
The endpoint hashes a versioned serialization of the image input for its bounded
|
||||||
|
in-process render cache and derives the HTTP ETag from the resulting PNG bytes.
|
||||||
|
A bounded request queue limits concurrent database projections and Sharp work.
|
||||||
|
Responses require cache revalidation so a visibility change to Private blocks
|
||||||
|
the next request instead of leaving a stale response window. Matching
|
||||||
|
conditional requests receive `304 Not Modified`. Private, missing, and
|
||||||
|
inaccessible Builds receive no preview. Unlisted images send the
|
||||||
|
`X-Robots-Tag: noindex` response header.
|
||||||
|
|
||||||
|
For eligible Builds, the Build Uplink invokes the native Web Share API as the
|
||||||
|
primary action when the browser supports it. An explicit Copy Link action
|
||||||
|
remains available for every public or unlisted persisted Build. This experiment
|
||||||
|
does not install a Discord bot, post to Discord, extend Discord OAuth scopes, or
|
||||||
|
store Discord tokens or webhooks. Discord receives only a link that an Agent
|
||||||
|
chooses to share and may fetch the public metadata and same-origin preview.
|
||||||
|
|
||||||
## Public data corrections and discussion
|
## Public data corrections and discussion
|
||||||
|
|
||||||
Every public database card links to its canonical ISAC record with an exact
|
Every public database card links to its canonical ISAC record with an exact
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { buildShareUrl, copyBuildLink, shareBuild } from './build-sharing';
|
||||||
|
|
||||||
|
describe('Build sharing', () => {
|
||||||
|
it('constructs Build URLs from slugs and preserves absolute URLs', () => {
|
||||||
|
expect(buildShareUrl('field-medic', 'https://shd.build')).toBe(
|
||||||
|
'https://shd.build/builds/field-medic',
|
||||||
|
);
|
||||||
|
expect(buildShareUrl('build name', 'https://shd.build/ignored')).toBe(
|
||||||
|
'https://shd.build/builds/build%20name',
|
||||||
|
);
|
||||||
|
expect(buildShareUrl('https://stage.shd.build/builds/test')).toBe(
|
||||||
|
'https://stage.shd.build/builds/test',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the native share API', async () => {
|
||||||
|
const share = vi.fn().mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
shareBuild(
|
||||||
|
{ share },
|
||||||
|
{ title: 'Field Medic', url: 'https://shd.build/builds/field-medic' },
|
||||||
|
),
|
||||||
|
).resolves.toEqual({ state: 'shared' });
|
||||||
|
expect(share).toHaveBeenCalledWith({
|
||||||
|
title: 'Field Medic',
|
||||||
|
text: 'View Field Medic on SHD.build.',
|
||||||
|
url: 'https://shd.build/builds/field-medic',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats AbortError as cancellation', async () => {
|
||||||
|
const share = vi
|
||||||
|
.fn()
|
||||||
|
.mockRejectedValue(new DOMException('Cancelled', 'AbortError'));
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
shareBuild(
|
||||||
|
{ share },
|
||||||
|
{ title: 'Build', url: 'https://shd.build/builds/a' },
|
||||||
|
),
|
||||||
|
).resolves.toEqual({ state: 'cancelled' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports unsupported and failed native sharing', async () => {
|
||||||
|
await expect(
|
||||||
|
shareBuild({}, { title: 'Build', url: 'https://shd.build/builds/a' }),
|
||||||
|
).resolves.toEqual({ state: 'unsupported' });
|
||||||
|
|
||||||
|
const error = new Error('Share failed');
|
||||||
|
await expect(
|
||||||
|
shareBuild(
|
||||||
|
{ share: vi.fn().mockRejectedValue(error) },
|
||||||
|
{ title: 'Build', url: 'https://shd.build/builds/a' },
|
||||||
|
),
|
||||||
|
).resolves.toEqual({ state: 'failed', error });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('copies the Build URL to the clipboard', async () => {
|
||||||
|
const writeText = vi.fn().mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
copyBuildLink({ clipboard: { writeText } }, 'https://shd.build/builds/a'),
|
||||||
|
).resolves.toEqual({ state: 'copied' });
|
||||||
|
expect(writeText).toHaveBeenCalledWith('https://shd.build/builds/a');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports unsupported and failed clipboard access', async () => {
|
||||||
|
await expect(
|
||||||
|
copyBuildLink({}, 'https://shd.build/builds/a'),
|
||||||
|
).resolves.toEqual({
|
||||||
|
state: 'unsupported',
|
||||||
|
});
|
||||||
|
|
||||||
|
const error = new Error('Clipboard denied');
|
||||||
|
await expect(
|
||||||
|
copyBuildLink(
|
||||||
|
{ clipboard: { writeText: vi.fn().mockRejectedValue(error) } },
|
||||||
|
'https://shd.build/builds/a',
|
||||||
|
),
|
||||||
|
).resolves.toEqual({ state: 'failed', error });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
export type BuildShareNavigator = {
|
||||||
|
share?: (data: ShareData) => Promise<void>;
|
||||||
|
clipboard?: {
|
||||||
|
writeText: (text: string) => Promise<void>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BuildShareResult =
|
||||||
|
| { state: 'shared' }
|
||||||
|
| { state: 'cancelled' }
|
||||||
|
| { state: 'unsupported' }
|
||||||
|
| { state: 'failed'; error: unknown };
|
||||||
|
|
||||||
|
export type BuildCopyResult =
|
||||||
|
| { state: 'copied' }
|
||||||
|
| { state: 'unsupported' }
|
||||||
|
| { state: 'failed'; error: unknown };
|
||||||
|
|
||||||
|
export function buildShareUrl(slugOrUrl: string, origin?: string): string {
|
||||||
|
const value = slugOrUrl.trim();
|
||||||
|
if (!value) throw new Error('A Build slug or URL is required.');
|
||||||
|
|
||||||
|
if (/^https?:\/\//i.test(value)) return new URL(value).toString();
|
||||||
|
|
||||||
|
const base = origin ?? globalThis.location?.origin;
|
||||||
|
if (!base) throw new Error('An origin is required to construct a Build URL.');
|
||||||
|
|
||||||
|
const slug = value.replace(/^\/+|\/+$/g, '');
|
||||||
|
return new URL(`/builds/${encodeURIComponent(slug)}`, base).toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function shareBuild(
|
||||||
|
navigatorApi: BuildShareNavigator,
|
||||||
|
input: { title: string; url: string },
|
||||||
|
): Promise<BuildShareResult> {
|
||||||
|
if (typeof navigatorApi.share !== 'function') return { state: 'unsupported' };
|
||||||
|
|
||||||
|
try {
|
||||||
|
await navigatorApi.share({
|
||||||
|
title: input.title,
|
||||||
|
text: `View ${input.title} on SHD.build.`,
|
||||||
|
url: input.url,
|
||||||
|
});
|
||||||
|
return { state: 'shared' };
|
||||||
|
} catch (error) {
|
||||||
|
if (
|
||||||
|
typeof error === 'object' &&
|
||||||
|
error !== null &&
|
||||||
|
'name' in error &&
|
||||||
|
error.name === 'AbortError'
|
||||||
|
) {
|
||||||
|
return { state: 'cancelled' };
|
||||||
|
}
|
||||||
|
return { state: 'failed', error };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function copyBuildLink(
|
||||||
|
navigatorApi: BuildShareNavigator,
|
||||||
|
url: string,
|
||||||
|
): Promise<BuildCopyResult> {
|
||||||
|
if (typeof navigatorApi.clipboard?.writeText !== 'function') {
|
||||||
|
return { state: 'unsupported' };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await navigatorApi.clipboard.writeText(url);
|
||||||
|
return { state: 'copied' };
|
||||||
|
} catch (error) {
|
||||||
|
return { state: 'failed', error };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { buildShareUrl, copyBuildLink, shareBuild } from '$lib/build-sharing';
|
||||||
|
import { toast } from '$lib/toast';
|
||||||
|
|
||||||
|
let {
|
||||||
|
title,
|
||||||
|
slugOrUrl,
|
||||||
|
visibility
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
slugOrUrl?: string | null;
|
||||||
|
visibility: 'public' | 'unlisted' | 'private';
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
let pending = $state<'share' | 'copy' | null>(null);
|
||||||
|
let status = $state('');
|
||||||
|
let nativeShareAvailable = $state(false);
|
||||||
|
const canShare = $derived((visibility === 'public' || visibility === 'unlisted') && Boolean(slugOrUrl?.trim()));
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
nativeShareAvailable = typeof navigator.share === 'function';
|
||||||
|
});
|
||||||
|
|
||||||
|
function shareUrl() {
|
||||||
|
return buildShareUrl(slugOrUrl ?? '', window.location.origin);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleShare() {
|
||||||
|
if (pending) return;
|
||||||
|
pending = 'share';
|
||||||
|
const result = await shareBuild(navigator, { title, url: shareUrl() });
|
||||||
|
pending = null;
|
||||||
|
|
||||||
|
if (result.state === 'shared') {
|
||||||
|
status = 'Build shared.';
|
||||||
|
toast.success('BUILD SHARED', 'The system share sheet accepted the Build link.');
|
||||||
|
} else if (result.state === 'cancelled') {
|
||||||
|
status = 'Sharing cancelled.';
|
||||||
|
toast.info('SHARING CANCELLED', 'No link was shared.');
|
||||||
|
} else if (result.state === 'unsupported') {
|
||||||
|
status = 'Native sharing is not supported on this device. Use Copy Link instead.';
|
||||||
|
toast.info('SHARE NOT SUPPORTED', 'Use COPY LINK to copy the Build URL.');
|
||||||
|
} else {
|
||||||
|
status = 'The Build could not be shared. Try Copy Link instead.';
|
||||||
|
toast.error('BUILD NOT SHARED', 'Try COPY LINK or try again.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCopy() {
|
||||||
|
if (pending) return;
|
||||||
|
pending = 'copy';
|
||||||
|
const result = await copyBuildLink(navigator, shareUrl());
|
||||||
|
pending = null;
|
||||||
|
|
||||||
|
if (result.state === 'copied') {
|
||||||
|
status = 'Build link copied to the clipboard.';
|
||||||
|
toast.success('LINK COPIED', 'Paste the Build link wherever you want to share it.');
|
||||||
|
} else if (result.state === 'unsupported') {
|
||||||
|
status = 'Clipboard copying is not supported in this browser.';
|
||||||
|
toast.info('COPY NOT SUPPORTED', 'This browser cannot copy the Build link automatically.');
|
||||||
|
} else {
|
||||||
|
status = 'The Build link could not be copied.';
|
||||||
|
toast.error('LINK NOT COPIED', 'Check clipboard permissions and try again.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section class="build-share-control" aria-label="Share Build">
|
||||||
|
{#if canShare}
|
||||||
|
<div class="build-share-heading">
|
||||||
|
<div>
|
||||||
|
<span>BUILD UPLINK</span>
|
||||||
|
<strong>{visibility === 'unlisted' ? 'UNLISTED LINK READY' : 'PUBLIC LINK READY'}</strong>
|
||||||
|
</div>
|
||||||
|
<span class="build-share-signal" aria-hidden="true"></span>
|
||||||
|
</div>
|
||||||
|
<div class:single={!nativeShareAvailable} class="build-share-actions">
|
||||||
|
{#if nativeShareAvailable}
|
||||||
|
<button class="build-share-primary" type="button" onclick={handleShare} disabled={pending !== null} aria-busy={pending === 'share'}>
|
||||||
|
{pending === 'share' ? 'OPENING SHARE…' : 'SHARE BUILD →'}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
<button class:build-share-primary={!nativeShareAvailable} class:build-share-copy={nativeShareAvailable} type="button" onclick={handleCopy} disabled={pending !== null} aria-busy={pending === 'copy'}>
|
||||||
|
{pending === 'copy' ? 'COPYING…' : 'COPY LINK'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p class="build-share-discord"><img src="/discord-symbol.svg" alt="" aria-hidden="true" />Discord will render the Build preview when you post the copied link.</p>
|
||||||
|
{:else}
|
||||||
|
<div class="build-share-locked">
|
||||||
|
<span>{slugOrUrl?.trim() ? 'PRIVATE BUILD' : 'UNSAVED BUILD'}</span>
|
||||||
|
<strong>PUBLIC LINK UNAVAILABLE</strong>
|
||||||
|
<p>{slugOrUrl?.trim() ? 'Change visibility to Public or Unlisted before sharing this Build.' : 'Save this Build before creating a public or unlisted sharing link.'}</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<p class="build-share-status" aria-live="polite" aria-atomic="true">{status}</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.build-share-control{display:grid;border:1px solid var(--shd-border-strong);background:#0d1011;font-family:var(--shd-font-sans)}
|
||||||
|
.build-share-heading{min-height:48px;display:flex;align-items:center;justify-content:space-between;gap:12px;padding:9px 11px;border-bottom:1px solid var(--shd-border);background:var(--shd-surface-raised)}
|
||||||
|
.build-share-heading>div,.build-share-locked{display:grid;gap:3px}.build-share-heading span,.build-share-locked>span{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.9px}.build-share-heading strong,.build-share-locked strong{font-size:var(--shd-type-supporting);letter-spacing:.45px}.build-share-signal{width:8px;height:8px;border:1px solid var(--shd-orange);background:var(--shd-orange);box-shadow:0 0 0 3px #f6a8211c}
|
||||||
|
.build-share-actions{display:grid;grid-template-columns:minmax(0,1fr) minmax(120px,.45fr);gap:8px;padding:10px}.build-share-actions button{min-height:44px;padding:0 13px;border-radius:0;font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.8px;cursor:pointer;transition:var(--shd-transition)}.build-share-primary{border:1px solid var(--shd-orange);background:var(--shd-orange);color:#111415}.build-share-copy{border:1px solid var(--shd-border-strong);background:var(--shd-surface);color:var(--shd-text)}.build-share-primary:hover{background:var(--shd-orange-hover);border-color:var(--shd-orange-hover)}.build-share-copy:hover{border-color:var(--shd-text-muted);background:var(--shd-surface-active)}.build-share-actions button:focus-visible{outline:2px solid var(--shd-focus-ring);outline-offset:2px}.build-share-actions button:active:not(:disabled){transform:translateY(1px);filter:brightness(.86)}.build-share-actions button:disabled{cursor:not-allowed;opacity:.48}
|
||||||
|
.build-share-actions.single{grid-template-columns:1fr}
|
||||||
|
.build-share-discord{display:flex;align-items:center;gap:7px;margin:0;padding:0 11px 10px;color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);line-height:1.35}.build-share-discord img{width:16px;height:13px;object-fit:contain;opacity:.72}.build-share-locked{padding:14px}.build-share-locked>span{color:var(--shd-danger)}.build-share-locked p{margin:2px 0 0;color:var(--shd-text-muted);font-size:var(--shd-type-supporting);line-height:1.4}.build-share-status{min-height:0;margin:0;padding:0 11px;color:var(--shd-text-muted);font-size:var(--shd-type-telemetry)}.build-share-status:not(:empty){min-height:32px;padding-top:7px;padding-bottom:7px;border-top:1px solid var(--shd-border-soft)}
|
||||||
|
@media(max-width:560px){.build-share-actions{grid-template-columns:1fr}.build-share-actions button{width:100%;min-height:44px}}
|
||||||
|
@media(prefers-reduced-motion:reduce){.build-share-actions button{transition:none}.build-share-actions button:active:not(:disabled){transform:none}}
|
||||||
|
</style>
|
||||||
@@ -75,7 +75,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function paste(event: ClipboardEvent) {
|
function paste(event: ClipboardEvent) {
|
||||||
const file = Array.from(event.clipboardData?.files ?? []).find((item) => item.type.startsWith('image/'));
|
const file =
|
||||||
|
Array.from(event.clipboardData?.items ?? [])
|
||||||
|
.find((item) => item.kind === 'file' && item.type.startsWith('image/'))
|
||||||
|
?.getAsFile() ??
|
||||||
|
Array.from(event.clipboardData?.files ?? []).find((item) => item.type.startsWith('image/'));
|
||||||
if (file) {
|
if (file) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
setFile(file);
|
setFile(file);
|
||||||
@@ -107,10 +111,12 @@
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<!-- svelte-ignore a11y_no_noninteractive_tabindex -- focus is required as a scoped native paste target -->
|
||||||
<div
|
<div
|
||||||
class="image-drop-input"
|
class="image-drop-input"
|
||||||
class:dragging
|
class:dragging
|
||||||
role="group"
|
role="group"
|
||||||
|
tabindex="0"
|
||||||
aria-label="Drop an image here or paste one from the clipboard"
|
aria-label="Drop an image here or paste one from the clipboard"
|
||||||
ondragenter={(event) => { event.preventDefault(); dragging = true; }}
|
ondragenter={(event) => { event.preventDefault(); dragging = true; }}
|
||||||
ondragover={(event) => event.preventDefault()}
|
ondragover={(event) => event.preventDefault()}
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import sharp from 'sharp';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import {
|
||||||
|
BUILD_SOCIAL_CARD_HEIGHT,
|
||||||
|
BUILD_SOCIAL_CARD_WIDTH,
|
||||||
|
buildBuildSocialCardSvg,
|
||||||
|
renderBuildSocialCardPng,
|
||||||
|
type BuildSocialCardInput,
|
||||||
|
} from './build-social-card';
|
||||||
|
|
||||||
|
const input: BuildSocialCardInput = {
|
||||||
|
title: 'Vanguard Counter-Offensive',
|
||||||
|
author: 'Agent Kelso',
|
||||||
|
primaryWeapon: 'Police M4',
|
||||||
|
secondaryWeapon: 'Super 90',
|
||||||
|
firepower: 1280,
|
||||||
|
toughness: 940,
|
||||||
|
engineering: 615,
|
||||||
|
supportingAttributes: [
|
||||||
|
{ label: 'Critical Hit Chance', value: '18.5%' },
|
||||||
|
{ label: 'Headshot Damage', value: '42%' },
|
||||||
|
{ label: 'Armor', value: '12,400' },
|
||||||
|
],
|
||||||
|
visibility: 'public',
|
||||||
|
directUrl: 'shd.build/builds/vanguard-counter-offensive',
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('build social card', () => {
|
||||||
|
it('escapes XML injection and keeps inputs out of markup and URLs', () => {
|
||||||
|
const attack = `</text><script href="https://attacker.invalid">alert('x') & more</script>`;
|
||||||
|
const svg = buildBuildSocialCardSvg({
|
||||||
|
...input,
|
||||||
|
title: attack,
|
||||||
|
author: `A\u0000gent <&> "one" 'two'`,
|
||||||
|
directUrl: `javascript:alert(1)\n${attack}`,
|
||||||
|
supportingAttributes: [{ label: attack, value: attack }],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(svg).not.toContain('<script');
|
||||||
|
expect(svg).not.toContain('href="https://attacker.invalid"');
|
||||||
|
expect(svg).not.toContain('\u0000');
|
||||||
|
expect(svg).toContain(
|
||||||
|
'</text><script href="https://attacker.invalid">',
|
||||||
|
);
|
||||||
|
expect(svg).toContain(
|
||||||
|
'A gent <&> "one" 'two'',
|
||||||
|
);
|
||||||
|
expect(svg).not.toMatch(/<[^>]+\s(?:href|xlink:href)\s*=/gu);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('wraps and truncates long Unicode text without splitting graphemes', () => {
|
||||||
|
const family = '👨👩👧👦';
|
||||||
|
const svg = buildBuildSocialCardSvg({
|
||||||
|
...input,
|
||||||
|
title: `${family.repeat(31)} ${family.repeat(31)} ${family.repeat(31)}`,
|
||||||
|
author: `Zoë ${'e\u0301'.repeat(60)}`,
|
||||||
|
directUrl: `shd.build/builds/${'路'.repeat(130)}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
const titleBlock =
|
||||||
|
svg.match(/<text class="title">(.*?)<\/text>/u)?.[1] ?? '';
|
||||||
|
expect(titleBlock.match(/<tspan/gu)).toHaveLength(2);
|
||||||
|
expect(titleBlock).toContain('…');
|
||||||
|
expect(titleBlock).not.toMatch(
|
||||||
|
/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/gu,
|
||||||
|
);
|
||||||
|
expect(svg).toContain('é');
|
||||||
|
expect(svg).not.toContain('é');
|
||||||
|
expect(svg).toContain(`${'路'.repeat(20)}`);
|
||||||
|
expect(svg).toContain('…');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('builds deterministic SVG with bounded supporting facts', () => {
|
||||||
|
const withExtraFacts = {
|
||||||
|
...input,
|
||||||
|
supportingAttributes: [
|
||||||
|
...input.supportingAttributes!,
|
||||||
|
{ label: 'Must not render', value: '999' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const first = buildBuildSocialCardSvg(withExtraFacts);
|
||||||
|
const second = buildBuildSocialCardSvg(withExtraFacts);
|
||||||
|
|
||||||
|
expect(first).toBe(second);
|
||||||
|
expect(first).toContain('width="1200" height="630"');
|
||||||
|
expect(first).toContain('SHD</text><text');
|
||||||
|
expect(first).toContain('.build');
|
||||||
|
expect(first).toContain('font-family:"DejaVu Sans Condensed",sans-serif');
|
||||||
|
expect(first).toContain('SUPPORTING ATTRIBUTES / 03');
|
||||||
|
expect(first).not.toContain('Must not render');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders a valid 1200x630 PNG through Sharp', async () => {
|
||||||
|
const png = await renderBuildSocialCardPng(input);
|
||||||
|
const metadata = await sharp(png).metadata();
|
||||||
|
|
||||||
|
expect(png.subarray(0, 8)).toEqual(
|
||||||
|
Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]),
|
||||||
|
);
|
||||||
|
expect(metadata.format).toBe('png');
|
||||||
|
expect(metadata.width).toBe(BUILD_SOCIAL_CARD_WIDTH);
|
||||||
|
expect(metadata.height).toBe(BUILD_SOCIAL_CARD_HEIGHT);
|
||||||
|
expect(png.byteLength).toBeLessThan(1_000_000);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
import sharp from 'sharp';
|
||||||
|
|
||||||
|
export const BUILD_SOCIAL_CARD_WIDTH = 1200;
|
||||||
|
export const BUILD_SOCIAL_CARD_HEIGHT = 630;
|
||||||
|
|
||||||
|
export type BuildVisibility = 'public' | 'unlisted' | 'private';
|
||||||
|
|
||||||
|
export interface BuildSocialCardFact {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BuildSocialCardInput {
|
||||||
|
title: string;
|
||||||
|
author: string;
|
||||||
|
primaryWeapon: string;
|
||||||
|
secondaryWeapon: string;
|
||||||
|
firepower: string | number;
|
||||||
|
toughness: string | number;
|
||||||
|
engineering: string | number;
|
||||||
|
supportingAttributes?: readonly BuildSocialCardFact[];
|
||||||
|
visibility: BuildVisibility;
|
||||||
|
directUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
|
||||||
|
|
||||||
|
function graphemes(value: string): string[] {
|
||||||
|
return Array.from(segmenter.segment(value), ({ segment }) => segment);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeText(value: string | number, maximum: number): string {
|
||||||
|
const normalized = String(value)
|
||||||
|
.normalize('NFC')
|
||||||
|
.replace(/[\u0000-\u001f\u007f-\u009f]/gu, ' ')
|
||||||
|
.replace(/\s+/gu, ' ')
|
||||||
|
.trim();
|
||||||
|
const characters = graphemes(normalized);
|
||||||
|
if (characters.length <= maximum) return normalized;
|
||||||
|
return `${characters
|
||||||
|
.slice(0, Math.max(0, maximum - 1))
|
||||||
|
.join('')
|
||||||
|
.trimEnd()}…`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeXml(value: string): string {
|
||||||
|
return value
|
||||||
|
.replace(/&/gu, '&')
|
||||||
|
.replace(/</gu, '<')
|
||||||
|
.replace(/>/gu, '>')
|
||||||
|
.replace(/"/gu, '"')
|
||||||
|
.replace(/'/gu, ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
function wrapText(
|
||||||
|
value: string,
|
||||||
|
width: number,
|
||||||
|
maximumLines: number,
|
||||||
|
): string[] {
|
||||||
|
const characters = graphemes(value);
|
||||||
|
const lines: string[] = [];
|
||||||
|
let offset = 0;
|
||||||
|
|
||||||
|
while (offset < characters.length && lines.length < maximumLines) {
|
||||||
|
const remaining = characters.slice(offset);
|
||||||
|
if (remaining.length <= width) {
|
||||||
|
lines.push(remaining.join('').trim());
|
||||||
|
offset = characters.length;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidate = remaining.slice(0, width + 1);
|
||||||
|
let splitAt = -1;
|
||||||
|
for (
|
||||||
|
let index = Math.min(width, candidate.length - 1);
|
||||||
|
index > 0;
|
||||||
|
index--
|
||||||
|
) {
|
||||||
|
if (/\s/u.test(candidate[index])) {
|
||||||
|
splitAt = index;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (splitAt < Math.floor(width * 0.55)) splitAt = width;
|
||||||
|
lines.push(remaining.slice(0, splitAt).join('').trim());
|
||||||
|
offset += splitAt;
|
||||||
|
while (offset < characters.length && /\s/u.test(characters[offset]))
|
||||||
|
offset++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (offset < characters.length && lines.length > 0) {
|
||||||
|
const last = graphemes(lines.at(-1) ?? '');
|
||||||
|
lines[lines.length - 1] = `${last
|
||||||
|
.slice(0, Math.max(0, width - 1))
|
||||||
|
.join('')
|
||||||
|
.trimEnd()}…`;
|
||||||
|
}
|
||||||
|
return lines.length > 0 ? lines : ['UNTITLED BUILD'];
|
||||||
|
}
|
||||||
|
|
||||||
|
function textLines(
|
||||||
|
lines: readonly string[],
|
||||||
|
x: number,
|
||||||
|
y: number,
|
||||||
|
lineHeight: number,
|
||||||
|
): string {
|
||||||
|
return lines
|
||||||
|
.map(
|
||||||
|
(line, index) =>
|
||||||
|
`<tspan x="${x}" y="${y + index * lineHeight}">${escapeXml(line)}</tspan>`,
|
||||||
|
)
|
||||||
|
.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function visibilityLabel(visibility: BuildVisibility): string {
|
||||||
|
if (visibility === 'public') return 'PUBLIC';
|
||||||
|
if (visibility === 'unlisted') return 'UNLISTED';
|
||||||
|
return 'PRIVATE';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Builds the complete, text-only SVG supplied to Sharp. */
|
||||||
|
export function buildBuildSocialCardSvg(input: BuildSocialCardInput): string {
|
||||||
|
const title = wrapText(normalizeText(input.title, 112), 31, 2);
|
||||||
|
const author = normalizeText(input.author, 42) || 'UNKNOWN AGENT';
|
||||||
|
const primaryWeapon = normalizeText(input.primaryWeapon, 52) || 'UNSPECIFIED';
|
||||||
|
const secondaryWeapon =
|
||||||
|
normalizeText(input.secondaryWeapon, 52) || 'UNSPECIFIED';
|
||||||
|
const firepower = normalizeText(input.firepower, 18) || '—';
|
||||||
|
const toughness = normalizeText(input.toughness, 18) || '—';
|
||||||
|
const engineering = normalizeText(input.engineering, 18) || '—';
|
||||||
|
const directUrl = normalizeText(input.directUrl, 92) || 'shd.build';
|
||||||
|
const facts = (input.supportingAttributes ?? []).slice(0, 3).map((fact) => ({
|
||||||
|
label: normalizeText(fact.label.toUpperCase(), 28) || 'ATTRIBUTE',
|
||||||
|
value: normalizeText(fact.value, 30) || '—',
|
||||||
|
}));
|
||||||
|
|
||||||
|
const factMarkup = facts
|
||||||
|
.map((fact, index) => {
|
||||||
|
const y = 421 + index * 48;
|
||||||
|
return `<g><line x1="746" y1="${y + 13}" x2="764" y2="${y + 13}" class="signal"/><text x="778" y="${y}" class="fact-label">${escapeXml(fact.label)}</text><text x="778" y="${y + 22}" class="fact-value">${escapeXml(fact.value)}</text></g>`;
|
||||||
|
})
|
||||||
|
.join('');
|
||||||
|
|
||||||
|
return `<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="630" viewBox="0 0 1200 630" role="img" aria-label="SHD.build build card">
|
||||||
|
<style>
|
||||||
|
text{font-family:"DejaVu Sans Condensed",sans-serif}.telemetry{font-size:13px;font-weight:700;letter-spacing:2px;fill:#858b8d}.title{font-size:54px;font-weight:900;letter-spacing:-1px;fill:#ecebe5}.weapon{font-size:25px;font-weight:800;fill:#ecebe5}.stat-label,.fact-label{font-size:12px;font-weight:800;letter-spacing:1.5px;fill:#858b8d}.stat-value{font-size:31px;font-weight:900;fill:#ecebe5}.fact-value{font-size:19px;font-weight:700;fill:#ecebe5}.signal{stroke:#f6a821;stroke-width:2}.grid{stroke:#272c2d;stroke-width:1}.panel{fill:#111415;stroke:#303536;stroke-width:1}
|
||||||
|
</style>
|
||||||
|
<rect width="1200" height="630" fill="#0a0c0d"/><path d="M0 96H1200M0 594H1200M710 96V594" class="grid"/><path d="M28 28H76M28 28V76M1172 28H1124M1172 28V76M28 602H76M28 602V554M1172 602H1124M1172 602V554" class="signal"/>
|
||||||
|
<g transform="translate(52 45)"><text x="0" y="0" font-size="34" font-weight="900" fill="#ecebe5">SHD</text><text x="67" y="0" font-size="34" font-weight="900" fill="#f6a821">.build</text><text x="191" y="-2" class="telemetry">BUILD DOSSIER / SOCIAL TRANSMISSION</text></g>
|
||||||
|
<text x="52" y="139" class="telemetry">AGENT LOADOUT</text><text class="title">${textLines(title, 52, 201, 61)}</text><text x="52" y="294" class="telemetry">AUTHORED BY</text><text x="164" y="294" font-size="18" font-weight="800" fill="#ecebe5">${escapeXml(author)}</text>
|
||||||
|
<g><rect x="52" y="328" width="622" height="88" class="panel"/><rect x="52" y="328" width="4" height="88" fill="#f6a821"/><text x="76" y="356" class="stat-label">PRIMARY WEAPON</text><text x="76" y="391" class="weapon">${escapeXml(primaryWeapon)}</text></g>
|
||||||
|
<g><rect x="52" y="430" width="622" height="88" class="panel"/><text x="76" y="458" class="stat-label">SECONDARY WEAPON</text><text x="76" y="493" class="weapon">${escapeXml(secondaryWeapon)}</text></g>
|
||||||
|
<g transform="translate(746 132)"><text x="0" y="0" class="telemetry">CORE ATTRIBUTES</text><rect x="0" y="24" width="402" height="174" class="panel"/><line x1="134" y1="24" x2="134" y2="198" class="grid"/><line x1="268" y1="24" x2="268" y2="198" class="grid"/><text x="18" y="66" class="stat-label">FIREPOWER</text><text x="18" y="112" class="stat-value">${escapeXml(firepower)}</text><text x="152" y="66" class="stat-label">TOUGHNESS</text><text x="152" y="112" class="stat-value">${escapeXml(toughness)}</text><text x="286" y="66" class="stat-label">ENGINEERING</text><text x="286" y="112" class="stat-value">${escapeXml(engineering)}</text><path d="M18 166H116M152 166H250M286 166H384" class="signal"/></g>
|
||||||
|
<text x="746" y="374" class="telemetry">SUPPORTING ATTRIBUTES / ${String(facts.length).padStart(2, '0')}</text>${factMarkup}
|
||||||
|
<g><text x="52" y="566" class="telemetry">DIRECT LINK</text><text x="157" y="566" font-size="15" font-weight="700" fill="#ecebe5">${escapeXml(directUrl)}</text><rect x="972" y="541" width="176" height="36" fill="#15191a" stroke="#f6a821"/><text x="1060" y="564" text-anchor="middle" font-size="13" font-weight="900" letter-spacing="2" fill="#f6a821">${visibilityLabel(input.visibility)}</text></g>
|
||||||
|
</svg>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Renders a deterministic 1200x630 PNG without loading external resources. */
|
||||||
|
export async function renderBuildSocialCardPng(
|
||||||
|
input: BuildSocialCardInput,
|
||||||
|
): Promise<Buffer> {
|
||||||
|
return sharp(Buffer.from(buildBuildSocialCardSvg(input)), {
|
||||||
|
density: 72,
|
||||||
|
failOn: 'error',
|
||||||
|
unlimited: false,
|
||||||
|
})
|
||||||
|
.png({ compressionLevel: 9, adaptiveFiltering: false, palette: false })
|
||||||
|
.toBuffer();
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { publicOrigin } from './public-origin';
|
||||||
|
|
||||||
|
describe('publicOrigin', () => {
|
||||||
|
it('uses the configured public origin instead of a request host', () => {
|
||||||
|
expect(
|
||||||
|
publicOrigin('https://host-header.invalid', 'https://shd.build/app/path'),
|
||||||
|
).toBe('https://shd.build');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the request origin when APP_ORIGIN is not configured', () => {
|
||||||
|
expect(publicOrigin('http://localhost:5173', '')).toBe(
|
||||||
|
'http://localhost:5173',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects non-HTTP origins', () => {
|
||||||
|
expect(() =>
|
||||||
|
publicOrigin('https://shd.build', 'javascript:alert(1)'),
|
||||||
|
).toThrow('APP_ORIGIN must use HTTP or HTTPS.');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { env } from '$env/dynamic/private';
|
||||||
|
|
||||||
|
export function publicOrigin(
|
||||||
|
fallback: string,
|
||||||
|
configuredOrigin: string | undefined = env.APP_ORIGIN,
|
||||||
|
): string {
|
||||||
|
const value = configuredOrigin?.trim() || fallback;
|
||||||
|
const url = new URL(value);
|
||||||
|
if (url.protocol !== 'https:' && url.protocol !== 'http:') {
|
||||||
|
throw new Error('APP_ORIGIN must use HTTP or HTTPS.');
|
||||||
|
}
|
||||||
|
return url.origin;
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { createDraft } from '$lib/builder/loadout';
|
||||||
|
|
||||||
|
const mocks = vi.hoisted(() => ({
|
||||||
|
row: null as Record<string, unknown> | null,
|
||||||
|
loadCatalog: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('$lib/server/database', () => ({
|
||||||
|
getDatabase: vi.fn(async () => ({
|
||||||
|
prepare: vi.fn(async (sql: string) => ({
|
||||||
|
get: vi.fn(async () => (sql.includes('FROM builds') ? mocks.row : null)),
|
||||||
|
all: vi.fn(async () => []),
|
||||||
|
})),
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
vi.mock('$lib/server/shared-build-summary', () => ({
|
||||||
|
loadSharedBuildSummaryCatalog: mocks.loadCatalog,
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { loadSharedBuildProjection } from './shared-build';
|
||||||
|
|
||||||
|
function buildRow(visibility: 'private' | 'unlisted' | 'public') {
|
||||||
|
return {
|
||||||
|
id: 'build-id',
|
||||||
|
owner_id: 'owner-id',
|
||||||
|
title: 'Field Medic',
|
||||||
|
slug: 'field-medic-12345678',
|
||||||
|
visibility,
|
||||||
|
author: 'Aewyn',
|
||||||
|
profile_is_public: 1,
|
||||||
|
loadout_json: JSON.stringify(createDraft()),
|
||||||
|
updated_at: '2026-08-02T00:00:00.000Z',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('shared Build access policy', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mocks.row = null;
|
||||||
|
mocks.loadCatalog.mockReset().mockResolvedValue({
|
||||||
|
attributes: [],
|
||||||
|
gear: [],
|
||||||
|
gearAugments: [],
|
||||||
|
brands: [],
|
||||||
|
weapons: [],
|
||||||
|
talents: [],
|
||||||
|
chipSets: [],
|
||||||
|
protocols: [],
|
||||||
|
specializations: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(['public', 'unlisted'] as const)(
|
||||||
|
'permits %s Builds in public previews',
|
||||||
|
async (visibility) => {
|
||||||
|
mocks.row = buildRow(visibility);
|
||||||
|
const result = await loadSharedBuildProjection('field-medic-12345678', {
|
||||||
|
kind: 'public-preview',
|
||||||
|
});
|
||||||
|
expect(result?.build.visibility).toBe(visibility);
|
||||||
|
expect(result?.build.owner).toBe(false);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it('does not expose private Builds to public previews or other viewers', async () => {
|
||||||
|
mocks.row = buildRow('private');
|
||||||
|
await expect(
|
||||||
|
loadSharedBuildProjection('field-medic-12345678', {
|
||||||
|
kind: 'public-preview',
|
||||||
|
}),
|
||||||
|
).resolves.toBeNull();
|
||||||
|
await expect(
|
||||||
|
loadSharedBuildProjection('field-medic-12345678', {
|
||||||
|
kind: 'viewer-page',
|
||||||
|
viewerId: 'another-agent',
|
||||||
|
}),
|
||||||
|
).resolves.toBeNull();
|
||||||
|
expect(mocks.loadCatalog).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('permits the owner to view a private Build page', async () => {
|
||||||
|
mocks.row = buildRow('private');
|
||||||
|
const result = await loadSharedBuildProjection('field-medic-12345678', {
|
||||||
|
kind: 'viewer-page',
|
||||||
|
viewerId: 'owner-id',
|
||||||
|
});
|
||||||
|
expect(result?.build.owner).toBe(true);
|
||||||
|
expect(result?.build.visibility).toBe('private');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null for malformed loadout snapshots', async () => {
|
||||||
|
mocks.row = { ...buildRow('public'), loadout_json: '{invalid' };
|
||||||
|
await expect(
|
||||||
|
loadSharedBuildProjection('field-medic-12345678', {
|
||||||
|
kind: 'public-preview',
|
||||||
|
}),
|
||||||
|
).resolves.toBeNull();
|
||||||
|
expect(mocks.loadCatalog).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import { avatarUrl } from '$lib/avatar';
|
||||||
|
import { parseLoadoutDraft } from '$lib/builder/loadout';
|
||||||
|
import { summarizeLoadout } from '$lib/builder/loadout-summary';
|
||||||
|
import { getDatabase } from '$lib/server/database';
|
||||||
|
import { loadSharedBuildSummaryCatalog } from '$lib/server/shared-build-summary';
|
||||||
|
|
||||||
|
export type SharedBuildAccessPolicy =
|
||||||
|
{ kind: 'viewer-page'; viewerId: string | null } | { kind: 'public-preview' };
|
||||||
|
type BuildVisibility = 'public' | 'unlisted' | 'private';
|
||||||
|
|
||||||
|
export async function loadSharedBuildProjection(
|
||||||
|
slug: string,
|
||||||
|
access: SharedBuildAccessPolicy,
|
||||||
|
) {
|
||||||
|
const database = await getDatabase();
|
||||||
|
const build = await (
|
||||||
|
await database.prepare(`
|
||||||
|
SELECT builds.*,COALESCE(user_profiles.display_name,users.username,'Deleted Agent') AS author,
|
||||||
|
user_profiles.public_slug,
|
||||||
|
COALESCE(user_profiles.is_public,1) AS profile_is_public,deleted_accounts.user_id AS deleted_user_id,
|
||||||
|
user_avatars.source AS avatar_source,user_avatars.value AS avatar_value,
|
||||||
|
user_avatars.updated_at AS avatar_updated_at
|
||||||
|
FROM builds
|
||||||
|
JOIN users ON users.id=builds.owner_id
|
||||||
|
LEFT JOIN user_profiles ON user_profiles.user_id=users.id
|
||||||
|
LEFT JOIN deleted_accounts ON deleted_accounts.user_id=users.id
|
||||||
|
LEFT JOIN user_avatars ON user_avatars.user_id=users.id
|
||||||
|
WHERE builds.slug=?
|
||||||
|
`)
|
||||||
|
).get(slug);
|
||||||
|
|
||||||
|
if (!build) return null;
|
||||||
|
|
||||||
|
const ownerId = String(build.owner_id);
|
||||||
|
const visibility = String(build.visibility) as BuildVisibility;
|
||||||
|
const viewerId = access.kind === 'viewer-page' ? access.viewerId : null;
|
||||||
|
const accessible =
|
||||||
|
access.kind === 'public-preview'
|
||||||
|
? visibility === 'public' || visibility === 'unlisted'
|
||||||
|
: visibility === 'public' ||
|
||||||
|
visibility === 'unlisted' ||
|
||||||
|
(visibility === 'private' && ownerId === viewerId);
|
||||||
|
if (!accessible) return null;
|
||||||
|
|
||||||
|
let loadout;
|
||||||
|
try {
|
||||||
|
loadout = parseLoadoutDraft(JSON.parse(String(build.loadout_json)));
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!loadout) return null;
|
||||||
|
|
||||||
|
const [gear, weapons, chips, protocols, brands, summaryCatalog] =
|
||||||
|
await Promise.all([
|
||||||
|
(
|
||||||
|
await database.prepare(
|
||||||
|
'SELECT id,name,slot,tier,base_armor_min FROM gear',
|
||||||
|
)
|
||||||
|
).all(),
|
||||||
|
(
|
||||||
|
await database.prepare(
|
||||||
|
'SELECT id,name,category,is_exotic,rpm,magazine,reload_speed,damage_type FROM weapons',
|
||||||
|
)
|
||||||
|
).all(),
|
||||||
|
(await database.prepare('SELECT id,name FROM skill_chip_sets')).all(),
|
||||||
|
(
|
||||||
|
await database.prepare('SELECT id,name,quality FROM os_protocols')
|
||||||
|
).all(),
|
||||||
|
(await database.prepare('SELECT id,name FROM brand_sets')).all(),
|
||||||
|
loadSharedBuildSummaryCatalog(database),
|
||||||
|
]);
|
||||||
|
const summary = summarizeLoadout(loadout, summaryCatalog);
|
||||||
|
|
||||||
|
return {
|
||||||
|
build: {
|
||||||
|
id: String(build.id),
|
||||||
|
title: String(build.title),
|
||||||
|
slug: String(build.slug),
|
||||||
|
visibility,
|
||||||
|
author: String(build.author),
|
||||||
|
ownerId,
|
||||||
|
authorPublicSlug: build.public_slug ? String(build.public_slug) : null,
|
||||||
|
profileAvailable:
|
||||||
|
Boolean(build.profile_is_public) && !build.deleted_user_id,
|
||||||
|
authorAvatarUrl: avatarUrl(
|
||||||
|
build.avatar_source,
|
||||||
|
build.avatar_value,
|
||||||
|
ownerId,
|
||||||
|
build.avatar_updated_at,
|
||||||
|
),
|
||||||
|
owner: ownerId === viewerId,
|
||||||
|
updatedAt: String(build.updated_at),
|
||||||
|
},
|
||||||
|
loadout,
|
||||||
|
gear,
|
||||||
|
weapons,
|
||||||
|
chips,
|
||||||
|
protocols,
|
||||||
|
brands,
|
||||||
|
summary,
|
||||||
|
attributes: summaryCatalog.attributes,
|
||||||
|
talents: summaryCatalog.talents,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import { getSessionUser } from '$lib/server/auth/session';
|
|||||||
import { fail } from '@sveltejs/kit';
|
import { fail } from '@sveltejs/kit';
|
||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
import { parseLoadoutDraft } from '$lib/builder/loadout';
|
import { parseLoadoutDraft } from '$lib/builder/loadout';
|
||||||
|
import { publicOrigin } from '$lib/server/public-origin';
|
||||||
import type { Actions, PageServerLoad } from './$types';
|
import type { Actions, PageServerLoad } from './$types';
|
||||||
import { attachWeaponCategoryBonuses } from '$lib/weapons/category-bonuses';
|
import { attachWeaponCategoryBonuses } from '$lib/weapons/category-bonuses';
|
||||||
|
|
||||||
@@ -284,6 +285,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
|||||||
}));
|
}));
|
||||||
return {
|
return {
|
||||||
viewer: viewer ? { id: viewer.id, displayName: viewer.displayName } : null,
|
viewer: viewer ? { id: viewer.id, displayName: viewer.displayName } : null,
|
||||||
|
publicOrigin: publicOrigin(url.origin),
|
||||||
initialBuild,
|
initialBuild,
|
||||||
attributes,
|
attributes,
|
||||||
coreRanges,
|
coreRanges,
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
import SegmentedChoice from '$lib/components/SegmentedChoice.svelte';
|
import SegmentedChoice from '$lib/components/SegmentedChoice.svelte';
|
||||||
import PendingForm from '$lib/components/PendingForm.svelte';
|
import PendingForm from '$lib/components/PendingForm.svelte';
|
||||||
import ItemIcon from '$lib/components/ItemIcon.svelte';
|
import ItemIcon from '$lib/components/ItemIcon.svelte';
|
||||||
|
import BuildShareControl from '$lib/components/BuildShareControl.svelte';
|
||||||
import LoadoutMatrix, { type MatrixCell } from '$lib/components/builder/LoadoutMatrix.svelte';
|
import LoadoutMatrix, { type MatrixCell } from '$lib/components/builder/LoadoutMatrix.svelte';
|
||||||
import LoadoutStatsSummary from '$lib/components/builder/LoadoutStatsSummary.svelte';
|
import LoadoutStatsSummary from '$lib/components/builder/LoadoutStatsSummary.svelte';
|
||||||
import PlannedCombatModel from '$lib/components/builder/PlannedCombatModel.svelte';
|
import PlannedCombatModel from '$lib/components/builder/PlannedCombatModel.svelte';
|
||||||
@@ -25,7 +26,10 @@
|
|||||||
let message = $state('');
|
let message = $state('');
|
||||||
let persistedBuildId = $state(untrack(() => data.initialBuild?.id ?? ''));
|
let persistedBuildId = $state(untrack(() => data.initialBuild?.id ?? ''));
|
||||||
let persistedSlug = $state(untrack(() => data.initialBuild?.slug ?? ''));
|
let persistedSlug = $state(untrack(() => data.initialBuild?.slug ?? ''));
|
||||||
let persistedVisibility = $state(untrack(() => data.initialBuild?.visibility ?? 'private'));
|
type BuildVisibility = 'private' | 'unlisted' | 'public';
|
||||||
|
let persistedVisibility = $state<BuildVisibility>(untrack(() => (data.initialBuild?.visibility ?? 'private') as BuildVisibility));
|
||||||
|
let savedBuildVisibility = $state<BuildVisibility>(untrack(() => (data.initialBuild?.visibility ?? 'private') as BuildVisibility));
|
||||||
|
let savedBuildTitle = $state(untrack(() => draft.buildName));
|
||||||
let handledBuildToken = '';
|
let handledBuildToken = '';
|
||||||
const weaponRangeKeys = ['damage','firepower','toughness','engineering'] as const;
|
const weaponRangeKeys = ['damage','firepower','toughness','engineering'] as const;
|
||||||
const coreKeys = ['firepower','toughness','engineering'] as const;
|
const coreKeys = ['firepower','toughness','engineering'] as const;
|
||||||
@@ -378,7 +382,9 @@
|
|||||||
handledBuildToken = form.buildToken;
|
handledBuildToken = form.buildToken;
|
||||||
persistedBuildId = form.savedBuild.id;
|
persistedBuildId = form.savedBuild.id;
|
||||||
persistedSlug = form.savedBuild.slug;
|
persistedSlug = form.savedBuild.slug;
|
||||||
persistedVisibility = form.savedBuild.visibility;
|
persistedVisibility = form.savedBuild.visibility as BuildVisibility;
|
||||||
|
savedBuildVisibility = form.savedBuild.visibility as BuildVisibility;
|
||||||
|
savedBuildTitle = form.savedBuild.title;
|
||||||
message = form.savedBuild.visibility === 'private'
|
message = form.savedBuild.visibility === 'private'
|
||||||
? 'Private build saved to your account.'
|
? 'Private build saved to your account.'
|
||||||
: form.savedBuild.visibility === 'unlisted'
|
: form.savedBuild.visibility === 'unlisted'
|
||||||
@@ -419,15 +425,16 @@
|
|||||||
{ value:'private', label:'PRIVATE' },
|
{ value:'private', label:'PRIVATE' },
|
||||||
{ value:'unlisted', label:'UNLISTED' },
|
{ value:'unlisted', label:'UNLISTED' },
|
||||||
{ value:'public', label:'PUBLIC' }
|
{ value:'public', label:'PUBLIC' }
|
||||||
]} value={persistedVisibility} onchange={(values) => persistedVisibility = values[0]} required/>
|
]} value={persistedVisibility} onchange={(values) => persistedVisibility = values[0] as BuildVisibility} required/>
|
||||||
<p>{persistedVisibility === 'private' ? 'Only you can open this build.' : persistedVisibility === 'unlisted' ? 'Anyone with its direct link can open this build.' : 'Listed publicly and accessible by direct link.'}</p>
|
<p>{persistedVisibility === 'private' ? 'Only you can open this build.' : persistedVisibility === 'unlisted' ? 'Anyone with its direct link can open this build.' : 'Listed publicly and accessible by direct link.'}</p>
|
||||||
{#if persistedSlug && persistedVisibility !== 'private'}<a class="builder-share-link" href={`/builds/${persistedSlug}`}>VIEW SHARE PAGE →</a>{/if}
|
{#if persistedSlug && savedBuildVisibility !== 'private'}<a class="builder-share-link" href={`/builds/${persistedSlug}`}>VIEW SHARE PAGE →</a>{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
<div class="builder-status"><span><i></i> KNOWN STATS ONLINE</span><span>ADVANCED MODEL · COMING SOON</span><span>12 LOADOUT CELLS + 9 CHIPS</span><span>{data.gear.length + data.weapons.length + data.chipSets.length + data.protocols.length} CATALOG ITEMS</span></div>
|
<div class="builder-status"><span><i></i> KNOWN STATS ONLINE</span><span>ADVANCED MODEL · COMING SOON</span><span>12 LOADOUT CELLS + 9 CHIPS</span><span>{data.gear.length + data.weapons.length + data.chipSets.length + data.protocols.length} CATALOG ITEMS</span></div>
|
||||||
</div>
|
</div>
|
||||||
{#if message}<p class="success" role="status">{message}</p>{/if}
|
{#if message}<p class="success" role="status">{message}</p>{/if}
|
||||||
{#if form?.buildError}<p class="error" role="alert">{form.buildError}</p>{/if}
|
{#if form?.buildError}<p class="error" role="alert">{form.buildError}</p>{/if}
|
||||||
|
{#if data.viewer && persistedSlug}<BuildShareControl title={savedBuildTitle} slugOrUrl={new URL(`/builds/${encodeURIComponent(persistedSlug)}`, data.publicOrigin).toString()} visibility={savedBuildVisibility}/>{/if}
|
||||||
{#if !data.gear.length && !data.weapons.length && !data.chipSets.length && !data.protocols.length}<div class="builder-empty-data"><strong>ISAC DATA LINK EMPTY</strong><span>Add active equipment definitions in ISAC to populate this workbench.</span></div>{/if}
|
{#if !data.gear.length && !data.weapons.length && !data.chipSets.length && !data.protocols.length}<div class="builder-empty-data"><strong>ISAC DATA LINK EMPTY</strong><span>Add active equipment definitions in ISAC to populate this workbench.</span></div>{/if}
|
||||||
<div class="loadout-workspace">
|
<div class="loadout-workspace">
|
||||||
<LoadoutMatrix
|
<LoadoutMatrix
|
||||||
|
|||||||
@@ -1,91 +1,15 @@
|
|||||||
import { error } from '@sveltejs/kit';
|
import { error } from '@sveltejs/kit';
|
||||||
import { getDatabase } from '$lib/server/database';
|
|
||||||
import { getSessionUser } from '$lib/server/auth/session';
|
import { getSessionUser } from '$lib/server/auth/session';
|
||||||
import { parseLoadoutDraft, type LoadoutDraft } from '$lib/builder/loadout';
|
import { publicOrigin } from '$lib/server/public-origin';
|
||||||
import { summarizeLoadout } from '$lib/builder/loadout-summary';
|
import { loadSharedBuildProjection } from '$lib/server/shared-build';
|
||||||
import { loadSharedBuildSummaryCatalog } from '$lib/server/shared-build-summary';
|
|
||||||
import { avatarUrl } from '$lib/avatar';
|
|
||||||
import type { PageServerLoad } from './$types';
|
import type { PageServerLoad } from './$types';
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ params, cookies }) => {
|
export const load: PageServerLoad = async ({ params, cookies, url }) => {
|
||||||
const database = await getDatabase();
|
|
||||||
const viewer = await getSessionUser(cookies);
|
const viewer = await getSessionUser(cookies);
|
||||||
const build = await (
|
const projection = await loadSharedBuildProjection(params.slug, {
|
||||||
await database.prepare(`
|
kind: 'viewer-page',
|
||||||
SELECT builds.*,COALESCE(user_profiles.display_name,users.username,'Deleted Agent') AS author,
|
viewerId: viewer?.id ?? null,
|
||||||
user_profiles.public_slug,
|
});
|
||||||
COALESCE(user_profiles.is_public,1) AS profile_is_public,deleted_accounts.user_id AS deleted_user_id,
|
if (!projection) error(404, 'Build not found');
|
||||||
user_avatars.source AS avatar_source,user_avatars.value AS avatar_value,
|
return { ...projection, publicOrigin: publicOrigin(url.origin) };
|
||||||
user_avatars.updated_at AS avatar_updated_at
|
|
||||||
FROM builds
|
|
||||||
JOIN users ON users.id=builds.owner_id
|
|
||||||
LEFT JOIN user_profiles ON user_profiles.user_id=users.id
|
|
||||||
LEFT JOIN deleted_accounts ON deleted_accounts.user_id=users.id
|
|
||||||
LEFT JOIN user_avatars ON user_avatars.user_id=users.id
|
|
||||||
WHERE builds.slug=?
|
|
||||||
`)
|
|
||||||
).get(params.slug);
|
|
||||||
if (
|
|
||||||
!build ||
|
|
||||||
(build.visibility === 'private' && build.owner_id !== viewer?.id)
|
|
||||||
)
|
|
||||||
error(404, 'Build not found');
|
|
||||||
let loadout: LoadoutDraft;
|
|
||||||
try {
|
|
||||||
const parsed = parseLoadoutDraft(JSON.parse(String(build.loadout_json)));
|
|
||||||
if (!parsed) error(404, 'Build data is unavailable');
|
|
||||||
loadout = parsed;
|
|
||||||
} catch {
|
|
||||||
error(404, 'Build data is unavailable');
|
|
||||||
}
|
|
||||||
const [gear, weapons, chips, protocols, brands, summaryCatalog] =
|
|
||||||
await Promise.all([
|
|
||||||
(
|
|
||||||
await database.prepare(
|
|
||||||
'SELECT id,name,slot,tier,base_armor_min FROM gear',
|
|
||||||
)
|
|
||||||
).all(),
|
|
||||||
(
|
|
||||||
await database.prepare(
|
|
||||||
'SELECT id,name,category,is_exotic,rpm,magazine,reload_speed,damage_type FROM weapons',
|
|
||||||
)
|
|
||||||
).all(),
|
|
||||||
(await database.prepare('SELECT id,name FROM skill_chip_sets')).all(),
|
|
||||||
(
|
|
||||||
await database.prepare('SELECT id,name,quality FROM os_protocols')
|
|
||||||
).all(),
|
|
||||||
(await database.prepare('SELECT id,name FROM brand_sets')).all(),
|
|
||||||
loadSharedBuildSummaryCatalog(database),
|
|
||||||
]);
|
|
||||||
const summary = summarizeLoadout(loadout, summaryCatalog);
|
|
||||||
return {
|
|
||||||
build: {
|
|
||||||
id: String(build.id),
|
|
||||||
title: String(build.title),
|
|
||||||
slug: String(build.slug),
|
|
||||||
visibility: String(build.visibility),
|
|
||||||
author: String(build.author),
|
|
||||||
ownerId: String(build.owner_id),
|
|
||||||
authorPublicSlug: build.public_slug ? String(build.public_slug) : null,
|
|
||||||
profileAvailable:
|
|
||||||
Boolean(build.profile_is_public) && !build.deleted_user_id,
|
|
||||||
authorAvatarUrl: avatarUrl(
|
|
||||||
build.avatar_source,
|
|
||||||
build.avatar_value,
|
|
||||||
String(build.owner_id),
|
|
||||||
build.avatar_updated_at,
|
|
||||||
),
|
|
||||||
owner: build.owner_id === viewer?.id,
|
|
||||||
updatedAt: String(build.updated_at),
|
|
||||||
},
|
|
||||||
loadout,
|
|
||||||
gear,
|
|
||||||
weapons,
|
|
||||||
chips,
|
|
||||||
protocols,
|
|
||||||
brands,
|
|
||||||
summary,
|
|
||||||
attributes: summaryCatalog.attributes,
|
|
||||||
talents: summaryCatalog.talents,
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { gearSlots } from '$lib/builder/loadout';
|
import { gearSlots } from '$lib/builder/loadout';
|
||||||
import AgentIdentityLink from '$lib/components/AgentIdentityLink.svelte';
|
import AgentIdentityLink from '$lib/components/AgentIdentityLink.svelte';
|
||||||
|
import BuildShareControl from '$lib/components/BuildShareControl.svelte';
|
||||||
import LoadoutStatsSummary from '$lib/components/builder/LoadoutStatsSummary.svelte';
|
import LoadoutStatsSummary from '$lib/components/builder/LoadoutStatsSummary.svelte';
|
||||||
import PlannedCombatModel from '$lib/components/builder/PlannedCombatModel.svelte';
|
import PlannedCombatModel from '$lib/components/builder/PlannedCombatModel.svelte';
|
||||||
let { data } = $props();
|
let { data } = $props();
|
||||||
@@ -17,14 +18,37 @@
|
|||||||
.join(' · ');
|
.join(' · ');
|
||||||
const coreLine = (selection: { firepower: number; toughness: number; engineering: number }) =>
|
const coreLine = (selection: { firepower: number; toughness: number; engineering: number }) =>
|
||||||
`FP ${number.format(selection.firepower)} · TGH ${number.format(selection.toughness)} · ENG ${number.format(selection.engineering)}`;
|
`FP ${number.format(selection.firepower)} · TGH ${number.format(selection.toughness)} · ENG ${number.format(selection.engineering)}`;
|
||||||
|
const description = $derived(`${data.build.title}, a Division Resurgence Build by ${data.build.author}. View its equipped weapons, gear, Skill Chips, and known Agent stats on SHD.build.`);
|
||||||
|
const canonicalUrl = $derived(new URL(`/builds/${encodeURIComponent(data.build.slug)}`, data.publicOrigin).toString());
|
||||||
|
const previewUrl = $derived(new URL(`${canonicalUrl}/preview.png`).toString());
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head><title>{data.build.title} — SHD.build</title></svelte:head>
|
<svelte:head>
|
||||||
|
<title>{data.build.title} — SHD.build</title>
|
||||||
|
<meta name="description" content={description}/>
|
||||||
|
<link rel="canonical" href={canonicalUrl}/>
|
||||||
|
<meta property="og:type" content="website"/>
|
||||||
|
<meta property="og:site_name" content="SHD.build"/>
|
||||||
|
<meta property="og:title" content={`${data.build.title} — SHD.build`}/>
|
||||||
|
<meta property="og:description" content={description}/>
|
||||||
|
<meta property="og:url" content={canonicalUrl}/>
|
||||||
|
{#if data.build.visibility !== 'private'}
|
||||||
|
<meta property="og:image" content={previewUrl}/>
|
||||||
|
<meta property="og:image:width" content="1200"/>
|
||||||
|
<meta property="og:image:height" content="630"/>
|
||||||
|
<meta property="og:image:type" content="image/png"/>
|
||||||
|
<meta property="og:image:alt" content={`Build preview for ${data.build.title}`}/>
|
||||||
|
<meta name="twitter:card" content="summary_large_image"/>
|
||||||
|
<meta name="twitter:image" content={previewUrl}/>
|
||||||
|
{/if}
|
||||||
|
{#if data.build.visibility !== 'public'}<meta name="robots" content="noindex,nofollow"/>{/if}
|
||||||
|
</svelte:head>
|
||||||
<main class="app-page shared-build-page">
|
<main class="app-page shared-build-page">
|
||||||
<header class="shared-build-command">
|
<header class="shared-build-command">
|
||||||
<div><p class="eyebrow"><span></span> SHD LOADOUT ARCHIVE / {data.build.visibility.toUpperCase()}</p><h1>{data.build.title}</h1><AgentIdentityLink size="compact" linked={data.build.profileAvailable} agent={{ id: data.build.ownerId, displayName: data.build.author, publicSlug: data.build.authorPublicSlug, avatarUrl: data.build.authorAvatarUrl }} meta={`UPDATED ${new Date(data.build.updatedAt).toLocaleDateString()}`}/></div>
|
<div><p class="eyebrow"><span></span> SHD LOADOUT ARCHIVE / {data.build.visibility.toUpperCase()}</p><h1>{data.build.title}</h1><AgentIdentityLink size="compact" linked={data.build.profileAvailable} agent={{ id: data.build.ownerId, displayName: data.build.author, publicSlug: data.build.authorPublicSlug, avatarUrl: data.build.authorAvatarUrl }} meta={`UPDATED ${new Date(data.build.updatedAt).toLocaleDateString()}`}/></div>
|
||||||
<div>{#if data.build.owner}<a class="secondary" href={`/guides/new?build=${encodeURIComponent(data.build.id)}`}>CREATE GUIDE FOR BUILD</a><a class="secondary" href={`/builder?build=${data.build.slug}`}>EDIT LOADOUT</a>{/if}<a class="primary" href="/builder">NEW BUILD +</a></div>
|
<div>{#if data.build.owner}<a class="secondary" href={`/guides/new?build=${encodeURIComponent(data.build.id)}`}>CREATE GUIDE FOR BUILD</a><a class="secondary" href={`/builder?build=${data.build.slug}`}>EDIT LOADOUT</a>{/if}<a class="primary" href="/builder">NEW BUILD +</a></div>
|
||||||
</header>
|
</header>
|
||||||
|
<BuildShareControl title={data.build.title} slugOrUrl={canonicalUrl} visibility={data.build.visibility}/>
|
||||||
<div class="shared-build-intelligence">
|
<div class="shared-build-intelligence">
|
||||||
<LoadoutStatsSummary summary={data.summary} attributes={data.attributes} talents={data.talents} headingId="shared-known-stats-heading" layout="page" />
|
<LoadoutStatsSummary summary={data.summary} attributes={data.attributes} talents={data.talents} headingId="shared-known-stats-heading" layout="page" />
|
||||||
<PlannedCombatModel headingId="shared-planned-model-heading" headingLevel="h2" variant="compact" />
|
<PlannedCombatModel headingId="shared-planned-model-heading" headingLevel="h2" variant="compact" />
|
||||||
|
|||||||
@@ -0,0 +1,180 @@
|
|||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
import { error } from '@sveltejs/kit';
|
||||||
|
import {
|
||||||
|
renderBuildSocialCardPng,
|
||||||
|
type BuildSocialCardFact,
|
||||||
|
type BuildSocialCardInput,
|
||||||
|
} from '$lib/server/build-social-card';
|
||||||
|
import { publicOrigin } from '$lib/server/public-origin';
|
||||||
|
import { loadSharedBuildProjection } from '$lib/server/shared-build';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
|
||||||
|
const number = new Intl.NumberFormat('en-US', { maximumFractionDigits: 2 });
|
||||||
|
const MAX_CACHE_BYTES = 16 * 1024 * 1024;
|
||||||
|
const MAX_ACTIVE_REQUESTS = 4;
|
||||||
|
const MAX_QUEUED_REQUESTS = 32;
|
||||||
|
type CachedImage = { body: Buffer; etag: string };
|
||||||
|
const imageCache = new Map<string, CachedImage>();
|
||||||
|
const pendingImages = new Map<string, Promise<CachedImage>>();
|
||||||
|
const requestQueue: Array<(release: () => void) => void> = [];
|
||||||
|
let cacheBytes = 0;
|
||||||
|
let activeRequests = 0;
|
||||||
|
|
||||||
|
function unitValue(value: number, unit: string | null | undefined) {
|
||||||
|
const rendered = number.format(value);
|
||||||
|
return unit === 'Shots' ? `${rendered} ${unit}` : `${rendered}${unit ?? ''}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function _socialCardInput(
|
||||||
|
projection: NonNullable<
|
||||||
|
Awaited<ReturnType<typeof loadSharedBuildProjection>>
|
||||||
|
>,
|
||||||
|
directUrl: string,
|
||||||
|
): BuildSocialCardInput {
|
||||||
|
const attribute = (id: string) =>
|
||||||
|
projection.attributes.find((item) => String(item.id) === id);
|
||||||
|
const statByName = (name: string) =>
|
||||||
|
projection.summary.stats.find(
|
||||||
|
(stat) => String(attribute(stat.id)?.name) === name,
|
||||||
|
);
|
||||||
|
const statValue = (name: string) => {
|
||||||
|
const stat = statByName(name);
|
||||||
|
return stat ? unitValue(Number(stat.value), attribute(stat.id)?.unit) : '0';
|
||||||
|
};
|
||||||
|
const weaponName = (index: number) => {
|
||||||
|
const selected = projection.loadout.weapons[index];
|
||||||
|
return (
|
||||||
|
projection.weapons.find((item) => String(item.id) === selected?.itemId)
|
||||||
|
?.name ?? 'Unassigned'
|
||||||
|
);
|
||||||
|
};
|
||||||
|
const coreNames = new Set(['Firepower', 'Toughness', 'Engineering']);
|
||||||
|
const supportingAttributes: BuildSocialCardFact[] = projection.summary.stats
|
||||||
|
.filter((stat) => !coreNames.has(String(attribute(stat.id)?.name)))
|
||||||
|
.slice(0, 3)
|
||||||
|
.map((stat) => ({
|
||||||
|
label: String(attribute(stat.id)?.name ?? 'Attribute'),
|
||||||
|
value: unitValue(Number(stat.value), attribute(stat.id)?.unit),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: projection.build.title,
|
||||||
|
author: projection.build.author,
|
||||||
|
primaryWeapon: String(weaponName(0)),
|
||||||
|
secondaryWeapon: String(weaponName(1)),
|
||||||
|
firepower: statValue('Firepower'),
|
||||||
|
toughness: statValue('Toughness'),
|
||||||
|
engineering: statValue('Engineering'),
|
||||||
|
supportingAttributes,
|
||||||
|
visibility: projection.build.visibility as 'public' | 'unlisted',
|
||||||
|
directUrl,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchesEtag(request: Request, etag: string) {
|
||||||
|
return request.headers
|
||||||
|
.get('if-none-match')
|
||||||
|
?.split(',')
|
||||||
|
.some((value) => value.trim() === etag);
|
||||||
|
}
|
||||||
|
|
||||||
|
function releaseRequest() {
|
||||||
|
const next = requestQueue.shift();
|
||||||
|
if (next) {
|
||||||
|
next(releaseRequest);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
activeRequests -= 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function acquireRequest(): Promise<(() => void) | null> {
|
||||||
|
if (activeRequests < MAX_ACTIVE_REQUESTS) {
|
||||||
|
activeRequests += 1;
|
||||||
|
return Promise.resolve(releaseRequest);
|
||||||
|
}
|
||||||
|
if (requestQueue.length >= MAX_QUEUED_REQUESTS) return Promise.resolve(null);
|
||||||
|
return new Promise((resolve) => requestQueue.push(resolve));
|
||||||
|
}
|
||||||
|
|
||||||
|
function storeImage(key: string, image: CachedImage) {
|
||||||
|
imageCache.set(key, image);
|
||||||
|
cacheBytes += image.body.byteLength;
|
||||||
|
while (cacheBytes > MAX_CACHE_BYTES && imageCache.size > 1) {
|
||||||
|
const oldestKey = imageCache.keys().next().value;
|
||||||
|
if (!oldestKey) break;
|
||||||
|
const oldest = imageCache.get(oldestKey);
|
||||||
|
imageCache.delete(oldestKey);
|
||||||
|
cacheBytes -= oldest?.body.byteLength ?? 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadImage(input: BuildSocialCardInput, inputKey: string) {
|
||||||
|
const cached = imageCache.get(inputKey);
|
||||||
|
if (cached) {
|
||||||
|
imageCache.delete(inputKey);
|
||||||
|
imageCache.set(inputKey, cached);
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
const pending = pendingImages.get(inputKey);
|
||||||
|
if (pending) return pending;
|
||||||
|
|
||||||
|
const request = renderBuildSocialCardPng(input).then((body) => {
|
||||||
|
const image = {
|
||||||
|
body,
|
||||||
|
etag: `"${createHash('sha256').update(body).digest('base64url')}"`,
|
||||||
|
};
|
||||||
|
storeImage(inputKey, image);
|
||||||
|
return image;
|
||||||
|
});
|
||||||
|
pendingImages.set(inputKey, request);
|
||||||
|
try {
|
||||||
|
return await request;
|
||||||
|
} finally {
|
||||||
|
pendingImages.delete(inputKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GET: RequestHandler = async ({ params, request, url }) => {
|
||||||
|
const release = await acquireRequest();
|
||||||
|
if (!release) error(503, 'Build preview capacity exceeded');
|
||||||
|
try {
|
||||||
|
const projection = await loadSharedBuildProjection(params.slug, {
|
||||||
|
kind: 'public-preview',
|
||||||
|
});
|
||||||
|
if (!projection) error(404, 'Build preview not found');
|
||||||
|
|
||||||
|
const origin = publicOrigin(url.origin);
|
||||||
|
const directUrl = new URL(
|
||||||
|
`/builds/${encodeURIComponent(projection.build.slug)}`,
|
||||||
|
origin,
|
||||||
|
).toString();
|
||||||
|
const input = _socialCardInput(projection, directUrl);
|
||||||
|
const inputKey = createHash('sha256')
|
||||||
|
.update(JSON.stringify({ version: 1, input }))
|
||||||
|
.digest('base64url');
|
||||||
|
const image = await loadImage(input, inputKey);
|
||||||
|
const cacheControl = 'public, max-age=0, must-revalidate';
|
||||||
|
if (matchesEtag(request, image.etag)) {
|
||||||
|
return new Response(null, {
|
||||||
|
status: 304,
|
||||||
|
headers: { 'cache-control': cacheControl, etag: image.etag },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(new Uint8Array(image.body), {
|
||||||
|
headers: {
|
||||||
|
'cache-control': cacheControl,
|
||||||
|
'content-length': String(image.body.byteLength),
|
||||||
|
'content-type': 'image/png',
|
||||||
|
'cross-origin-resource-policy': 'same-origin',
|
||||||
|
etag: image.etag,
|
||||||
|
'x-content-type-options': 'nosniff',
|
||||||
|
...(projection.build.visibility === 'unlisted'
|
||||||
|
? { 'x-robots-tag': 'noindex' }
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
release();
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
const mocks = vi.hoisted(() => ({
|
||||||
|
loadProjection: vi.fn(),
|
||||||
|
render: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('$lib/server/shared-build', () => ({
|
||||||
|
loadSharedBuildProjection: mocks.loadProjection,
|
||||||
|
}));
|
||||||
|
vi.mock('$lib/server/build-social-card', () => ({
|
||||||
|
renderBuildSocialCardPng: mocks.render,
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { GET, _socialCardInput } from './+server';
|
||||||
|
|
||||||
|
const projection = {
|
||||||
|
build: {
|
||||||
|
title: 'Field Medic',
|
||||||
|
slug: 'field-medic-12345678',
|
||||||
|
author: 'Aewyn',
|
||||||
|
visibility: 'unlisted',
|
||||||
|
},
|
||||||
|
loadout: {
|
||||||
|
weapons: [{ itemId: 'weapon-1' }, { itemId: 'weapon-2' }],
|
||||||
|
},
|
||||||
|
weapons: [
|
||||||
|
{ id: 'weapon-1', name: 'ACR' },
|
||||||
|
{ id: 'weapon-2', name: 'Super 90' },
|
||||||
|
],
|
||||||
|
attributes: [
|
||||||
|
{ id: 'fp', name: 'Firepower', unit: '' },
|
||||||
|
{ id: 'tgh', name: 'Toughness', unit: '' },
|
||||||
|
{ id: 'eng', name: 'Engineering', unit: '' },
|
||||||
|
{ id: 'crit', name: 'Critical Hit Chance', unit: '%' },
|
||||||
|
],
|
||||||
|
summary: {
|
||||||
|
stats: [
|
||||||
|
{ id: 'fp', value: 1200 },
|
||||||
|
{ id: 'tgh', value: 950 },
|
||||||
|
{ id: 'eng', value: 610 },
|
||||||
|
{ id: 'crit', value: 18.5 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('Build social preview endpoint', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mocks.loadProjection.mockReset().mockResolvedValue(projection);
|
||||||
|
mocks.render.mockReset().mockResolvedValue(Buffer.from([1, 2, 3]));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('projects canonical Build facts into the card input', () => {
|
||||||
|
expect(
|
||||||
|
_socialCardInput(
|
||||||
|
projection as never,
|
||||||
|
'https://shd.build/builds/field-medic-12345678',
|
||||||
|
),
|
||||||
|
).toEqual({
|
||||||
|
title: 'Field Medic',
|
||||||
|
author: 'Aewyn',
|
||||||
|
primaryWeapon: 'ACR',
|
||||||
|
secondaryWeapon: 'Super 90',
|
||||||
|
firepower: '1,200',
|
||||||
|
toughness: '950',
|
||||||
|
engineering: '610',
|
||||||
|
supportingAttributes: [{ label: 'Critical Hit Chance', value: '18.5%' }],
|
||||||
|
visibility: 'unlisted',
|
||||||
|
directUrl: 'https://shd.build/builds/field-medic-12345678',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('serves a cacheable PNG and honors its ETag', async () => {
|
||||||
|
const first = await GET({
|
||||||
|
params: { slug: 'field-medic-12345678' },
|
||||||
|
request: new Request(
|
||||||
|
'https://shd.build/builds/field-medic-12345678/preview.png',
|
||||||
|
),
|
||||||
|
url: new URL('https://shd.build/builds/field-medic-12345678/preview.png'),
|
||||||
|
} as never);
|
||||||
|
expect(first.status).toBe(200);
|
||||||
|
expect(first.headers.get('content-type')).toBe('image/png');
|
||||||
|
expect(first.headers.get('x-content-type-options')).toBe('nosniff');
|
||||||
|
expect(first.headers.get('cache-control')).toBe(
|
||||||
|
'public, max-age=0, must-revalidate',
|
||||||
|
);
|
||||||
|
expect(first.headers.get('x-robots-tag')).toBe('noindex');
|
||||||
|
expect(mocks.loadProjection).toHaveBeenCalledWith('field-medic-12345678', {
|
||||||
|
kind: 'public-preview',
|
||||||
|
});
|
||||||
|
const etag = first.headers.get('etag');
|
||||||
|
expect(etag).toBeTruthy();
|
||||||
|
|
||||||
|
const second = await GET({
|
||||||
|
params: { slug: 'field-medic-12345678' },
|
||||||
|
request: new Request(
|
||||||
|
'https://shd.build/builds/field-medic-12345678/preview.png',
|
||||||
|
{ headers: { 'if-none-match': String(etag) } },
|
||||||
|
),
|
||||||
|
url: new URL('https://shd.build/builds/field-medic-12345678/preview.png'),
|
||||||
|
} as never);
|
||||||
|
expect(second.status).toBe(304);
|
||||||
|
expect(mocks.render).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns a generic 404 when the public projection is unavailable', async () => {
|
||||||
|
mocks.loadProjection.mockResolvedValue(null);
|
||||||
|
await expect(
|
||||||
|
GET({
|
||||||
|
params: { slug: 'private-build' },
|
||||||
|
request: new Request(
|
||||||
|
'https://shd.build/builds/private-build/preview.png',
|
||||||
|
),
|
||||||
|
url: new URL('https://shd.build/builds/private-build/preview.png'),
|
||||||
|
} as never),
|
||||||
|
).rejects.toMatchObject({ status: 404 });
|
||||||
|
expect(mocks.render).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user