Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 9 additions & 8 deletions cmd/opensave-app/frontend/src/components/AboutModal.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import { navigate } from '../lib/stores.js';
import { backdropClose } from '../lib/backdrop.js';
import { DISCORD_URL, GITHUB_URL } from '../lib/links.js';
import { t } from '../lib/i18n.js';
import logoUrl from '../assets/logo.png';
import X from 'lucide-svelte/icons/x';
import ExternalLink from 'lucide-svelte/icons/external-link';
Expand Down Expand Up @@ -39,33 +40,33 @@
<svelte:window on:keydown={onKeydown} />

<div class="backdrop" use:backdropClose={onClose} role="presentation" out:dialogOut|global>
<div class="modal" role="dialog" aria-modal="true" aria-label="About OpenSave">
<button class="btn ghost icon x" on:click={onClose} title="Close" aria-label="Close"><X size={18} /></button>
<div class="modal" role="dialog" aria-modal="true" aria-label={$t('about.title')}>
<button class="btn ghost icon x" on:click={onClose} title={$t('common.close')} aria-label={$t('common.close')}><X size={18} /></button>
<img class="logo" src={logoUrl} alt="" />
<h2>{info?.name ?? 'OpenSave'}</h2>
<div class="ver">
Version {info?.version ?? '—'}{#if buildLabel}<span class="build"> · built {buildLabel}</span>{/if}
{$t('about.version', { version: info?.version ?? '—' })}{#if buildLabel}<span class="build">{$t('about.builtAt', { time: buildLabel })}</span>{/if}
</div>
<p class="tagline">{info?.tagline ?? ''}</p>

<div class="meta">
<div><span>License</span> {info?.license ?? 'MIT'}</div>
<div><span>Built with</span> {info?.tech ?? 'Go + Wails'}</div>
<div><span>{$t('about.license')}</span> {info?.license ?? 'MIT'}</div>
<div><span>{$t('about.builtWith')}</span> {info?.tech ?? 'Go + Wails'}</div>
</div>

<div class="links">
<button class="btn small discord" on:click={() => native.openExternal(DISCORD_URL)}>
<svg viewBox="0 0 24 18" width="16" height="12" fill="currentColor" aria-hidden="true">
<path d="M20.32 1.53A19.8 19.8 0 0 0 15.43 0c-.21.38-.46.9-.63 1.31a18.3 18.3 0 0 0-5.6 0C9.03.9 8.77.38 8.56 0A19.74 19.74 0 0 0 3.67 1.53C.57 6.19-.27 10.73.15 15.21A19.9 19.9 0 0 0 6.18 18c.49-.66.92-1.37 1.29-2.11-.71-.27-1.39-.6-2.03-.98.17-.13.34-.26.5-.4a14.2 14.2 0 0 0 12.12 0c.16.14.33.27.5.4-.64.38-1.32.71-2.03.98.37.74.8 1.45 1.29 2.11a19.87 19.87 0 0 0 6.03-2.79c.5-5.19-.84-9.69-3.53-13.68ZM8.02 12.46c-1.18 0-2.15-1.08-2.15-2.4s.95-2.4 2.15-2.4c1.2 0 2.17 1.09 2.15 2.4 0 1.32-.95 2.4-2.15 2.4Zm7.96 0c-1.18 0-2.15-1.08-2.15-2.4s.95-2.4 2.15-2.4c1.2 0 2.17 1.09 2.15 2.4 0 1.32-.95 2.4-2.15 2.4Z" />
</svg>
Join the Discord
{$t('about.joinDiscord')}
</button>
<button class="btn small" on:click={() => native.openExternal(GITHUB_URL)}>GitHub<ExternalLink size={13} /></button>
<button class="btn small" on:click={openChangelog}><ScrollText size={14} />Changelog</button>
<button class="btn small" on:click={openChangelog}><ScrollText size={14} />{$t('about.changelog')}</button>
</div>

<p class="copy">{info?.copyright ?? ''}</p>
<p class="note">Wire-compatible with the original Node.js/Electron OpenSave — Go and JS devices sync together.</p>
<p class="note">{$t('about.note')}</p>
</div>
</div>

Expand Down
21 changes: 10 additions & 11 deletions cmd/opensave-app/frontend/src/components/CloudOfferBanner.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -14,17 +14,18 @@
const fly = gated(flyIn);
import { cloudOffers, toast } from '../lib/stores.js';
import { api } from '../lib/api.js';
import { t, segments } from '../lib/i18n.js';
import { timeAgo } from '../lib/timeago.js';
import CloudDownload from 'lucide-svelte/icons/cloud-download';

let busy = '';

const where = {
$: where = {
google_drive: 'Google Drive',
onedrive: 'OneDrive',
dropbox: 'Dropbox',
webdav: 'your WebDAV server',
local: 'your backup folder'
webdav: $t('cloudOffer.where.webdav'),
local: $t('cloudOffer.where.local')
};

const key = (o) => `${o.gameId}:${o.snapshotId}`;
Expand All @@ -34,7 +35,7 @@
busy = key(offer);
try {
await api.post('/api/cloud/offers/accept', { gameId: offer.gameId, snapshotId: offer.snapshotId });
toast(`Now using ${offer.deviceName}'s save for “${offer.gameName}” — this device's is kept as a snapshot`, 'success');
toast($t('cloudOffer.acceptedToast', { device: offer.deviceName, game: offer.gameName }), 'success');
} catch (e) {
toast(e.message, 'error');
} finally {
Expand Down Expand Up @@ -62,26 +63,24 @@
<div class="offer-icon"><CloudDownload size={19} /></div>
<div class="offer-body">
<div class="offer-title">
<strong>{offer.deviceName}</strong> has a newer save for <strong>{offer.gameName}</strong>
{#each segments($t('cloudOffer.title'), { device: offer.deviceName, game: offer.gameName }, ['device', 'game']) as part}{#if part.bold}<strong>{part.text}</strong>{:else}{part.text}{/if}{/each}
</div>
<div class="offer-sub">
Saved {timeAgo(offer.savedAt)} · in {where[offer.provider] ?? 'your cloud backup'}
{$t('cloudOffer.sub', { when: timeAgo(offer.savedAt), place: where[offer.provider] ?? $t('cloudOffer.where.cloud') })}
</div>
{#if offer.diverged}
<!-- Not "since the two last matched": two devices meeting through
the cloud for the first time never matched at all, and that
is the commonest way to see this card. -->
<div class="offer-warn">
This device's save has changes of its own that aren't in {offer.deviceName}'s.
Bringing theirs replaces them — this device's save is kept as a snapshot you can go
back to.
{$t('cloudOffer.diverged', { device: offer.deviceName })}
</div>
{/if}
</div>
<div class="offer-actions">
<button class="btn small" disabled={!!busy} on:click={() => later(offer)}>Not now</button>
<button class="btn small" disabled={!!busy} on:click={() => later(offer)}>{$t('cloudOffer.notNow')}</button>
<button class="btn small primary" disabled={!!busy} on:click={() => bring(offer)}>
{busy === key(offer) ? 'Bringing…' : 'Bring it here'}
{busy === key(offer) ? $t('cloudOffer.bringing') : $t('cloudOffer.bring')}
</button>
</div>
</div>
Expand Down
18 changes: 10 additions & 8 deletions cmd/opensave-app/frontend/src/components/CollectionsDialog.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import Trash2 from 'lucide-svelte/icons/trash-2';
import Plus from 'lucide-svelte/icons/plus';
import Modal from './ui/Modal.svelte';
import { t } from '../lib/i18n.js';
import ModalFoot from './ui/ModalFoot.svelte';
import { askConfirm } from '../lib/stores.js';
import { collectionsDialog } from '../lib/gameactions.js';
Expand Down Expand Up @@ -45,13 +46,14 @@

async function remove(c) {
const n = c.gameIds.length;
if (!(await askConfirm(`Delete the collection “${c.name}”? ${n ? `Its ${n} game${n === 1 ? '' : 's'} stay tracked; only the grouping goes.` : 'It is empty.'}`, { title: 'Delete collection?', confirmText: 'Delete', danger: true }))) return;
const body = $t('collections.deleteConfirmBody', { name: c.name }) + ' ' + (n ? $t('collections.deleteConfirmGames', { n }) : $t('collections.deleteConfirmEmpty'));
if (!(await askConfirm(body, { title: $t('collections.deleteConfirmTitle'), confirmText: $t('common.delete'), danger: true }))) return;
await deleteCollection(c.id);
}
</script>

{#if game}
<Modal title="Collections" icon={Tags} onClose={close} width={460} height="auto" maxHeight="min(78vh, 620px)">
<Modal title={$t('collections.title')} icon={Tags} onClose={close} width={460} height="auto" maxHeight="min(78vh, 620px)">
<svelte:fragment slot="sub">{game.name}</svelte:fragment>
<div class="body">
<ul>
Expand Down Expand Up @@ -80,22 +82,22 @@
<span class="count">{c.gameIds.length}</span>
</label>
{#if !c.builtin}
<button class="btn small ghost icon" title="Rename" aria-label="Rename {c.name}" on:click={() => startRename(c)}><Pencil size={13} /></button>
<button class="btn small ghost icon" title="Delete" aria-label="Delete {c.name}" on:click={() => remove(c)}><Trash2 size={13} /></button>
<button class="btn small ghost icon" title={$t('common.rename')} aria-label={$t('collections.renameAria', { name: c.name })} on:click={() => startRename(c)}><Pencil size={13} /></button>
<button class="btn small ghost icon" title={$t('common.delete')} aria-label={$t('collections.deleteAria', { name: c.name })} on:click={() => remove(c)}><Trash2 size={13} /></button>
{/if}
{/if}
</li>
{/each}
</ul>
<form class="new" on:submit|preventDefault={add}>
<input placeholder="New collection, e.g. Playing now" maxlength="40" bind:value={newName} />
<button class="btn small" type="submit" disabled={!newName.trim()}><Plus size={14} />Add</button>
<input placeholder={$t('collections.newPlaceholder')} maxlength="40" bind:value={newName} />
<button class="btn small" type="submit" disabled={!newName.trim()}><Plus size={14} />{$t('common.add')}</button>
</form>
<p class="hint">Collections are for finding games in the library; they don't change how a game syncs.</p>
<p class="hint">{$t('collections.hint')}</p>
</div>
<ModalFoot>
<span></span>
<button class="btn primary" on:click={close}>Done</button>
<button class="btn primary" on:click={close}>{$t('common.done')}</button>
</ModalFoot>
</Modal>
{/if}
Expand Down
48 changes: 25 additions & 23 deletions cmd/opensave-app/frontend/src/components/CommandPalette.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
// Enter. Everything in it is also somewhere on screen; this is the way to
// it that does not need the mouse or knowing where it is.
import { createEventDispatcher, onMount, tick } from 'svelte';
import { plural } from '../lib/format.js';
import Search from 'lucide-svelte/icons/search';
import CornerDownLeft from 'lucide-svelte/icons/corner-down-left';
import { navigate, toast, syncPause } from '../lib/stores.js';
Expand All @@ -14,6 +13,7 @@
import { showSetupAgain } from '../lib/setup.js';
import { appearance, resolvedTheme } from '../lib/appearance.js';
import { PAGES } from '../lib/shortcuts.js';
import { t } from '../lib/i18n.js';
import { rank, snapshotEntries } from '../lib/palette.js';

const dispatch = createEventDispatcher();
Expand All @@ -29,21 +29,21 @@
const isDark = () => resolvedTheme($appearance.theme, globalThis.matchMedia?.('(prefers-color-scheme: dark)').matches ?? true) === 'dark';

$: entries = [
...PAGES.map((p) => ({ label: `Go to ${p.label}`, kind: 'Page', idle: true, keywords: [p.id], run: () => navigate(p.id) })),
{ label: 'Sync all games', kind: 'Action', idle: true, run: syncAll },
{ label: 'Snapshot every game now', kind: 'Action', idle: true, keywords: ['backup', 'save', 'all'], run: snapshotAll },
{ label: 'Scan for saves', kind: 'Action', idle: true, keywords: ['find', 'detect', 'auto-scan'], run: () => navigate('home', { scan: Date.now() }) },
{ label: 'Track a folder', kind: 'Action', idle: true, keywords: ['add', 'game'], run: () => navigate('home', { add: true }) },
...PAGES.map((p) => ({ label: $t('palette.goTo', { page: p.label }), kind: 'Page', idle: true, keywords: [p.id], run: () => navigate(p.id) })),
{ label: $t('palette.syncAll'), kind: 'Action', idle: true, run: syncAll },
{ label: $t('palette.snapshotAll'), kind: 'Action', idle: true, keywords: ['backup', 'save', 'all'], run: snapshotAll },
{ label: $t('home.welcome.scanForSaves'), kind: 'Action', idle: true, keywords: ['find', 'detect', 'auto-scan'], run: () => navigate('home', { scan: Date.now() }) },
{ label: $t('palette.trackFolder'), kind: 'Action', idle: true, keywords: ['add', 'game'], run: () => navigate('home', { add: true }) },
{
label: isDark() ? 'Switch to light theme' : 'Switch to dark theme',
label: isDark() ? $t('palette.toLight') : $t('palette.toDark'),
kind: 'Action',
idle: true,
keywords: ['theme', 'appearance', 'mode'],
run: () => appearance.update((a) => ({ ...a, theme: isDark() ? 'light' : 'dark' }))
},
{ label: 'Keyboard shortcuts', kind: 'Help', idle: true, keywords: ['keys', 'help'], run: () => dispatch('help') },
{ label: $t('shortcuts.title'), kind: 'Help', idle: true, keywords: ['keys', 'help'], run: () => dispatch('help') },
{
label: 'Show the setup guide',
label: $t('palette.showSetup'),
kind: 'Help',
keywords: ['tutorial', 'getting started', 'onboarding', 'help', 'checklist'],
run: () => {
Expand All @@ -52,17 +52,17 @@
}
},
...($syncPause.paused
? [{ label: 'Resume syncing', kind: 'Action', idle: true, keywords: ['unpause', 'start'], run: resumeSync }]
? [{ label: $t('pause.resume'), kind: 'Action', idle: true, keywords: ['unpause', 'start'], run: resumeSync }]
: PAUSE_CHOICES.map((c) => ({
label: `Pause syncing ${c.label}`,
label: $t('palette.pauseFor', { when: c.label }),
kind: 'Action',
keywords: ['stop', 'hold', 'offline'],
run: () => pauseSync(c.minutes)
}))),
...$collections
.filter((c) => c.gameIds.length)
.map((c) => ({
label: `Show ${c.name}`,
label: $t('palette.show', { name: c.name }),
kind: 'Collection',
idle: c.builtin,
keywords: ['collection', 'filter'],
Expand All @@ -73,11 +73,11 @@
})),
...$visibleGames.flatMap((g) => [
{ label: g.name, kind: 'Game', idle: true, weight: 1, run: () => runGameAction('open', g) },
{ label: `Sync ${g.name}`, kind: 'Action', weight: -1, run: () => runGameAction('sync', g) },
{ label: `Snapshot ${g.name}`, kind: 'Action', weight: -1, keywords: ['backup', 'save'], run: () => runGameAction('snapshot', g) },
...(canLaunch(g) ? [{ label: `Launch ${g.name}`, kind: 'Action', weight: -1, keywords: ['play', 'start'], run: () => runGameAction('launch', g) }] : []),
{ label: `Open ${g.name} save folder`, kind: 'Action', weight: -1, keywords: ['explorer', 'files'], run: () => runGameAction('folder', g) },
{ label: `Rename ${g.name}`, kind: 'Action', weight: -1, keywords: ['name', 'title'], run: () => runGameAction('rename', g) }
{ label: $t('palette.syncGame', { game: g.name }), kind: 'Action', weight: -1, run: () => runGameAction('sync', g) },
{ label: $t('palette.snapshotGame', { game: g.name }), kind: 'Action', weight: -1, keywords: ['backup', 'save'], run: () => runGameAction('snapshot', g) },
...(canLaunch(g) ? [{ label: $t('palette.launchGame', { game: g.name }), kind: 'Action', weight: -1, keywords: ['play', 'start'], run: () => runGameAction('launch', g) }] : []),
{ label: $t('palette.openSaveFolder', { game: g.name }), kind: 'Action', weight: -1, keywords: ['explorer', 'files'], run: () => runGameAction('folder', g) },
{ label: $t('palette.renameGame', { game: g.name }), kind: 'Action', weight: -1, keywords: ['name', 'title'], run: () => runGameAction('rename', g) }
]),
// Snapshots with words of their own — a note, or a comment someone typed
// — found by those words: "boss" finds "Before the final boss".
Expand All @@ -91,7 +91,9 @@
try {
const res = await api.post('/api/snapshots/all', {});
toast(
res.failed?.length ? `Snapshot of ${plural(res.taken, 'game')}; ${res.failed.length} could not be taken` : `Took a snapshot of ${plural(res.taken, 'game')}`,
res.failed?.length
? $t('palette.snapshotAllPartial', { n: res.taken, failed: res.failed.length })
: $t('palette.snapshotAllDone', { n: res.taken }),
res.failed?.length ? 'error' : 'success'
);
} catch (e) {
Expand All @@ -101,7 +103,7 @@

async function syncAll() {
for (const g of $visibleGames) api.post(`/api/games/${g.id}/sync`).catch(() => {});
toast('Sync triggered for all games');
toast($t('home.syncAllToast'));
}

function choose(entry) {
Expand Down Expand Up @@ -135,14 +137,14 @@
</script>

<div class="scrim" role="presentation" on:mousedown|self={close}>
<div class="palette" role="dialog" aria-modal="true" aria-label="Quick switcher">
<div class="palette" role="dialog" aria-modal="true" aria-label={$t('palette.ariaLabel')}>
<div class="input-row">
<Search size={17} />
<input
bind:this={input}
bind:value={query}
on:keydown={onKeydown}
placeholder="Jump to a game, a page or an action…"
placeholder={$t('palette.placeholder')}
role="combobox"
aria-expanded="true"
aria-controls="palette-results"
Expand All @@ -163,11 +165,11 @@
on:mousedown|preventDefault={() => choose(entry)}
>
<span class="label">{entry.label}</span>
<span class="kind">{entry.kind}</span>
<span class="kind">{$t(`palette.kind.${entry.kind.toLowerCase()}`)}</span>
{#if i === active}<CornerDownLeft size={13} />{/if}
</li>
{:else}
<li class="none">Nothing matches “{query}”.</li>
<li class="none">{$t('palette.noMatches', { query })}</li>
{/each}
</ul>
</div>
Expand Down
7 changes: 4 additions & 3 deletions cmd/opensave-app/frontend/src/components/DiscordBanner.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
// clickable version, shown above the notes where the announcement is.
import { native } from '../lib/api.js';
import { DISCORD_URL } from '../lib/links.js';
import { t } from '../lib/i18n.js';

export let compact = false;
</script>
Expand All @@ -16,10 +17,10 @@
</svg>
</span>
<span class="text">
<span class="title">The OpenSave Discord is open</span>
<span class="sub">Get help, report a bug, or see what's coming before it ships.</span>
<span class="title">{$t('discord.title')}</span>
<span class="sub">{$t('discord.sub')}</span>
</span>
<span class="cta">Join <span class="arrow" aria-hidden="true">→</span></span>
<span class="cta">{$t('discord.join')} <span class="arrow" aria-hidden="true">→</span></span>
</button>

<style>
Expand Down
5 changes: 3 additions & 2 deletions cmd/opensave-app/frontend/src/components/DropOverlay.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import { onDestroy, onMount } from 'svelte';
import FolderPlus from 'lucide-svelte/icons/folder-plus';
import { dragging, listenForDrops } from '../lib/filedrop.js';
import { t } from '../lib/i18n.js';

let stop = () => {};
onMount(() => (stop = listenForDrops()));
Expand Down Expand Up @@ -40,8 +41,8 @@
<div class="drop">
<div class="box">
<FolderPlus size={34} strokeWidth={1.6} />
<strong>Drop a save folder to track it</strong>
<span>You'll get to check its name first.</span>
<strong>{$t('drop.title')}</strong>
<span>{$t('drop.hint')}</span>
</div>
</div>
{/if}
Expand Down
Loading
Loading