mirror of
https://github.com/jcreek/LivingDexTracker.git
synced 2026-09-18 19:42:04 +00:00
feat(pokedex): add shareable read-only dex links
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import type { CatchRecord } from '$lib/models/CatchRecord';
|
||||
import type { SharedCatchStatus } from '$lib/models/SharedPokedex';
|
||||
import type { CatchInformationItem, PokedexEntry } from '$lib/models/PokedexEntry';
|
||||
import PokemonSprite from '../PokemonSprite.svelte';
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
@@ -11,9 +12,11 @@
|
||||
export let showShiny: boolean;
|
||||
export let userId: string | null = null;
|
||||
export let pokedexId: string;
|
||||
export let readOnly = false;
|
||||
export let sharedCatchStatus: SharedCatchStatus | null = null;
|
||||
|
||||
// Create a default catch record if none exists
|
||||
$: if (!catchRecord) {
|
||||
$: if (!readOnly && !catchRecord) {
|
||||
catchRecord = {
|
||||
_id: '', // Empty string, not temp ID - will be created by server
|
||||
userId: userId || '',
|
||||
@@ -36,10 +39,12 @@
|
||||
): value is CatchInformationItem => typeof value !== 'string';
|
||||
|
||||
function updateCatchRecord(source: UpdateCatchSource) {
|
||||
if (readOnly) return;
|
||||
dispatch('updateCatch', { pokedexEntry, catchRecord, source });
|
||||
}
|
||||
|
||||
function onCaughtChange() {
|
||||
if (readOnly) return;
|
||||
if (!catchRecord) return;
|
||||
// Mutually exclusive with "needs to evolve"
|
||||
if (catchRecord.caught) {
|
||||
@@ -49,6 +54,7 @@
|
||||
}
|
||||
|
||||
function onNeedsToEvolveChange() {
|
||||
if (readOnly) return;
|
||||
if (!catchRecord) return;
|
||||
// Mutually exclusive with "caught"
|
||||
if (catchRecord.haveToEvolve) {
|
||||
@@ -95,78 +101,94 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if catchRecord}
|
||||
{#if readOnly || catchRecord}
|
||||
<div
|
||||
class="dex-column catch-record-container bg-base-100 text-base-content rounded-lg p-4 mb-4 md:mb-0"
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<div class="form-control">
|
||||
<label class="cursor-pointer label">
|
||||
<span class="block font-bold mr-2">Caught:</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={catchRecord.caught}
|
||||
class="checkbox checkbox-primary"
|
||||
on:change={onCaughtChange}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<div class="form-control">
|
||||
<label class="cursor-pointer label">
|
||||
<span class="block font-bold mr-2">Needs to evolve:</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={catchRecord.haveToEvolve}
|
||||
class="checkbox checkbox-primary"
|
||||
on:change={onNeedsToEvolveChange}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<div class="form-control">
|
||||
<label class="cursor-pointer label">
|
||||
<span class="block font-bold mr-2">In Home:</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={catchRecord.inHome}
|
||||
class="checkbox checkbox-primary"
|
||||
on:change={() => updateCatchRecord('toggle')}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
{#if pokedexEntry.canGigantamax && showForms}
|
||||
{#if readOnly}
|
||||
<h3 class="text-lg font-semibold mb-2">Progress</h3>
|
||||
<dl class="grid grid-cols-2 gap-x-4 gap-y-2">
|
||||
<dt>Caught</dt>
|
||||
<dd class="font-semibold">{sharedCatchStatus?.caught ? 'Yes' : 'No'}</dd>
|
||||
<dt>Needs to evolve</dt>
|
||||
<dd class="font-semibold">{sharedCatchStatus?.haveToEvolve ? 'Yes' : 'No'}</dd>
|
||||
<dt>In HOME</dt>
|
||||
<dd class="font-semibold">{sharedCatchStatus?.inHome ? 'Yes' : 'No'}</dd>
|
||||
{#if pokedexEntry.canGigantamax && showForms}
|
||||
<dt>Has Gigantamaxed</dt>
|
||||
<dd class="font-semibold">{sharedCatchStatus?.hasGigantamaxed ? 'Yes' : 'No'}</dd>
|
||||
{/if}
|
||||
</dl>
|
||||
{:else if catchRecord}
|
||||
<div class="flex items-center">
|
||||
<div class="form-control">
|
||||
<label class="cursor-pointer label">
|
||||
<span class="block font-bold mr-2">Has Gigantamaxed:</span>
|
||||
<span class="block font-bold mr-2">Caught:</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={catchRecord.hasGigantamaxed}
|
||||
bind:checked={catchRecord.caught}
|
||||
class="checkbox checkbox-primary"
|
||||
on:change={onCaughtChange}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<div class="form-control">
|
||||
<label class="cursor-pointer label">
|
||||
<span class="block font-bold mr-2">Needs to evolve:</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={catchRecord.haveToEvolve}
|
||||
class="checkbox checkbox-primary"
|
||||
on:change={onNeedsToEvolveChange}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<div class="form-control">
|
||||
<label class="cursor-pointer label">
|
||||
<span class="block font-bold mr-2">In Home:</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={catchRecord.inHome}
|
||||
class="checkbox checkbox-primary"
|
||||
on:change={() => updateCatchRecord('toggle')}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
{#if pokedexEntry.canGigantamax && showForms}
|
||||
<div class="flex items-center">
|
||||
<div class="form-control">
|
||||
<label class="cursor-pointer label">
|
||||
<span class="block font-bold mr-2">Has Gigantamaxed:</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={catchRecord.hasGigantamaxed}
|
||||
class="checkbox checkbox-primary"
|
||||
on:change={() => updateCatchRecord('toggle')}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<p>
|
||||
<label
|
||||
class="block font-bold mb-1"
|
||||
for={`personalNotesInput-${catchRecord._id || pokedexEntry._id}`}>Notes:</label
|
||||
>
|
||||
<textarea
|
||||
bind:value={catchRecord.personalNotes}
|
||||
id={`personalNotesInput-${catchRecord._id || pokedexEntry._id}`}
|
||||
class="textarea textarea-bordered w-full"
|
||||
style="min-height: 120px;"
|
||||
on:input={() => updateCatchRecord('notes')}
|
||||
on:change={() => updateCatchRecord('notes-blur')}
|
||||
></textarea>
|
||||
</p>
|
||||
{/if}
|
||||
<p>
|
||||
<label
|
||||
class="block font-bold mb-1"
|
||||
for={`personalNotesInput-${catchRecord._id || pokedexEntry._id}`}>Notes:</label
|
||||
>
|
||||
<textarea
|
||||
bind:value={catchRecord.personalNotes}
|
||||
id={`personalNotesInput-${catchRecord._id || pokedexEntry._id}`}
|
||||
class="textarea textarea-bordered w-full"
|
||||
style="min-height: 120px;"
|
||||
on:input={() => updateCatchRecord('notes')}
|
||||
on:change={() => updateCatchRecord('notes-blur')}
|
||||
></textarea>
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
|
||||
@@ -2,12 +2,17 @@
|
||||
import { onMount } from 'svelte';
|
||||
import type { CatchRecord } from '$lib/models/CatchRecord';
|
||||
import type { CombinedData } from '$lib/models/CombinedData';
|
||||
import type { SharedCatchStatus, SharedCombinedData } from '$lib/models/SharedPokedex';
|
||||
import { calculateBoxPlacement } from '$lib/utils/boxPlacement';
|
||||
import PokemonSprite from '$lib/components/PokemonSprite.svelte';
|
||||
import Tooltip from '$lib/components/Tooltip.svelte';
|
||||
|
||||
export let showShiny = false;
|
||||
export let combinedData: CombinedData[] | null;
|
||||
type DisplayData = CombinedData | SharedCombinedData;
|
||||
type DisplayStatus = CatchRecord | SharedCatchStatus | null;
|
||||
|
||||
export let combinedData: DisplayData[] | null;
|
||||
export let readOnly = false;
|
||||
export let boxNumbers: number[] = [];
|
||||
export let creatingRecords = false;
|
||||
export let totalRecordsCreated = 0;
|
||||
@@ -18,7 +23,7 @@
|
||||
export let markBoxAsInHome: (boxNumber: number) => void = () => {};
|
||||
export let markBoxAsNotInHome: (boxNumber: number) => void = () => {};
|
||||
export let createCatchRecords = () => {};
|
||||
export let onPokemonClick: (pokemon: CombinedData) => void = () => {};
|
||||
export let onPokemonClick: (pokemon: DisplayData) => void = () => {};
|
||||
|
||||
let filterNotCaught = false;
|
||||
let filterNeedsToEvolve = false;
|
||||
@@ -36,7 +41,7 @@
|
||||
return () => window.removeEventListener('click', close);
|
||||
});
|
||||
|
||||
let filteredCombinedData: CombinedData[] = [];
|
||||
let filteredCombinedData: DisplayData[] = [];
|
||||
let filteredTotal = 0;
|
||||
let overallTotal = 0;
|
||||
let overallCaughtCount = 0;
|
||||
@@ -47,7 +52,7 @@
|
||||
let filtersActive = false;
|
||||
let filtersKey = '';
|
||||
|
||||
function normalizedStatus(catchRecord: CatchRecord | null) {
|
||||
function normalizedStatus(catchRecord: DisplayStatus) {
|
||||
return {
|
||||
caught: !!catchRecord?.caught,
|
||||
needsToEvolve: !!catchRecord?.haveToEvolve,
|
||||
@@ -55,7 +60,7 @@
|
||||
};
|
||||
}
|
||||
|
||||
function matchesFilters(catchRecord: CatchRecord | null) {
|
||||
function matchesFilters(catchRecord: DisplayStatus) {
|
||||
const status = normalizedStatus(catchRecord);
|
||||
if (!filtersActive) return true;
|
||||
|
||||
@@ -148,7 +153,7 @@
|
||||
boxViewLayout === 'comfortable' ? 1 : boxViewLayout === 'compact' ? 0.6 : 0.45;
|
||||
$: spriteSizePx = boxViewLayout === 'comfortable' ? 64 : boxViewLayout === 'compact' ? 52 : 44;
|
||||
|
||||
function cellStatusClasses(catchRecord: CatchRecord | null) {
|
||||
function cellStatusClasses(catchRecord: DisplayStatus) {
|
||||
// Keep borders/layout unchanged; rely on clearer fills + badges instead.
|
||||
if (catchRecord?.caught) {
|
||||
// Match legend (green-600) while keeping sprites readable.
|
||||
@@ -161,7 +166,7 @@
|
||||
return '';
|
||||
}
|
||||
|
||||
function statusLabel(catchRecord: CatchRecord | null) {
|
||||
function statusLabel(catchRecord: DisplayStatus) {
|
||||
const parts: string[] = [];
|
||||
if (catchRecord?.caught) parts.push('Caught');
|
||||
if (catchRecord?.haveToEvolve) parts.push('Needs to evolve');
|
||||
@@ -169,7 +174,7 @@
|
||||
return parts.length ? parts.join(', ') : 'Not caught';
|
||||
}
|
||||
|
||||
function cellBackgroundColourStyle(index: number, catchRecord: CatchRecord | null) {
|
||||
function cellBackgroundColourStyle(index: number, catchRecord: DisplayStatus) {
|
||||
if (catchRecord?.caught || catchRecord?.haveToEvolve) {
|
||||
return '';
|
||||
} else {
|
||||
@@ -353,88 +358,88 @@
|
||||
<div class="mb-8">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3 mb-4 relative z-20">
|
||||
<h2 class="text-xl font-bold">Box {boxNumber}</h2>
|
||||
<div class="relative">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-outline relative z-[210]"
|
||||
aria-label="Open bulk actions menu"
|
||||
aria-haspopup="menu"
|
||||
aria-controls={bulkMenuId}
|
||||
aria-expanded={openBulkMenuForBox === boxNumber}
|
||||
on:click={(event) => {
|
||||
event.stopPropagation();
|
||||
openBulkMenuForBox = openBulkMenuForBox === boxNumber ? null : boxNumber;
|
||||
}}
|
||||
on:keydown={(event) => {
|
||||
if (event.key === 'Escape') openBulkMenuForBox = null;
|
||||
}}
|
||||
>
|
||||
⋯
|
||||
</button>
|
||||
|
||||
{#if openBulkMenuForBox === boxNumber}
|
||||
<ul
|
||||
id={bulkMenuId}
|
||||
class="menu bg-base-100 rounded-box absolute right-0 mt-2 z-[220] w-56 p-2 shadow border border-base-300"
|
||||
{#if !readOnly}<div class="relative">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-outline relative z-[210]"
|
||||
aria-label="Open bulk actions menu"
|
||||
aria-haspopup="menu"
|
||||
aria-controls={bulkMenuId}
|
||||
aria-expanded={openBulkMenuForBox === boxNumber}
|
||||
on:click={(event) => {
|
||||
event.stopPropagation();
|
||||
openBulkMenuForBox = openBulkMenuForBox === boxNumber ? null : boxNumber;
|
||||
}}
|
||||
on:keydown={(event) => {
|
||||
if (event.key === 'Escape') openBulkMenuForBox = null;
|
||||
}}
|
||||
>
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
on:click|stopPropagation={() => {
|
||||
markBoxAsNotCaught(boxNumber);
|
||||
openBulkMenuForBox = null;
|
||||
}}
|
||||
>
|
||||
Mark box as Not caught
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
on:click|stopPropagation={() => {
|
||||
markBoxAsCaught(boxNumber);
|
||||
openBulkMenuForBox = null;
|
||||
}}
|
||||
>
|
||||
Mark box as Caught
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
on:click|stopPropagation={() => {
|
||||
markBoxAsNeedsToEvolve(boxNumber);
|
||||
openBulkMenuForBox = null;
|
||||
}}
|
||||
>
|
||||
Mark box as Needs to evolve
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
on:click|stopPropagation={() => {
|
||||
markBoxAsInHome(boxNumber);
|
||||
openBulkMenuForBox = null;
|
||||
}}
|
||||
>
|
||||
Mark box as In HOME
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
on:click|stopPropagation={() => {
|
||||
markBoxAsNotInHome(boxNumber);
|
||||
openBulkMenuForBox = null;
|
||||
}}
|
||||
>
|
||||
Mark box as Not in HOME
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
⋯
|
||||
</button>
|
||||
|
||||
{#if openBulkMenuForBox === boxNumber}
|
||||
<ul
|
||||
id={bulkMenuId}
|
||||
class="menu bg-base-100 rounded-box absolute right-0 mt-2 z-[220] w-56 p-2 shadow border border-base-300"
|
||||
>
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
on:click|stopPropagation={() => {
|
||||
markBoxAsNotCaught(boxNumber);
|
||||
openBulkMenuForBox = null;
|
||||
}}
|
||||
>
|
||||
Mark box as Not caught
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
on:click|stopPropagation={() => {
|
||||
markBoxAsCaught(boxNumber);
|
||||
openBulkMenuForBox = null;
|
||||
}}
|
||||
>
|
||||
Mark box as Caught
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
on:click|stopPropagation={() => {
|
||||
markBoxAsNeedsToEvolve(boxNumber);
|
||||
openBulkMenuForBox = null;
|
||||
}}
|
||||
>
|
||||
Mark box as Needs to evolve
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
on:click|stopPropagation={() => {
|
||||
markBoxAsInHome(boxNumber);
|
||||
openBulkMenuForBox = null;
|
||||
}}
|
||||
>
|
||||
Mark box as In HOME
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
on:click|stopPropagation={() => {
|
||||
markBoxAsNotInHome(boxNumber);
|
||||
openBulkMenuForBox = null;
|
||||
}}
|
||||
>
|
||||
Mark box as Not in HOME
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
{/if}
|
||||
</div>{/if}
|
||||
</div>
|
||||
<div class="grid grid-cols-6">
|
||||
{#each BOX_POSITIONS as positionInBox}
|
||||
@@ -583,7 +588,9 @@
|
||||
If you're seeing this, you probably haven't created your Pokédex data yet. Please do so by
|
||||
clicking this button.
|
||||
</p>
|
||||
<button class="btn" on:click={createCatchRecords}>Create Pokédex data</button>
|
||||
{#if !readOnly}
|
||||
<button class="btn" on:click={createCatchRecords}>Create Pokédex data</button>
|
||||
{/if}
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="min-w-max mx-auto">
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export interface Pokedex {
|
||||
_id: string;
|
||||
shareToken: string;
|
||||
userId: string;
|
||||
name: string;
|
||||
description: string;
|
||||
@@ -13,6 +14,7 @@ export interface Pokedex {
|
||||
|
||||
export interface PokedexDB {
|
||||
id: string;
|
||||
shareToken: string;
|
||||
userId: string;
|
||||
name: string;
|
||||
description: string;
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { PokedexEntry } from './PokedexEntry';
|
||||
|
||||
export interface SharedCatchStatus {
|
||||
pokemonId: string;
|
||||
caught: boolean;
|
||||
haveToEvolve: boolean;
|
||||
inHome: boolean;
|
||||
hasGigantamaxed: boolean;
|
||||
}
|
||||
|
||||
export interface SharedPokedexMetadata {
|
||||
name: string;
|
||||
description: string;
|
||||
isLivingDex: boolean;
|
||||
isShinyDex: boolean;
|
||||
isOriginDex: boolean;
|
||||
isFormDex: boolean;
|
||||
gameScope: string | null;
|
||||
dexScopes: string[];
|
||||
}
|
||||
|
||||
export interface SharedPokedexRpcData extends SharedPokedexMetadata {
|
||||
catchStatuses: SharedCatchStatus[];
|
||||
}
|
||||
|
||||
export interface SharedCombinedData {
|
||||
pokedexEntry: PokedexEntry;
|
||||
catchRecord: SharedCatchStatus | null;
|
||||
}
|
||||
|
||||
export interface SharedPokedexData extends SharedPokedexMetadata {
|
||||
combinedData: SharedCombinedData[];
|
||||
total: number;
|
||||
caught: number;
|
||||
completionPercentage: number;
|
||||
}
|
||||
@@ -10,6 +10,7 @@ class PokedexRepository {
|
||||
private transform(db: PokedexDB, dexScopes: string[] = []): Pokedex {
|
||||
return {
|
||||
_id: db.id,
|
||||
shareToken: db.shareToken,
|
||||
userId: db.userId,
|
||||
name: db.name,
|
||||
description: db.description || '',
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import sharp from 'sharp';
|
||||
import type { SharedPokedexData } from '$lib/models/SharedPokedex';
|
||||
|
||||
export const SHARE_PREVIEW_WIDTH = 1200;
|
||||
export const SHARE_PREVIEW_HEIGHT = 630;
|
||||
|
||||
export function escapeXml(value: string): string {
|
||||
return value
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
}
|
||||
|
||||
export function truncatePreviewText(value: string, maximumLength: number): string {
|
||||
const normalized = value.replace(/\s+/g, ' ').trim();
|
||||
if (normalized.length <= maximumLength) return normalized;
|
||||
return `${normalized.slice(0, Math.max(0, maximumLength - 1)).trimEnd()}…`;
|
||||
}
|
||||
|
||||
function previewBadges(shared: SharedPokedexData): string[] {
|
||||
return [
|
||||
shared.isLivingDex && 'Living',
|
||||
shared.isShinyDex && 'Shiny',
|
||||
shared.isOriginDex && 'Origin',
|
||||
shared.isFormDex && 'Form',
|
||||
shared.gameScope || 'All Games'
|
||||
].filter((value): value is string => Boolean(value));
|
||||
}
|
||||
|
||||
export function buildSharePreviewSvg(shared: SharedPokedexData): string {
|
||||
const name = escapeXml(truncatePreviewText(shared.name, 48));
|
||||
const description = escapeXml(truncatePreviewText(shared.description, 92));
|
||||
const badges = previewBadges(shared).slice(0, 5);
|
||||
const badgeMarkup = badges
|
||||
.map((badge, index) => {
|
||||
const label = escapeXml(truncatePreviewText(badge, 22));
|
||||
const width = Math.max(112, Math.min(220, 44 + badge.length * 15));
|
||||
const previousWidth = badges
|
||||
.slice(0, index)
|
||||
.reduce((sum, value) => sum + Math.max(112, Math.min(220, 44 + value.length * 15)) + 16, 0);
|
||||
return `<g transform="translate(${76 + previousWidth} 270)">
|
||||
<rect width="${width}" height="52" rx="26" fill="#fee2e2" />
|
||||
<text x="${width / 2}" y="34" text-anchor="middle" class="badge">${label}</text>
|
||||
</g>`;
|
||||
})
|
||||
.join('');
|
||||
const progressWidth = Math.round(
|
||||
(870 * Math.min(100, Math.max(0, shared.completionPercentage))) / 100
|
||||
);
|
||||
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" width="${SHARE_PREVIEW_WIDTH}" height="${SHARE_PREVIEW_HEIGHT}" viewBox="0 0 ${SHARE_PREVIEW_WIDTH} ${SHARE_PREVIEW_HEIGHT}">
|
||||
<defs>
|
||||
<linearGradient id="background" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0" stop-color="#7f1d1d" />
|
||||
<stop offset="1" stop-color="#dc2626" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<style>
|
||||
.title { font: 700 64px system-ui, -apple-system, sans-serif; fill: #fff; }
|
||||
.description { font: 400 27px system-ui, -apple-system, sans-serif; fill: #fecaca; }
|
||||
.badge { font: 650 22px system-ui, -apple-system, sans-serif; fill: #991b1b; }
|
||||
.progress { font: 750 52px system-ui, -apple-system, sans-serif; fill: #fff; }
|
||||
.percent { font: 800 82px system-ui, -apple-system, sans-serif; fill: #fff; }
|
||||
.brand { font: 650 24px system-ui, -apple-system, sans-serif; fill: #fecaca; letter-spacing: 1px; }
|
||||
</style>
|
||||
<rect width="1200" height="630" fill="url(#background)" />
|
||||
<circle cx="1070" cy="90" r="190" fill="#fff" opacity=".08" />
|
||||
<circle cx="1070" cy="90" r="62" fill="none" stroke="#fff" stroke-width="26" opacity=".16" />
|
||||
<path d="M880 90h380" stroke="#fff" stroke-width="26" opacity=".16" />
|
||||
<text x="76" y="118" class="brand">LIVING DEX TRACKER</text>
|
||||
<text x="76" y="205" class="title">${name}</text>
|
||||
${description ? `<text x="76" y="246" class="description">${description}</text>` : ''}
|
||||
${badgeMarkup}
|
||||
<text x="76" y="425" class="progress">${shared.caught} of ${shared.total} Pokémon caught</text>
|
||||
<text x="1090" y="425" text-anchor="end" class="percent">${shared.completionPercentage}%</text>
|
||||
<rect x="76" y="472" width="870" height="28" rx="14" fill="#450a0a" opacity=".65" />
|
||||
<rect x="76" y="472" width="${progressWidth}" height="28" rx="14" fill="#fff" />
|
||||
<text x="76" y="574" class="brand">pokedex.jcreek.co.uk</text>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
export async function renderSharePreview(shared: SharedPokedexData): Promise<Buffer> {
|
||||
return sharp(Buffer.from(buildSharePreviewSvg(shared)))
|
||||
.png()
|
||||
.toBuffer();
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import type { SupabaseClient } from '@supabase/supabase-js';
|
||||
import CombinedDataRepository from '$lib/repositories/CombinedDataRepository';
|
||||
import type {
|
||||
SharedCatchStatus,
|
||||
SharedPokedexData,
|
||||
SharedPokedexRpcData
|
||||
} from '$lib/models/SharedPokedex';
|
||||
|
||||
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||
|
||||
export function isShareToken(value: string): boolean {
|
||||
return UUID_PATTERN.test(value);
|
||||
}
|
||||
|
||||
export function calculateSharedProgress(statuses: SharedCatchStatus[], total: number) {
|
||||
const caught = statuses.reduce(
|
||||
(sum, status) => sum + (status.caught || status.haveToEvolve ? 1 : 0),
|
||||
0
|
||||
);
|
||||
return {
|
||||
caught,
|
||||
completionPercentage: total === 0 ? 0 : Math.round((caught / total) * 100)
|
||||
};
|
||||
}
|
||||
|
||||
export async function loadSharedPokedex(
|
||||
supabase: SupabaseClient,
|
||||
shareToken: string
|
||||
): Promise<SharedPokedexData | null> {
|
||||
if (!isShareToken(shareToken)) return null;
|
||||
|
||||
const { data, error } = await supabase.rpc('get_shared_pokedex', {
|
||||
p_share_token: shareToken
|
||||
});
|
||||
if (error || !data) return null;
|
||||
|
||||
const shared = data as SharedPokedexRpcData;
|
||||
const repo = new CombinedDataRepository(supabase, null, null);
|
||||
const entries = await repo.findAllCombinedData(
|
||||
'',
|
||||
shared.isFormDex,
|
||||
'',
|
||||
shared.gameScope || '',
|
||||
shared.dexScopes
|
||||
);
|
||||
const statuses = new Map(shared.catchStatuses.map((status) => [status.pokemonId, status]));
|
||||
const combinedData = entries.map(({ pokedexEntry }) => ({
|
||||
pokedexEntry,
|
||||
catchRecord: statuses.get(pokedexEntry._id) ?? null
|
||||
}));
|
||||
const visibleStatuses = combinedData.flatMap(({ catchRecord }) =>
|
||||
catchRecord ? [catchRecord] : []
|
||||
);
|
||||
const progress = calculateSharedProgress(visibleStatuses, combinedData.length);
|
||||
|
||||
return {
|
||||
name: shared.name,
|
||||
description: shared.description,
|
||||
isLivingDex: shared.isLivingDex,
|
||||
isShinyDex: shared.isShinyDex,
|
||||
isOriginDex: shared.isOriginDex,
|
||||
isFormDex: shared.isFormDex,
|
||||
gameScope: shared.gameScope,
|
||||
dexScopes: shared.dexScopes,
|
||||
combinedData,
|
||||
total: combinedData.length,
|
||||
...progress
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user