feat(import): bookmarklet for store pages
Build, Test & Push Frontend / quality-check (push) Successful in 22s
Build, Test & Push Frontend / docker (push) Successful in 25s

This commit is contained in:
lobo committed 2026-09-30 15:59:05 +02:00
1 parent c6d76b4cb5
commit f01ad03f6e
8 files changed
+411 -22

No files matched your search

+58 -15
View File
@@ -1,6 +1,7 @@
import {useState} from 'react';
import {useTranslation} from 'react-i18next';
import {Loader2, Search} from 'lucide-react';
import {Copy, Loader2, Search} from 'lucide-react';
import {toast} from 'sonner';
import {Button} from '@/components/ui/button';
import {Card} from '@/components/ui/card';
@@ -10,14 +11,22 @@ import useStartImport from '../../hooks/useStartImport.js';
import {readImportFile} from '../../utils/importFormats.js';
import {parseTextList} from '../../utils/imports.js';
import {MAX_IMPORT_ROWS} from '../../utils/limits.js';
import {bookmarkletHref, consoleScript} from '../../utils/storeList.js';
export default function ImportInput({onCreated}) {
const STORE_SITES = 'PlayStation, Xbox, Nintendo eShop, Epic Games, GOG';
// React 19 blocks javascript: URLs in href. One function, so React sets it only when the link mounts.
const setBookmarklet = (link) => link?.setAttribute('href', bookmarkletHref(location.origin));
/**
* @param storeRows the games the bookmarklet read on a store page, or null
*/
export default function ImportInput({storeRows, onCreated}) {
const {t} = useTranslation();
const [source, setSource] = useState('TEXT');
const [source, setSource] = useState(storeRows ? 'BOOKMARKLET' : 'TEXT');
const [text, setText] = useState('');
const [csv, setCsv] = useState(null);
const [busy, setBusy] = useState(false);
const rows = source === 'TEXT' ? parseTextList(text) : csv?.rows ?? [];
const rows = source === 'TEXT' ? parseTextList(text) : (source === 'CSV' ? csv?.rows : storeRows) ?? [];
const tooMany = rows.length > MAX_IMPORT_ROWS;
const readFile = async (file) => setCsv(file ? readImportFile(await file.text()) : null);
@@ -35,12 +44,14 @@ export default function ImportInput({onCreated}) {
<Card className="gap-4 p-5">
<p className="text-sm text-muted-foreground">{t('IMPORT.HINT')}</p>
<div role="group" aria-label={t('IMPORT.SOURCE')} className="flex gap-2">
{['TEXT', 'CSV'].map((value) => (
{['TEXT', 'CSV', 'BOOKMARKLET'].map((value) => (
<Button key={value} size="sm" variant={source === value ? 'default' : 'outline'} aria-pressed={source === value}
onClick={() => setSource(value)}>{t(`IMPORT.SOURCE_${value}`)}</Button>
))}
</div>
{source === 'TEXT' ? (
{source === 'BOOKMARKLET' ? (
<StoreInput rows={storeRows}/>
) : source === 'TEXT' ? (
<Textarea rows={10} value={text} aria-label={t('IMPORT.TEXT_LABEL')} placeholder={t('IMPORT.TEXT_PLACEHOLDER')}
onChange={(e) => setText(e.target.value)}/>
) : (
@@ -52,15 +63,7 @@ export default function ImportInput({onCreated}) {
{csv?.rows && csv.format !== 'CSV' && (
<p className="text-sm">{t('IMPORT.FORMAT_READ', {format: t(`IMPORT.SOURCE_${csv.format}`)})}</p>
)}
{csv?.rows?.length > 0 && (
<ul className="text-sm text-muted-foreground">
{csv.rows.slice(0, 5).map((row, index) => (
<li key={index} className="truncate">
{[row.title, row.platform, row.status].filter(Boolean).join(' · ')}
</li>
))}
</ul>
)}
<RowPreview rows={csv?.rows}/>
</div>
)}
<div className="flex flex-wrap items-center justify-between gap-3">
@@ -74,3 +77,43 @@ export default function ImportInput({onCreated}) {
</Card>
);
}
const RowPreview = ({rows}) => rows?.length > 0 && (
<ul className="text-sm text-muted-foreground">
{rows.slice(0, 5).map((row, index) => (
<li key={index} className="truncate">{[row.title, row.platform, row.status].filter(Boolean).join(' · ')}</li>
))}
</ul>
);
function StoreInput({rows}) {
const {t} = useTranslation();
const copyScript = async () => {
try {
await navigator.clipboard.writeText(consoleScript(location.origin));
toast.success(t('IMPORT.STORE_COPIED'));
} catch {
toast.error(t('IMPORT.STORE_COPY_FAILED'));
}
};
if (rows) {
return (
<div className="space-y-2">
<p className="text-sm">{t('IMPORT.STORE_READ', {count: rows.length})}</p>
<RowPreview rows={rows}/>
</div>
);
}
return (
<div className="space-y-3 text-sm">
<p>{t('IMPORT.STORE_HINT', {sites: STORE_SITES})}</p>
<div className="flex flex-wrap items-center gap-2">
<a ref={setBookmarklet} onClick={(e) => e.preventDefault()} className="rounded-md border border-dashed px-3 py-1.5 font-medium">{t('IMPORT.STORE_LINK')}</a>
<Button size="sm" variant="outline" onClick={copyScript}><Copy/>{t('IMPORT.STORE_COPY')}</Button>
</div>
<p className="text-xs text-muted-foreground">{t('IMPORT.STORE_NOTE')}</p>
</div>
);
}