Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit 40b32a8

Browse files
committed
update og image
1 parent a72c2ea commit 40b32a8

9 files changed

Lines changed: 491 additions & 76 deletions

File tree

.dex/tasks.jsonl

Lines changed: 4 additions & 0 deletions
Large diffs are not rendered by default.

src/lib/battle_mode/BattleRecapGrid.svelte

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import OverlayCompare from '$lib/battle_mode/OverlayCompare.svelte';
88
import { FRAME_HEIGHT, FRAME_WIDTH } from '$lib/constants';
99
import { parseTargetCode, usesTailwind } from '$utils/code';
10+
import { captureElement } from '$utils/diff';
1011
import sentinel from '../../routes/(style)/(app)/battle/sentinel-dark.css?raw';
1112
1213
type RecapTone = 'win' | 'loss' | 'neutral';
@@ -35,12 +36,14 @@
3536
target = null,
3637
showOutcomeLabel = true,
3738
showDiff = false,
39+
onResultPreviewReady,
3840
children
3941
}: {
4042
participants?: RecapParticipant[];
4143
target?: BattleTarget | null;
4244
showOutcomeLabel?: boolean;
4345
showDiff?: boolean;
46+
onResultPreviewReady?: (pngDataUrl: string) => void;
4447
children?: Snippet;
4548
} = $props();
4649
@@ -76,6 +79,30 @@
7679
// Diff canvas data URLs
7780
let leftDiffCanvasSrc: string | null = $state(null);
7881
let rightDiffCanvasSrc: string | null = $state(null);
82+
let resultPreviewEmitted = $state(false);
83+
let resultPreviewCaptureInFlight = $state(false);
84+
85+
const RESULT_PREVIEW_MAX_ATTEMPTS = 6;
86+
const RESULT_PREVIEW_RETRY_DELAY_MS = 250;
87+
88+
const delay = (ms: number) =>
89+
new Promise<void>((resolve) => setTimeout(resolve, ms));
90+
91+
function looksLikePlaceholderFrame(root: HTMLElement) {
92+
const body = root.querySelector('body');
93+
if (!body) {
94+
return true;
95+
}
96+
97+
const text = (body.textContent ?? '').replace(/\s+/g, ' ').trim();
98+
const normalized = text.toLowerCase();
99+
const hasLetsBattle =
100+
normalized.includes("let's battle!") ||
101+
normalized.includes('lets battle!');
102+
const elementCount = body.querySelectorAll('*').length;
103+
104+
return hasLetsBattle && text.length < 120 && elementCount < 20;
105+
}
79106
80107
function handleLeftDiffCanvasUpdate(canvas: HTMLCanvasElement | null) {
81108
if (canvas) {
@@ -89,10 +116,90 @@
89116
}
90117
}
91118
119+
async function emitResultPreviewOnce() {
120+
if (
121+
resultPreviewEmitted ||
122+
resultPreviewCaptureInFlight ||
123+
!onResultPreviewReady
124+
) {
125+
return;
126+
}
127+
128+
if (!leftIframeElement?.contentDocument) {
129+
return;
130+
}
131+
132+
resultPreviewCaptureInFlight = true;
133+
134+
try {
135+
for (
136+
let attempt = 1;
137+
attempt <= RESULT_PREVIEW_MAX_ATTEMPTS;
138+
attempt += 1
139+
) {
140+
const frameRoot = leftIframeElement?.contentDocument?.documentElement;
141+
if (!frameRoot) {
142+
await delay(RESULT_PREVIEW_RETRY_DELAY_MS);
143+
continue;
144+
}
145+
146+
if (looksLikePlaceholderFrame(frameRoot)) {
147+
await delay(RESULT_PREVIEW_RETRY_DELAY_MS);
148+
continue;
149+
}
150+
151+
try {
152+
const captured = await captureElement(frameRoot);
153+
await captured.decode?.().catch(() => {});
154+
155+
const width = captured.naturalWidth || captured.width;
156+
const height = captured.naturalHeight || captured.height;
157+
if (!width || !height) {
158+
throw new Error('Captured preview had invalid dimensions');
159+
}
160+
161+
const canvas = document.createElement('canvas');
162+
canvas.width = width;
163+
canvas.height = height;
164+
const ctx = canvas.getContext('2d');
165+
if (!ctx) {
166+
throw new Error('Could not create preview canvas context');
167+
}
168+
169+
ctx.drawImage(captured, 0, 0, width, height);
170+
onResultPreviewReady(canvas.toDataURL('image/png'));
171+
resultPreviewEmitted = true;
172+
return;
173+
} catch (error) {
174+
console.error(
175+
`[BattleRecapGrid] Failed to capture result preview (attempt ${attempt}/${RESULT_PREVIEW_MAX_ATTEMPTS}):`,
176+
error
177+
);
178+
}
179+
180+
if (attempt < RESULT_PREVIEW_MAX_ATTEMPTS) {
181+
await delay(RESULT_PREVIEW_RETRY_DELAY_MS);
182+
}
183+
}
184+
185+
console.error(
186+
'[BattleRecapGrid] Result preview capture exhausted all retry attempts'
187+
);
188+
} catch (error) {
189+
console.error(
190+
'[BattleRecapGrid] Failed to capture result preview:',
191+
error
192+
);
193+
} finally {
194+
resultPreviewCaptureInFlight = false;
195+
}
196+
}
197+
92198
function handleLeftLoad() {
93199
if (showDiff) {
94200
leftDiffEngine?.triggerCompare();
95201
}
202+
void emitResultPreviewOnce();
96203
}
97204
98205
function handleRightLoad() {

src/lib/mutators.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -357,6 +357,8 @@ export const mutators = defineMutators({
357357
status?: string | null;
358358
referee_id?: string;
359359
starts_at?: number | null;
360+
total_time_seconds?: number | null;
361+
ends_at?: number | null;
360362
winner_hax_id?: string | null;
361363
} | null;
362364
if (!battle?.id) {
@@ -404,18 +406,30 @@ export const mutators = defineMutators({
404406
}
405407

406408
const now = Date.now();
409+
const durationMs = Math.max(
410+
1,
411+
Math.round(
412+
(battle.total_time_seconds ?? SOLO_BATTLE_DURATION_SECONDS) * 1000
413+
)
414+
);
415+
const scheduledEndsAt =
416+
battle.ends_at ??
417+
(battle.starts_at ? battle.starts_at + durationMs : null);
418+
const effectiveFinishedAt = scheduledEndsAt
419+
? Math.min(now, scheduledEndsAt)
420+
: now;
407421
await tx.mutate.battle_participants.update({
408422
id: participant.id,
409423
status: 'FINISHED',
410-
finished_at: now,
424+
finished_at: effectiveFinishedAt,
411425
updated_at: now
412426
});
413427

414428
await tx.mutate.battles.update({
415429
id: args.id,
416430
status: 'COMPLETED',
417431
winner_hax_id: battle.winner_hax_id ?? battleHax.id,
418-
ends_at: now,
432+
ends_at: effectiveFinishedAt,
419433
updated_at: now
420434
});
421435
}

src/lib/server/solo-share.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,12 @@ export type PublicSoloShareData = {
77
battleName: string;
88
targetName: string;
99
targetImage: string;
10-
renderedPreviewUrl: string | null;
10+
haxId: string | null;
1111
userName: string;
1212
userUsername: string | null;
1313
diffScore: number | null;
1414
diffScoreUpdatedAt: number | null;
15+
haxUpdatedAt: number | null;
1516
completedAt: number | null;
1617
completionMs: number | null;
1718
};
@@ -100,9 +101,10 @@ export async function getPublicSoloShareData(
100101

101102
const [haxRow] = await db
102103
.select({
104+
haxId: hax.id,
103105
diffScore: hax.diff_score,
104106
diffScoreUpdatedAt: hax.diff_score_updated_at,
105-
renderedPreviewUrl: hax.rendered_preview_url
107+
haxUpdatedAt: hax.updated_at
106108
})
107109
.from(hax)
108110
.where(
@@ -124,11 +126,12 @@ export async function getPublicSoloShareData(
124126
battleRow.name ?? `${battleRow.targetName ?? 'Untitled'} Solo Challenge`,
125127
targetName: battleRow.targetName,
126128
targetImage: battleRow.targetImage,
127-
renderedPreviewUrl: haxRow?.renderedPreviewUrl ?? null,
129+
haxId: haxRow?.haxId ?? null,
128130
userName: battleRow.userName,
129131
userUsername: battleRow.userUsername,
130132
diffScore: toScore(haxRow?.diffScore),
131133
diffScoreUpdatedAt: toTimestamp(haxRow?.diffScoreUpdatedAt),
134+
haxUpdatedAt: toTimestamp(haxRow?.haxUpdatedAt),
132135
completedAt: endsAt,
133136
completionMs
134137
};

src/routes/(style)/(menu)/recap/[id]/+page.svelte

Lines changed: 86 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -98,13 +98,26 @@
9898
);
9999
100100
let copiedShareLink = $state(false);
101+
let soloPreviewUploadStarted = $state(false);
102+
let soloPreviewUploadInFlight = $state(false);
101103
102104
const isParticipant = $derived(
103105
battle.data?.participants?.some(
104106
(participant) => participant.user_id === z.userID
105107
) ?? false
106108
);
107109
110+
const ownSoloParticipant = $derived.by(() => {
111+
if (!isSoloBattle) return null;
112+
return (
113+
battle.data?.participants?.find(
114+
(participant) => participant.user_id === z.userID && participant.hax?.id
115+
) ?? null
116+
);
117+
});
118+
const ownSoloHaxId = $derived(ownSoloParticipant?.hax?.id ?? '');
119+
const ownsSoloHax = $derived(Boolean(isSoloBattle && ownSoloParticipant));
120+
108121
const battlers = $derived.by(() => {
109122
const participants = battle.data?.participants ?? [];
110123
const ordered = [...participants]
@@ -172,9 +185,18 @@
172185
if (!participant) return null;
173186
const score = participant.hax?.diff_score ?? 0;
174187
const completionMs =
175-
participant.finished_at && leaderboardBattle.starts_at
176-
? Math.max(0, participant.finished_at - leaderboardBattle.starts_at)
177-
: null;
188+
leaderboardBattle.starts_at !== null &&
189+
leaderboardBattle.starts_at !== undefined &&
190+
leaderboardBattle.ends_at !== null &&
191+
leaderboardBattle.ends_at !== undefined
192+
? Math.max(
193+
0,
194+
leaderboardBattle.ends_at - leaderboardBattle.starts_at
195+
)
196+
: leaderboardBattle.total_time_seconds !== null &&
197+
leaderboardBattle.total_time_seconds !== undefined
198+
? leaderboardBattle.total_time_seconds * 1000
199+
: null;
178200
return {
179201
battleId: leaderboardBattle.id,
180202
userId: participant.user_id,
@@ -247,6 +269,66 @@
247269
console.error('Failed to copy URL:', error);
248270
}
249271
}
272+
273+
async function uploadSoloPreview(pngDataUrl: string) {
274+
if (
275+
!isSoloBattle ||
276+
!ownsSoloHax ||
277+
!ownSoloHaxId ||
278+
soloPreviewUploadStarted ||
279+
soloPreviewUploadInFlight
280+
) {
281+
return;
282+
}
283+
284+
soloPreviewUploadStarted = true;
285+
soloPreviewUploadInFlight = true;
286+
287+
try {
288+
const imageBlob = await fetch(pngDataUrl).then((response) =>
289+
response.blob()
290+
);
291+
const file = new File([imageBlob], `solo-preview-${ownSoloHaxId}.png`, {
292+
type: 'image/png'
293+
});
294+
const formData = new FormData();
295+
formData.append('file', file);
296+
formData.append('hax_id', ownSoloHaxId);
297+
298+
const uploadRes = await fetch('/api/uploads/solo-preview/upload', {
299+
method: 'POST',
300+
body: formData
301+
});
302+
303+
if (!uploadRes.ok) {
304+
const error = await uploadRes
305+
.json()
306+
.catch(() => ({ error: 'Failed to upload preview image' }));
307+
throw new Error(error.error ?? 'Failed to upload preview image');
308+
}
309+
310+
const payload = (await uploadRes.json()) as {
311+
success?: boolean;
312+
};
313+
if (!payload.success) {
314+
throw new Error('Upload response missing success state');
315+
}
316+
317+
const mutation = z.mutate(
318+
mutators.hax.update({
319+
id: ownSoloHaxId,
320+
user_id: z.userID,
321+
updated_at: Date.now()
322+
})
323+
);
324+
await mutation.server;
325+
} catch (error) {
326+
console.error('Failed to upload solo preview image:', error);
327+
soloPreviewUploadStarted = false;
328+
} finally {
329+
soloPreviewUploadInFlight = false;
330+
}
331+
}
250332
</script>
251333

252334
<svelte:head>
@@ -289,6 +371,7 @@
289371
})}
290372
target={battleData.target}
291373
showOutcomeLabel={!isSoloBattle}
374+
onResultPreviewReady={uploadSoloPreview}
292375
>
293376
{#if isSoloBattle && (canShowSoloShareControls || battleData.visibility !== 'PUBLIC')}
294377
<section class="stack leaderboard" style="--gap: 0.75rem;">

0 commit comments

Comments
 (0)