Compare commits
10 commits
6a15035118
...
b05094b4d2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b05094b4d2 | ||
|
|
4c3348db5d | ||
|
|
52a417fc74 | ||
|
|
8eb709841e | ||
|
|
70554b27f6 | ||
|
|
c9b7ab30ca | ||
|
|
79fe404de5 | ||
|
|
ed695d136c | ||
|
|
a87ee38839 | ||
|
|
38768ca963 |
17 changed files with 184 additions and 84 deletions
58
README.md
58
README.md
|
|
@ -1,42 +1,44 @@
|
||||||
# sv
|
# Character Records Generator
|
||||||
|
|
||||||
Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli).
|
A web-based character records tool for [Aurora Station](https://aurorastation.org/). Hosted at [c.ily.rs](https://c.ily.rs).
|
||||||
|
|
||||||
## Creating a project
|
Pick a template and fill in the form. Each section covers a different record. Blank fields are omitted from the output automatically, so no rush to finish everything.
|
||||||
|
|
||||||
If you're seeing this, you've probably already done this step. Congrats!
|
Characters save to your browser. You can also export to a file or generate a share link: the link itself encodes the full set of records, so functionally it's a save file.
|
||||||
|
|
||||||
```sh
|
Share links let the recipient see a preview of your records, with the option to import the character into their own roster.
|
||||||
# create a new project
|
|
||||||
npx sv create my-app
|
This tool is entirely data-driven in XML, and it's already set up for template sharing. A visual template editor is coming soon, so anybody can create their own templates and share them between one another.
|
||||||
```
|
|
||||||
|
For issues, your best chance of getting a reply is to make an issue here, or to ping @llywelwyn in Discord.
|
||||||
To recreate this project with the same configuration:
|
|
||||||
|
Cheers.
|
||||||
```sh
|
|
||||||
# recreate this project
|
## Development
|
||||||
npx sv@0.12.8 create --template minimal --types ts --no-install .
|
|
||||||
```
|
|
||||||
|
|
||||||
## Developing
|
|
||||||
|
|
||||||
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
|
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
|
npm install
|
||||||
npm run dev
|
npm run dev
|
||||||
|
|
||||||
# or start the server and open the app in a new browser tab
|
|
||||||
npm run dev -- --open
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Building
|
Build for production:
|
||||||
|
|
||||||
To create a production version of your app:
|
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
npm run build
|
npm run build
|
||||||
```
|
```
|
||||||
|
|
||||||
You can preview the production build with `npm run preview`.
|
Validate the data files:
|
||||||
|
|
||||||
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.
|
```sh
|
||||||
|
npm run validate
|
||||||
|
```
|
||||||
|
|
||||||
|
Run tests:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npx vitest run
|
||||||
|
```
|
||||||
|
|
||||||
|
## Where did the old WPF app go?
|
||||||
|
|
||||||
|
This used to be a WPF desktop app. The last version of that lives at [`03feee5`](https://github.com/Aurorastation/character-records-generator/tree/03feee572bc7085fd8f9c458490a5dcc642ce689).
|
||||||
|
|
|
||||||
|
|
@ -38,8 +38,7 @@
|
||||||
<record type="medical">
|
<record type="medical">
|
||||||
<preamble>The following information is protected by doctor-patient confidentiality laws. Do not release without patient's consent.</preamble>
|
<preamble>The following information is protected by doctor-patient confidentiality laws. Do not release without patient's consent.</preamble>
|
||||||
<field label="Opt-Outs" type="multi-select">
|
<field label="Opt-Outs" type="multi-select">
|
||||||
<option value="no-borg" label="Do NOT BORGIFY" />
|
<option value="no-revive" label="DO NOT RESUSCITATE" />
|
||||||
<option value="no-revive" label="DO NOT REVIVE" />
|
|
||||||
<option value="no-prosthetic" label="DO NOT GIVE PROSTHETICS" />
|
<option value="no-prosthetic" label="DO NOT GIVE PROSTHETICS" />
|
||||||
</field>
|
</field>
|
||||||
<field label="Postmortem Instructions" type="textarea" />
|
<field label="Postmortem Instructions" type="textarea" />
|
||||||
|
|
|
||||||
13
package-lock.json
generated
13
package-lock.json
generated
|
|
@ -15,8 +15,7 @@
|
||||||
"zod": "^4.3.6"
|
"zod": "^4.3.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@sveltejs/adapter-auto": "^7.0.0",
|
"@sveltejs/adapter-auto": "^7.0.1",
|
||||||
"@sveltejs/adapter-static": "^3.0.10",
|
|
||||||
"@sveltejs/kit": "^2.50.2",
|
"@sveltejs/kit": "^2.50.2",
|
||||||
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
||||||
"@tailwindcss/vite": "^4.2.2",
|
"@tailwindcss/vite": "^4.2.2",
|
||||||
|
|
@ -1112,16 +1111,6 @@
|
||||||
"@sveltejs/kit": "^2.0.0"
|
"@sveltejs/kit": "^2.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@sveltejs/adapter-static": {
|
|
||||||
"version": "3.0.10",
|
|
||||||
"resolved": "https://registry.npmjs.org/@sveltejs/adapter-static/-/adapter-static-3.0.10.tgz",
|
|
||||||
"integrity": "sha512-7D9lYFWJmB7zxZyTE/qxjksvMqzMuYrrsyh1f4AlZqeZeACPRySjbC3aFiY55wb1tWUaKOQG9PVbm74JcN2Iew==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"peerDependencies": {
|
|
||||||
"@sveltejs/kit": "^2.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@sveltejs/kit": {
|
"node_modules/@sveltejs/kit": {
|
||||||
"version": "2.55.0",
|
"version": "2.55.0",
|
||||||
"resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.55.0.tgz",
|
"resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.55.0.tgz",
|
||||||
|
|
|
||||||
|
|
@ -13,8 +13,7 @@
|
||||||
"validate": "scripts/validate-xml.sh"
|
"validate": "scripts/validate-xml.sh"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@sveltejs/adapter-auto": "^7.0.0",
|
"@sveltejs/adapter-auto": "^7.0.1",
|
||||||
"@sveltejs/adapter-static": "^3.0.10",
|
|
||||||
"@sveltejs/kit": "^2.50.2",
|
"@sveltejs/kit": "^2.50.2",
|
||||||
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
||||||
"@tailwindcss/vite": "^4.2.2",
|
"@tailwindcss/vite": "^4.2.2",
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@
|
||||||
--text-muted: #737373;
|
--text-muted: #737373;
|
||||||
--accent: #0066cc;
|
--accent: #0066cc;
|
||||||
--accent-hover: #0052a3;
|
--accent-hover: #0052a3;
|
||||||
|
--error: #dc2626;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark {
|
.dark {
|
||||||
|
|
@ -28,6 +29,7 @@
|
||||||
--text-muted: #a3a3a3;
|
--text-muted: #a3a3a3;
|
||||||
--accent: #4da6ff;
|
--accent: #4da6ff;
|
||||||
--accent-hover: #80bfff;
|
--accent-hover: #80bfff;
|
||||||
|
--error: #f87171;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
|
|
@ -35,3 +37,9 @@ body {
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
font-family: system-ui, -apple-system, sans-serif;
|
font-family: system-ui, -apple-system, sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.field-error input,
|
||||||
|
.field-error select,
|
||||||
|
.field-error textarea {
|
||||||
|
border-color: var(--error) !important;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<title>Aurora Records</title>
|
<title>Character Records</title>
|
||||||
%sveltekit.head%
|
%sveltekit.head%
|
||||||
</head>
|
</head>
|
||||||
<body data-sveltekit-preload-data="hover">
|
<body data-sveltekit-preload-data="hover">
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Sun, Moon, Trash2, Plus, Upload } from 'lucide-svelte';
|
import { Sun, Moon, Trash2, Plus, Upload, CircleHelp } from 'lucide-svelte';
|
||||||
import { theme } from '$lib/theme.svelte';
|
import { theme } from '$lib/theme.svelte';
|
||||||
import { roster } from '$lib/state.svelte';
|
import { roster } from '$lib/state.svelte';
|
||||||
import { presets } from '$lib/presets';
|
import { presets } from '$lib/presets';
|
||||||
|
|
@ -8,11 +8,13 @@
|
||||||
import TemplatePicker from './TemplatePicker.svelte';
|
import TemplatePicker from './TemplatePicker.svelte';
|
||||||
import ShareMenu from './ShareMenu.svelte';
|
import ShareMenu from './ShareMenu.svelte';
|
||||||
import Modal from './Modal.svelte';
|
import Modal from './Modal.svelte';
|
||||||
|
import HelpText from './HelpText.svelte';
|
||||||
|
|
||||||
let { onImport }: { onImport?: (json: string) => void } = $props();
|
let { onImport }: { onImport?: (json: string) => void } = $props();
|
||||||
|
|
||||||
let confirmDelete = $state(false);
|
let confirmDelete = $state(false);
|
||||||
let showPicker = $state(false);
|
let showPicker = $state(false);
|
||||||
|
let showHelp = $state(false);
|
||||||
let openDropdown = $state<'add' | 'share' | null>(null);
|
let openDropdown = $state<'add' | 'share' | null>(null);
|
||||||
let fileInput: HTMLInputElement;
|
let fileInput: HTMLInputElement;
|
||||||
|
|
||||||
|
|
@ -62,7 +64,7 @@
|
||||||
|
|
||||||
<header class="border-b shrink-0" style="border-color: var(--border); background: var(--bg-card);">
|
<header class="border-b shrink-0" style="border-color: var(--border); background: var(--bg-card);">
|
||||||
<div class="flex items-center gap-2 px-4 py-3 max-w-7xl mx-auto w-full">
|
<div class="flex items-center gap-2 px-4 py-3 max-w-7xl mx-auto w-full">
|
||||||
<h1 class="font-bold whitespace-nowrap">Aurora Records</h1>
|
<h1 class="font-bold whitespace-nowrap">Character Records</h1>
|
||||||
|
|
||||||
{#if roster.characters.length > 0}
|
{#if roster.characters.length > 0}
|
||||||
<CharacterSwitcher />
|
<CharacterSwitcher />
|
||||||
|
|
@ -109,6 +111,10 @@
|
||||||
{#if roster.saveStatus === 'saving'}Saving...{:else if roster.saveStatus === 'saved'}Saved{/if}
|
{#if roster.saveStatus === 'saving'}Saving...{:else if roster.saveStatus === 'saved'}Saved{/if}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
|
<button onclick={() => { showHelp = true; }} class="flex items-center justify-center w-[30px] h-[30px] rounded hover:opacity-80" title="About">
|
||||||
|
<CircleHelp size={18} />
|
||||||
|
</button>
|
||||||
|
|
||||||
<button onclick={() => theme.toggle()} class="flex items-center justify-center w-[30px] h-[30px] rounded hover:opacity-80" title="Toggle theme">
|
<button onclick={() => theme.toggle()} class="flex items-center justify-center w-[30px] h-[30px] rounded hover:opacity-80" title="Toggle theme">
|
||||||
{#if theme.dark}
|
{#if theme.dark}
|
||||||
<Sun size={18} />
|
<Sun size={18} />
|
||||||
|
|
@ -147,4 +153,19 @@
|
||||||
</Modal>
|
</Modal>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#if showHelp}
|
||||||
|
<Modal onClose={() => { showHelp = false; }}>
|
||||||
|
<HelpText />
|
||||||
|
<div class="flex justify-end mt-4">
|
||||||
|
<button
|
||||||
|
onclick={() => { showHelp = false; }}
|
||||||
|
class="px-3 py-1 rounded text-sm border hover:opacity-80"
|
||||||
|
style="border-color: var(--border); color: var(--text);"
|
||||||
|
>
|
||||||
|
Got it
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<svelte:window onclick={() => { openDropdown = null; }} />
|
<svelte:window onclick={() => { openDropdown = null; }} />
|
||||||
|
|
|
||||||
17
src/lib/components/HelpText.svelte
Normal file
17
src/lib/components/HelpText.svelte
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
<div class="text-sm flex flex-col gap-4" style="color: var(--text-muted);">
|
||||||
|
<p>
|
||||||
|
Pick a template and fill in the form. Each section covers a different record. Blank fields are omitted from the output automatically, so no rush to finish everything.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Characters save to your browser. You can also export to a file or generate a share link: the link itself encodes the full set of records, so functionally it's a save file.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Share links let the recipient see a preview of your records, with the option to import the character into their own roster.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
This tool is entirely data-driven in XML, and it's already set up for template sharing. A visual template editor is coming soon, so anybody can create their own templates and share them between one another.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Cheers.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { slide } from 'svelte/transition';
|
import { slide } from 'svelte/transition';
|
||||||
import { ChevronDown } from 'lucide-svelte';
|
import { ChevronDown } from 'lucide-svelte';
|
||||||
import type { RecordDef } from '$lib/types';
|
import type { FieldDef, RecordDef } from '$lib/types';
|
||||||
import DynamicField from './fields/DynamicField.svelte';
|
import DynamicField from './fields/DynamicField.svelte';
|
||||||
import { slugify } from '$lib/utils/slugify';
|
import { slugify } from '$lib/utils/slugify';
|
||||||
|
|
||||||
|
|
@ -12,15 +12,22 @@
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
let expanded = $state(false);
|
let expanded = $state(false);
|
||||||
|
let touched: Record<string, boolean> = $state({});
|
||||||
|
|
||||||
|
function isFieldEmpty(v: unknown): boolean {
|
||||||
|
if (v === undefined || v === null || v === '' || v === 0) return true;
|
||||||
|
if (Array.isArray(v) && v.length === 0) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRequired(field: FieldDef): boolean {
|
||||||
|
if (field.type === 'separator') return false;
|
||||||
|
return !!field.required;
|
||||||
|
}
|
||||||
|
|
||||||
let dataFields = $derived(record.fields.filter((f) => f.type !== 'separator'));
|
let dataFields = $derived(record.fields.filter((f) => f.type !== 'separator'));
|
||||||
let filled = $derived(
|
let filled = $derived(
|
||||||
dataFields.filter((f) => {
|
dataFields.filter((f) => !isFieldEmpty(data[slugify(f.label)])).length
|
||||||
const v = data[slugify(f.label)];
|
|
||||||
if (v === undefined || v === null || v === '' || v === 0) return false;
|
|
||||||
if (Array.isArray(v) && v.length === 0) return false;
|
|
||||||
return true;
|
|
||||||
}).length
|
|
||||||
);
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
@ -55,12 +62,26 @@
|
||||||
{#if expanded}
|
{#if expanded}
|
||||||
<div transition:slide={{ duration: 150 }} class="px-4 pb-4 flex flex-col gap-4">
|
<div transition:slide={{ duration: 150 }} class="px-4 pb-4 flex flex-col gap-4">
|
||||||
{#each record.fields as field}
|
{#each record.fields as field}
|
||||||
<DynamicField
|
{@const key = slugify(field.label)}
|
||||||
{field}
|
{@const hasError = isRequired(field) && touched[key] && isFieldEmpty(data[key])}
|
||||||
value={data[slugify(field.label)]}
|
<div
|
||||||
{data}
|
class={hasError ? 'field-error' : ''}
|
||||||
onChange={(v) => onFieldChange(slugify(field.label), v)}
|
onfocusout={(e) => {
|
||||||
/>
|
if (isRequired(field) && !(e.currentTarget as HTMLElement).contains(e.relatedTarget as Node)) {
|
||||||
|
touched[key] = true;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DynamicField
|
||||||
|
{field}
|
||||||
|
value={data[key]}
|
||||||
|
{data}
|
||||||
|
onChange={(v) => onFieldChange(key, v)}
|
||||||
|
/>
|
||||||
|
{#if hasError}
|
||||||
|
<p class="text-xs mt-1" style="color: var(--error);">This field is required</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
|
||||||
|
|
@ -36,12 +36,12 @@
|
||||||
<span class="relative">
|
<span class="relative">
|
||||||
<button
|
<button
|
||||||
onclick={(e) => { e.stopPropagation(); onToggle(); }}
|
onclick={(e) => { e.stopPropagation(); onToggle(); }}
|
||||||
class="flex items-center justify-center w-[30px] h-[30px] rounded border hover:opacity-80"
|
class="flex items-center justify-center h-[30px] rounded border hover:opacity-80 {copied ? 'gap-1 px-2' : 'w-[30px]'}"
|
||||||
style="border-color: var(--border);"
|
style="border-color: var(--border);"
|
||||||
title="Share & export"
|
title="Share & export"
|
||||||
>
|
>
|
||||||
{#if copied}
|
{#if copied}
|
||||||
<Check size={14} />
|
<Check size={14} /> <span class="text-xs">Copied!</span>
|
||||||
{:else}
|
{:else}
|
||||||
<Share2 size={14} />
|
<Share2 size={14} />
|
||||||
{/if}
|
{/if}
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ describe('exportCharacter', () => {
|
||||||
expect(parsed.version).toBe(1);
|
expect(parsed.version).toBe(1);
|
||||||
expect(parsed.templateId).toBe('preset:standard');
|
expect(parsed.templateId).toBe('preset:standard');
|
||||||
expect(parsed.template).toBeDefined();
|
expect(parsed.template).toBeDefined();
|
||||||
expect(parsed.template.name).toBe('Standard');
|
expect(parsed.template.name).toBe('General');
|
||||||
expect(parsed.data).toEqual({
|
expect(parsed.data).toEqual({
|
||||||
name: 'Yury Zakharov',
|
name: 'Yury Zakharov',
|
||||||
species: 'human',
|
species: 'human',
|
||||||
|
|
|
||||||
|
|
@ -101,6 +101,11 @@ describe('formatFieldOutput', () => {
|
||||||
expect(formatFieldOutput(field, 'hharar', stubSpecies, 'tajara')).toBe('Ethnicity: Hharar');
|
expect(formatFieldOutput(field, 'hharar', stubSpecies, 'tajara')).toBe('Ethnicity: Hharar');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('formats custom subspecies with dynamic label', () => {
|
||||||
|
const field: FieldDef = { label: 'Subspecies', type: 'subspecies' };
|
||||||
|
expect(formatFieldOutput(field, 'asdfg', stubSpecies, 'tajara')).toBe('Ethnicity: asdfg');
|
||||||
|
});
|
||||||
|
|
||||||
it('returns null for empty subspecies', () => {
|
it('returns null for empty subspecies', () => {
|
||||||
const field: FieldDef = { label: 'Subspecies', type: 'subspecies' };
|
const field: FieldDef = { label: 'Subspecies', type: 'subspecies' };
|
||||||
expect(formatFieldOutput(field, '', stubSpecies, 'tajara')).toBeNull();
|
expect(formatFieldOutput(field, '', stubSpecies, 'tajara')).toBeNull();
|
||||||
|
|
|
||||||
|
|
@ -48,7 +48,7 @@ export function formatFieldOutput(
|
||||||
const sp = speciesData.find((s) => s.id === currentSpecies);
|
const sp = speciesData.find((s) => s.id === currentSpecies);
|
||||||
if (!sp) return null;
|
if (!sp) return null;
|
||||||
const sub = sp.subspecies.find((s) => s.id === value);
|
const sub = sp.subspecies.find((s) => s.id === value);
|
||||||
return sub ? `${sp.subspeciesLabel}: ${sub.name}` : null;
|
return sub ? `${sp.subspeciesLabel}: ${sub.name}` : `${sp.subspeciesLabel}: ${value}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'languages': {
|
case 'languages': {
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,7 @@ describe('character URL encoding', () => {
|
||||||
const encoded = encodeCharacterURL(testCharacter);
|
const encoded = encodeCharacterURL(testCharacter);
|
||||||
const decoded = decodeCharacterURL(encoded);
|
const decoded = decodeCharacterURL(encoded);
|
||||||
expect(decoded.data).toEqual(testCharacter.data);
|
expect(decoded.data).toEqual(testCharacter.data);
|
||||||
expect(decoded.template.name).toBe('Standard');
|
expect(decoded.template.name).toBe('General');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('uses short encoding for preset templates', () => {
|
it('uses short encoding for preset templates', () => {
|
||||||
|
|
|
||||||
|
|
@ -40,8 +40,8 @@ describe('isBlankCharacter', () => {
|
||||||
expect(isBlankCharacter(makeChar({ 'spoken-languages': [] }))).toBe(true);
|
expect(isBlankCharacter(makeChar({ 'spoken-languages': [] }))).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns true when languages is just the default', () => {
|
it('returns false when languages has any value', () => {
|
||||||
expect(isBlankCharacter(makeChar({ 'spoken-languages': ['Tau Ceti Basic'] }))).toBe(true);
|
expect(isBlankCharacter(makeChar({ 'spoken-languages': ['Tau Ceti Basic'] }))).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns false when languages has custom values', () => {
|
it('returns false when languages has custom values', () => {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import Header from '$lib/components/Header.svelte';
|
import Header from '$lib/components/Header.svelte';
|
||||||
|
import HelpText from '$lib/components/HelpText.svelte';
|
||||||
import SchemaForm from '$lib/components/SchemaForm.svelte';
|
import SchemaForm from '$lib/components/SchemaForm.svelte';
|
||||||
import OutputPanel from '$lib/components/OutputPanel.svelte';
|
import OutputPanel from '$lib/components/OutputPanel.svelte';
|
||||||
import ImportModal from '$lib/components/ImportModal.svelte';
|
import ImportModal from '$lib/components/ImportModal.svelte';
|
||||||
|
|
@ -11,8 +12,10 @@
|
||||||
|
|
||||||
let importData = $state<string | null>(null);
|
let importData = $state<string | null>(null);
|
||||||
let fileImportData = $state<{ template: any; data: Record<string, unknown> } | null>(null);
|
let fileImportData = $state<{ template: any; data: Record<string, unknown> } | null>(null);
|
||||||
|
let importError = $state<string | null>(null);
|
||||||
let mobileView = $state<'edit' | 'preview' | 'split'>('split');
|
let mobileView = $state<'edit' | 'preview' | 'split'>('split');
|
||||||
let showPicker = $state(false);
|
let showPicker = $state(false);
|
||||||
|
let emptyFileInput = $state<HTMLInputElement>();
|
||||||
|
|
||||||
function checkHash() {
|
function checkHash() {
|
||||||
const hash = window.location.hash.slice(1);
|
const hash = window.location.hash.slice(1);
|
||||||
|
|
@ -35,8 +38,9 @@
|
||||||
function handleFileImport(json: string) {
|
function handleFileImport(json: string) {
|
||||||
try {
|
try {
|
||||||
fileImportData = parseCharacterFile(json);
|
fileImportData = parseCharacterFile(json);
|
||||||
|
importError = null;
|
||||||
} catch {
|
} catch {
|
||||||
// TODO: show error to user
|
importError = 'Could not read that file. Check that it\u2019s a valid character export.';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -44,12 +48,28 @@
|
||||||
fileImportData = null;
|
fileImportData = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleEmptyFileImport(e: Event) {
|
||||||
|
const input = e.target as HTMLInputElement;
|
||||||
|
const file = input.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
const text = await file.text();
|
||||||
|
handleFileImport(text);
|
||||||
|
input.value = '';
|
||||||
|
}
|
||||||
|
|
||||||
const modes = ['edit', 'preview', 'split'] as const;
|
const modes = ['edit', 'preview', 'split'] as const;
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="h-dvh flex flex-col overflow-hidden">
|
<div class="h-dvh flex flex-col overflow-hidden">
|
||||||
<Header onImport={handleFileImport} />
|
<Header onImport={handleFileImport} />
|
||||||
|
|
||||||
|
{#if importError}
|
||||||
|
<div class="px-4 py-2 text-sm flex items-center justify-center gap-2 border-b" style="background: color-mix(in srgb, var(--error) 8%, var(--bg)); color: var(--error); border-color: var(--border);">
|
||||||
|
<span>{importError}</span>
|
||||||
|
<button onclick={() => { importError = null; }} class="underline hover:opacity-80">Dismiss</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if fileImportData}
|
{#if fileImportData}
|
||||||
<div class="flex-1 overflow-y-auto">
|
<div class="flex-1 overflow-y-auto">
|
||||||
<ImportModal fileData={fileImportData} onClose={closeFileImport} />
|
<ImportModal fileData={fileImportData} onClose={closeFileImport} />
|
||||||
|
|
@ -109,19 +129,40 @@
|
||||||
{/if}
|
{/if}
|
||||||
</main>
|
</main>
|
||||||
{:else}
|
{:else}
|
||||||
<main class="flex-1 flex flex-col items-center justify-center gap-4">
|
<main class="flex-1 flex items-center justify-center p-6">
|
||||||
<p style="color: var(--text-muted);">No characters yet.</p>
|
<div class="max-w-md w-full flex flex-col gap-6">
|
||||||
<button
|
<HelpText />
|
||||||
onclick={() => {
|
|
||||||
if (presets.length === 1) roster.create(presets[0]);
|
<div class="flex gap-3">
|
||||||
else showPicker = true;
|
<button
|
||||||
}}
|
onclick={() => {
|
||||||
class="px-3 py-1 rounded text-sm border hover:opacity-80"
|
if (presets.length === 1) roster.create(presets[0]);
|
||||||
style="border-color: var(--border);"
|
else showPicker = true;
|
||||||
>
|
}}
|
||||||
Get Started
|
class="flex-1 px-4 py-2 rounded text-sm font-medium hover:opacity-90"
|
||||||
</button>
|
style="background: var(--accent); color: white;"
|
||||||
|
>
|
||||||
|
New Character
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onclick={() => emptyFileInput.click()}
|
||||||
|
class="px-4 py-2 rounded text-sm border hover:opacity-80"
|
||||||
|
style="border-color: var(--border); color: var(--text-muted);"
|
||||||
|
>
|
||||||
|
Import
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
<input
|
||||||
|
bind:this={emptyFileInput}
|
||||||
|
type="file"
|
||||||
|
accept=".json"
|
||||||
|
class="hidden"
|
||||||
|
onchange={handleEmptyFileImport}
|
||||||
|
/>
|
||||||
|
|
||||||
{#if showPicker}
|
{#if showPicker}
|
||||||
<TemplatePicker onClose={() => { showPicker = false; }} />
|
<TemplatePicker onClose={() => { showPicker = false; }} />
|
||||||
{/if}
|
{/if}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,9 @@
|
||||||
import adapter from '@sveltejs/adapter-static';
|
import adapter from '@sveltejs/adapter-auto';
|
||||||
|
|
||||||
/** @type {import('@sveltejs/kit').Config} */
|
/** @type {import('@sveltejs/kit').Config} */
|
||||||
const config = {
|
const config = {
|
||||||
kit: {
|
kit: {
|
||||||
adapter: adapter({
|
adapter: adapter()
|
||||||
fallback: 'index.html'
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue