Feature/add localization #21
21 changed files with 398 additions and 81 deletions
BIN
bun.lockb
BIN
bun.lockb
Binary file not shown.
|
@ -1,10 +1,13 @@
|
||||||
{
|
{
|
||||||
"name": "calque",
|
"name": "calque",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@solid-primitives/i18n": "^2.1.1",
|
||||||
|
"@solid-primitives/storage": "^4.2.1",
|
||||||
"@solidjs/meta": "^0.29.4",
|
"@solidjs/meta": "^0.29.4",
|
||||||
"@solidjs/router": "^0.15.2",
|
"@solidjs/router": "^0.15.2",
|
||||||
"@solidjs/start": "^1.0.10",
|
"@solidjs/start": "^1.0.10",
|
||||||
"dexie": "^4.0.10",
|
"dexie": "^4.0.10",
|
||||||
|
"flag-icons": "^7.2.3",
|
||||||
"iterator-helpers-polyfill": "^3.0.1",
|
"iterator-helpers-polyfill": "^3.0.1",
|
||||||
"sitemap": "^8.0.0",
|
"sitemap": "^8.0.0",
|
||||||
"solid-icons": "^1.1.0",
|
"solid-icons": "^1.1.0",
|
||||||
|
|
|
@ -2,8 +2,9 @@ import { MetaProvider } from "@solidjs/meta";
|
||||||
import { Router } from "@solidjs/router";
|
import { Router } from "@solidjs/router";
|
||||||
import { FileRoutes } from "@solidjs/start/router";
|
import { FileRoutes } from "@solidjs/start/router";
|
||||||
import { Suspense } from "solid-js";
|
import { Suspense } from "solid-js";
|
||||||
import "./app.css";
|
|
||||||
import { ThemeProvider } from "./components/colorschemepicker";
|
import { ThemeProvider } from "./components/colorschemepicker";
|
||||||
|
import { I18nProvider } from "./features/i18n";
|
||||||
|
import "./app.css";
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
|
@ -11,9 +12,11 @@ export default function App() {
|
||||||
root={props => (
|
root={props => (
|
||||||
<MetaProvider>
|
<MetaProvider>
|
||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
<Suspense>{props.children}</Suspense>
|
<I18nProvider>
|
||||||
|
<Suspense>{props.children}</Suspense>
|
||||||
|
</I18nProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</MetaProvider>
|
</ MetaProvider>
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<FileRoutes />
|
<FileRoutes />
|
||||||
|
|
|
@ -1,26 +1,5 @@
|
||||||
.picker {
|
.picker {
|
||||||
display: flex;
|
grid-template-columns: auto 1fr;
|
||||||
flex-flow: row;
|
|
||||||
align-items: center;
|
|
||||||
background-color: inherit;
|
|
||||||
border: 1px solid transparent;
|
|
||||||
border-radius: var(--radii-m);
|
|
||||||
padding: var(--padding-s);
|
|
||||||
|
|
||||||
& select {
|
|
||||||
flex: 1 1 auto;
|
|
||||||
border: none;
|
|
||||||
background-color: inherit;
|
|
||||||
border-radius: var(--radii-m);
|
|
||||||
|
|
||||||
&:focus {
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&:has(:focus-visible) {
|
|
||||||
border-color: var(--info);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.hue {
|
.hue {
|
||||||
|
|
|
@ -1,9 +1,10 @@
|
||||||
import { Component, createContext, createEffect, createResource, For, ParentComponent, Show, Suspense, useContext } from "solid-js";
|
import { Component, createContext, createEffect, createResource, Match, ParentComponent, Show, Suspense, Switch, useContext } from "solid-js";
|
||||||
import css from './colorschemepicker.module.css';
|
|
||||||
import { CgDarkMode } from "solid-icons/cg";
|
|
||||||
import { action, query, useAction } from "@solidjs/router";
|
import { action, query, useAction } from "@solidjs/router";
|
||||||
import { useSession } from "vinxi/http";
|
import { useSession } from "vinxi/http";
|
||||||
import { createStore } from "solid-js/store";
|
import { createStore } from "solid-js/store";
|
||||||
|
import { ComboBox } from "./combobox";
|
||||||
|
import { WiMoonAltFull, WiMoonAltNew, WiMoonAltFirstQuarter } from "solid-icons/wi";
|
||||||
|
import css from './colorschemepicker.module.css';
|
||||||
|
|
||||||
export enum ColorScheme {
|
export enum ColorScheme {
|
||||||
Auto = 'light dark',
|
Auto = 'light dark',
|
||||||
|
@ -11,7 +12,7 @@ export enum ColorScheme {
|
||||||
Dark = 'dark',
|
Dark = 'dark',
|
||||||
}
|
}
|
||||||
|
|
||||||
const colorSchemes = Object.entries(ColorScheme) as readonly [keyof typeof ColorScheme, ColorScheme][];
|
const colorSchemes: Record<ColorScheme, keyof typeof ColorScheme> = Object.fromEntries(Object.entries(ColorScheme).map(([k, v]) => [v, k] as const)) as any;
|
||||||
|
|
||||||
export interface State {
|
export interface State {
|
||||||
colorScheme: ColorScheme;
|
colorScheme: ColorScheme;
|
||||||
|
@ -88,20 +89,17 @@ export const ColorSchemePicker: Component = (props) => {
|
||||||
const { theme, setColorScheme, setHue } = useStore();
|
const { theme, setColorScheme, setHue } = useStore();
|
||||||
|
|
||||||
return <>
|
return <>
|
||||||
<label class={css.picker} aria-label="Color scheme picker">
|
<label aria-label="Color scheme picker">
|
||||||
<CgDarkMode />
|
<ComboBox id="color-scheme-picker" class={css.picker} value={theme.colorScheme} setValue={(next) => setColorScheme(next())} values={colorSchemes}>{
|
||||||
|
(k, v) => <>
|
||||||
<select name="color-scheme-picker" onInput={(e) => {
|
<Switch>
|
||||||
if (e.target.value !== theme.colorScheme) {
|
<Match when={k === ColorScheme.Auto}><WiMoonAltFirstQuarter /></Match>
|
||||||
const nextValue = (e.target.value ?? ColorScheme.Auto) as ColorScheme;
|
<Match when={k === ColorScheme.Light}><WiMoonAltNew /></Match>
|
||||||
|
<Match when={k === ColorScheme.Dark}><WiMoonAltFull /></Match>
|
||||||
setColorScheme(nextValue);
|
</Switch>
|
||||||
}
|
{v}
|
||||||
}}>
|
</>
|
||||||
<For each={colorSchemes}>{
|
}</ComboBox>
|
||||||
([label, value]) => <option value={value} selected={value === theme.colorScheme}>{label}</option>
|
|
||||||
}</For>
|
|
||||||
</select>
|
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label class={css.hue} aria-label="Hue slider">
|
<label class={css.hue} aria-label="Hue slider">
|
||||||
|
|
76
src/components/combobox/index.module.css
Normal file
76
src/components/combobox/index.module.css
Normal file
|
@ -0,0 +1,76 @@
|
||||||
|
.box {
|
||||||
|
display: contents;
|
||||||
|
inline-size: max-content;
|
||||||
|
|
||||||
|
&:has(> :popover-open) > .button {
|
||||||
|
background-color: var(--surface-500);
|
||||||
|
border-bottom-left-radius: 0;
|
||||||
|
border-bottom-right-radius: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.button {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: inherit;
|
||||||
|
place-items: center start;
|
||||||
|
|
||||||
|
inline-size: max-content;
|
||||||
|
|
||||||
|
padding: var(--padding-m);
|
||||||
|
background-color: transparent;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radii-m);
|
||||||
|
font-size: 1rem;
|
||||||
|
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog {
|
||||||
|
display: none;
|
||||||
|
grid-template-columns: inherit;
|
||||||
|
|
||||||
|
inset-inline-start: anchor(start);
|
||||||
|
inset-block-start: anchor(end);
|
||||||
|
position-try-fallbacks: flip-inline;
|
||||||
|
|
||||||
|
inline-size: anchor-size(self-inline);
|
||||||
|
background-color: var(--surface-500);
|
||||||
|
padding: var(--padding-m);
|
||||||
|
border: none;
|
||||||
|
box-shadow: var(--shadow-2);
|
||||||
|
|
||||||
|
&:popover-open {
|
||||||
|
display: grid;
|
||||||
|
}
|
||||||
|
|
||||||
|
& > header {
|
||||||
|
display: grid;
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
|
||||||
|
gap: var(--padding-s);
|
||||||
|
}
|
||||||
|
|
||||||
|
& > main {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: subgrid;
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
row-gap: var(--padding-s);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.option {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: subgrid;
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
place-items: center start;
|
||||||
|
|
||||||
|
border-radius: var(--radii-m);
|
||||||
|
padding: var(--padding-s);
|
||||||
|
margin-inline: calc(-1 * var(--padding-s));
|
||||||
|
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&.selected {
|
||||||
|
background-color: oklch(from var(--info) l c h / .1);
|
||||||
|
}
|
||||||
|
}
|
67
src/components/combobox/index.tsx
Normal file
67
src/components/combobox/index.tsx
Normal file
|
@ -0,0 +1,67 @@
|
||||||
|
import { createMemo, createSignal, For, JSX, Setter, createEffect, Show } from "solid-js";
|
||||||
|
import css from './index.module.css';
|
||||||
|
|
||||||
|
interface ComboBoxProps<T, K extends string> {
|
||||||
|
id: string;
|
||||||
|
class?: string;
|
||||||
|
value: K;
|
||||||
|
setValue?: Setter<K>;
|
||||||
|
values: Record<K, T>;
|
||||||
|
open?: boolean;
|
||||||
|
children: (key: K, value: T) => JSX.Element;
|
||||||
|
filter?: (query: string, key: K, value: T) => boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ComboBox<T, K extends string>(props: ComboBoxProps<T, K>) {
|
||||||
|
const [dialog, setDialog] = createSignal<HTMLDialogElement>();
|
||||||
|
const [value, setValue] = createSignal<K>(props.value);
|
||||||
|
const [open, setOpen] = createSignal<boolean>(props.open ?? false);
|
||||||
|
const [query, setQuery] = createSignal<string>('');
|
||||||
|
|
||||||
|
const values = createMemo(() => {
|
||||||
|
let entries = Object.entries<T>(props.values) as [K, T][];
|
||||||
|
const filter = props.filter;
|
||||||
|
const q = query();
|
||||||
|
|
||||||
|
if (filter) {
|
||||||
|
entries = entries.filter(([k, v]) => filter(q, k, v));
|
||||||
|
}
|
||||||
|
|
||||||
|
return entries;
|
||||||
|
});
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
props.setValue?.(() => value());
|
||||||
|
});
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
dialog()?.[open() ? 'showPopover' : 'hidePopover']();
|
||||||
|
});
|
||||||
|
|
||||||
|
return <section class={`${css.box} ${props.class}`}>
|
||||||
|
<button id={`${props.id}_button`} popoverTarget={`${props.id}_dialog`} class={css.button}>
|
||||||
|
{props.children(value(), props.values[value()])}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<dialog ref={setDialog} id={`${props.id}_dialog`} anchor={`${props.id}_button`} popover class={css.dialog} onToggle={e => setOpen(e.newState === 'open')}>
|
||||||
|
<Show when={props.filter !== undefined}>
|
||||||
|
<header>
|
||||||
|
<input value={query()} onInput={e => setQuery(e.target.value)} />
|
||||||
|
</header>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<For each={values()}>{
|
||||||
|
([k, v]) => {
|
||||||
|
const selected = createMemo(() => value() === k);
|
||||||
|
|
||||||
|
return <span class={`${css.option} ${selected() ? css.selected : ''}`} onpointerdown={() => {
|
||||||
|
setValue(() => k);
|
||||||
|
dialog()?.hidePopover();
|
||||||
|
}}>{props.children(k, v)}</span>;
|
||||||
|
}
|
||||||
|
}</For>
|
||||||
|
</main>
|
||||||
|
</dialog>
|
||||||
|
</section>;
|
||||||
|
}
|
|
@ -23,7 +23,7 @@ export interface TableApi<T extends Record<string, any>> {
|
||||||
readonly rows: Accessor<DataSet<T>>;
|
readonly rows: Accessor<DataSet<T>>;
|
||||||
readonly columns: Accessor<Column<T>[]>;
|
readonly columns: Accessor<Column<T>[]>;
|
||||||
selectAll(): void;
|
selectAll(): void;
|
||||||
clear(): void;
|
clearSelection(): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TableContextType<T extends Record<string, any>> {
|
interface TableContextType<T extends Record<string, any>> {
|
||||||
|
@ -123,7 +123,7 @@ function Api<T extends Record<string, any>>(props: { api: undefined | ((api: Tab
|
||||||
selectAll() {
|
selectAll() {
|
||||||
selectionContext.selectAll();
|
selectionContext.selectAll();
|
||||||
},
|
},
|
||||||
clear() {
|
clearSelection() {
|
||||||
selectionContext.clear();
|
selectionContext.clear();
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
@ -3,6 +3,7 @@ import { debounce, Mutation } from "~/utilities";
|
||||||
import { Column, GridApi as GridCompApi, Grid as GridComp } from "~/components/grid";
|
import { Column, GridApi as GridCompApi, Grid as GridComp } from "~/components/grid";
|
||||||
import { createDataSet, DataSetNode, DataSetRowNode } from "~/components/table";
|
import { createDataSet, DataSetNode, DataSetRowNode } from "~/components/table";
|
||||||
import { SelectionItem } from "../selectable";
|
import { SelectionItem } from "../selectable";
|
||||||
|
import { useI18n } from "../i18n";
|
||||||
import css from "./grid.module.css"
|
import css from "./grid.module.css"
|
||||||
|
|
||||||
export type Entry = { key: string } & { [lang: string]: string };
|
export type Entry = { key: string } & { [lang: string]: string };
|
||||||
|
@ -11,7 +12,8 @@ export interface GridApi {
|
||||||
readonly selection: Accessor<SelectionItem<number, Entry>[]>;
|
readonly selection: Accessor<SelectionItem<number, Entry>[]>;
|
||||||
remove(indices: number[]): void;
|
remove(indices: number[]): void;
|
||||||
addKey(key: string): void;
|
addKey(key: string): void;
|
||||||
// addLocale(locale: string): void;
|
selectAll(): void;
|
||||||
|
clearSelection(): void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const groupBy = (rows: DataSetRowNode<number, Entry>[]) => {
|
const groupBy = (rows: DataSetRowNode<number, Entry>[]) => {
|
||||||
|
@ -28,12 +30,14 @@ const groupBy = (rows: DataSetRowNode<number, Entry>[]) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Grid(props: { class?: string, rows: Entry[], locales: string[], api?: (api: GridApi) => any }) {
|
export function Grid(props: { class?: string, rows: Entry[], locales: string[], api?: (api: GridApi) => any }) {
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
const rows = createMemo(() => createDataSet<Entry>(props.rows, { group: { by: 'key', with: groupBy } }));
|
const rows = createMemo(() => createDataSet<Entry>(props.rows, { group: { by: 'key', with: groupBy } }));
|
||||||
const locales = createMemo(() => props.locales);
|
const locales = createMemo(() => props.locales);
|
||||||
const columns = createMemo<Column<Entry>[]>(() => [
|
const columns = createMemo<Column<Entry>[]>(() => [
|
||||||
{
|
{
|
||||||
id: 'key',
|
id: 'key',
|
||||||
label: 'Key',
|
label: t('feature.file.grid.key'),
|
||||||
renderer: ({ value }) => value.split('.').at(-1),
|
renderer: ({ value }) => value.split('.').at(-1),
|
||||||
},
|
},
|
||||||
...locales().map<Column<Entry>>(lang => ({
|
...locales().map<Column<Entry>>(lang => ({
|
||||||
|
@ -68,6 +72,12 @@ export function Grid(props: { class?: string, rows: Entry[], locales: string[],
|
||||||
addKey(key) {
|
addKey(key) {
|
||||||
r.insert({ key, ...Object.fromEntries(locales().map(l => [l, ''])) });
|
r.insert({ key, ...Object.fromEntries(locales().map(l => [l, ''])) });
|
||||||
},
|
},
|
||||||
|
selectAll() {
|
||||||
|
api()?.selectAll();
|
||||||
|
},
|
||||||
|
clearSelection() {
|
||||||
|
api()?.clearSelection();
|
||||||
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
9
src/features/i18n/constants.ts
Normal file
9
src/features/i18n/constants.ts
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
import { Locale } from "./context";
|
||||||
|
|
||||||
|
import Flag_en_GB from 'flag-icons/flags/4x3/gb.svg';
|
||||||
|
import Flag_nl_NL from 'flag-icons/flags/4x3/nl.svg';
|
||||||
|
|
||||||
|
export const locales: Record<Locale, { label: string, flag: any }> = {
|
||||||
|
'en-GB': { label: 'English', flag: Flag_en_GB },
|
||||||
|
'nl-NL': { label: 'Nederlands', flag: Flag_nl_NL },
|
||||||
|
} as const;
|
60
src/features/i18n/context.tsx
Normal file
60
src/features/i18n/context.tsx
Normal file
|
@ -0,0 +1,60 @@
|
||||||
|
import { Accessor, createContext, createMemo, createSignal, ParentComponent, Setter, useContext } from 'solid-js';
|
||||||
|
import { translator, flatten, Translator, Flatten } from "@solid-primitives/i18n";
|
||||||
|
import en from '~/i18n/en-GB.json';
|
||||||
|
import nl from '~/i18n/nl-NL.json';
|
||||||
|
import { makePersisted } from '@solid-primitives/storage';
|
||||||
|
|
||||||
|
type RawDictionary = typeof en;
|
||||||
|
type Dictionary = Flatten<RawDictionary>;
|
||||||
|
export type Locale = 'en-GB' | 'nl-NL';
|
||||||
|
|
||||||
|
const dictionaries = {
|
||||||
|
'en-GB': en,
|
||||||
|
'nl-NL': nl,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
interface I18nContextType {
|
||||||
|
readonly t: Translator<Dictionary>;
|
||||||
|
readonly locale: Accessor<Locale>;
|
||||||
|
readonly setLocale: Setter<Locale>;
|
||||||
|
readonly dictionaries: Accessor<Record<Locale, RawDictionary>>;
|
||||||
|
readonly availableLocales: Accessor<Locale[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const I18nContext = createContext<I18nContextType>();
|
||||||
|
|
||||||
|
export const I18nProvider: ParentComponent = (props) => {
|
||||||
|
const [locale, setLocale, initLocale] = makePersisted(createSignal<Locale>('en-GB'), { name: 'locale' });
|
||||||
|
const dictionary = createMemo(() => flatten(dictionaries[locale()]));
|
||||||
|
const t = translator(dictionary);
|
||||||
|
|
||||||
|
const ctx: I18nContextType = {
|
||||||
|
t,
|
||||||
|
locale,
|
||||||
|
setLocale,
|
||||||
|
dictionaries: createMemo(() => dictionaries),
|
||||||
|
availableLocales: createMemo(() => Object.keys(dictionaries) as Locale[]),
|
||||||
|
};
|
||||||
|
|
||||||
|
return <I18nContext.Provider value={ctx}>{props.children}</I18nContext.Provider>
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useI18n = () => {
|
||||||
|
const context = useContext(I18nContext);
|
||||||
|
|
||||||
|
if (!context) {
|
||||||
|
throw new Error(`'useI18n' is called outside the scope of an <I18nProvider />`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { t: context.t, locale: context.locale };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const internal_useI18n = () => {
|
||||||
|
const context = useContext(I18nContext);
|
||||||
|
|
||||||
|
if (!context) {
|
||||||
|
throw new Error(`'useI18n' is called outside the scope of an <I18nProvider />`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return context;
|
||||||
|
};
|
2
src/features/i18n/index.tsx
Normal file
2
src/features/i18n/index.tsx
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
export { I18nProvider, useI18n } from './context';
|
||||||
|
export { LocalePicker } from './picker';
|
7
src/features/i18n/picker.module.css
Normal file
7
src/features/i18n/picker.module.css
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
.box {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flag {
|
||||||
|
inline-size: 1em;
|
||||||
|
}
|
22
src/features/i18n/picker.tsx
Normal file
22
src/features/i18n/picker.tsx
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
import { Component } from "solid-js";
|
||||||
|
import { internal_useI18n } from "./context";
|
||||||
|
import { locales } from "./constants";
|
||||||
|
import { ComboBox } from "~/components/combobox";
|
||||||
|
import { Dynamic } from "solid-js/web";
|
||||||
|
import css from './picker.module.css';
|
||||||
|
|
||||||
|
interface LocalePickerProps { }
|
||||||
|
|
||||||
|
export const LocalePicker: Component<LocalePickerProps> = (props) => {
|
||||||
|
const { locale, setLocale } = internal_useI18n();
|
||||||
|
|
||||||
|
return <ComboBox
|
||||||
|
id="locale-picker"
|
||||||
|
class={css.box}
|
||||||
|
value={locale()}
|
||||||
|
setValue={setLocale}
|
||||||
|
values={locales}
|
||||||
|
>
|
||||||
|
{(locale, { flag, label }) => <Dynamic component={flag} lang={locale} aria-label={label} class={css.flag} />}
|
||||||
|
</ComboBox>
|
||||||
|
};
|
37
src/i18n/en-GB.json
Normal file
37
src/i18n/en-GB.json
Normal file
|
@ -0,0 +1,37 @@
|
||||||
|
{
|
||||||
|
"page": {
|
||||||
|
"welcome": {
|
||||||
|
"title": "Hi, welcome!",
|
||||||
|
"subtitle": "Lets get started",
|
||||||
|
"edit": "Start editing",
|
||||||
|
"instructions": "Read the instructions",
|
||||||
|
"about": "Abut this app"
|
||||||
|
},
|
||||||
|
"edit": {
|
||||||
|
"menu": {
|
||||||
|
"file": "File",
|
||||||
|
"edit": "Edit",
|
||||||
|
"selection": "Selection"
|
||||||
|
},
|
||||||
|
"command": {
|
||||||
|
"open": "Open folder",
|
||||||
|
"close": "Close folder",
|
||||||
|
"closeTab": "Close tab",
|
||||||
|
"save": "Save",
|
||||||
|
"saveAs": "Save as ...",
|
||||||
|
"selectAll": "Select all",
|
||||||
|
"clearSelection": "Clear selection",
|
||||||
|
"insertKey": "Insert new key",
|
||||||
|
"insertLanguage": "Insert new language",
|
||||||
|
"delete": "Delete selected items"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"feature": {
|
||||||
|
"file": {
|
||||||
|
"grid": {
|
||||||
|
"key": "Key"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
37
src/i18n/nl-NL.json
Normal file
37
src/i18n/nl-NL.json
Normal file
|
@ -0,0 +1,37 @@
|
||||||
|
{
|
||||||
|
"page": {
|
||||||
|
"welcome": {
|
||||||
|
"title": "Hoi, welkom!",
|
||||||
|
"subtitle": "Laten we beginnen",
|
||||||
|
"edit": "Begin met bewerken",
|
||||||
|
"instructions": "Lees de instructies",
|
||||||
|
"about": "Over deze app"
|
||||||
|
},
|
||||||
|
"edit": {
|
||||||
|
"menu": {
|
||||||
|
"file": "Bestand",
|
||||||
|
"edit": "Bewerken",
|
||||||
|
"selection": "Selectie"
|
||||||
|
},
|
||||||
|
"command": {
|
||||||
|
"open": "Map openen",
|
||||||
|
"close": "Map sluiten",
|
||||||
|
"closeTab": "Tabblad sluiten",
|
||||||
|
"save": "Opslaan",
|
||||||
|
"saveAs": "Opslaan als ...",
|
||||||
|
"selectAll": "Selecteer alles",
|
||||||
|
"clearSelection": "Selectie leeg maken",
|
||||||
|
"insertKey": "Voeg nieuwe sleutel toe",
|
||||||
|
"insertLanguage": "Voeg nieuwe taal toe",
|
||||||
|
"delete": "Verwijder geselecteerde items"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"feature": {
|
||||||
|
"file": {
|
||||||
|
"grid": {
|
||||||
|
"key": "Sleutel"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,5 +1,5 @@
|
||||||
import { Link, Meta, Title } from "@solidjs/meta";
|
import { Link, Meta, Title } from "@solidjs/meta";
|
||||||
import { Component, createMemo, createSignal, ParentProps, Show } from "solid-js";
|
import { Component, createMemo, createSignal, ErrorBoundary, ParentProps, Show } from "solid-js";
|
||||||
import { FilesProvider } from "~/features/file";
|
import { FilesProvider } from "~/features/file";
|
||||||
import { CommandPalette, CommandPaletteApi, Menu, MenuProvider } from "~/features/menu";
|
import { CommandPalette, CommandPaletteApi, Menu, MenuProvider } from "~/features/menu";
|
||||||
import { A, RouteDefinition, useBeforeLeave } from "@solidjs/router";
|
import { A, RouteDefinition, useBeforeLeave } from "@solidjs/router";
|
||||||
|
@ -9,6 +9,7 @@ import { getRequestEvent } from "solid-js/web";
|
||||||
import { HttpHeader } from "@solidjs/start";
|
import { HttpHeader } from "@solidjs/start";
|
||||||
import { FaSolidPalette } from "solid-icons/fa";
|
import { FaSolidPalette } from "solid-icons/fa";
|
||||||
import css from "./editor.module.css";
|
import css from "./editor.module.css";
|
||||||
|
import { LocalePicker } from "~/features/i18n";
|
||||||
|
|
||||||
const event = getRequestEvent();
|
const event = getRequestEvent();
|
||||||
|
|
||||||
|
@ -76,6 +77,8 @@ export default function Editor(props: ParentProps) {
|
||||||
<Menu.Mount />
|
<Menu.Mount />
|
||||||
|
|
||||||
<section class={css.right}>
|
<section class={css.right}>
|
||||||
|
<LocalePicker />
|
||||||
|
|
||||||
<div class={css.themeMenu}>
|
<div class={css.themeMenu}>
|
||||||
<button class={css.themeMenuButton} id={`${themeMenuId}-button`} popoverTarget={`${themeMenuId}-dialog`} title="Open theme picker menu">
|
<button class={css.themeMenuButton} id={`${themeMenuId}-button`} popoverTarget={`${themeMenuId}-dialog`} title="Open theme picker menu">
|
||||||
<FaSolidPalette />
|
<FaSolidPalette />
|
||||||
|
@ -89,15 +92,11 @@ export default function Editor(props: ParentProps) {
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<FilesProvider>
|
<ErrorBoundary fallback={err => <ErrorComp error={err} />}>
|
||||||
{props.children}
|
|
||||||
</FilesProvider>
|
|
||||||
|
|
||||||
{/* <ErrorBoundary fallback={err => <ErrorComp error={err} />}>
|
|
||||||
<FilesProvider>
|
<FilesProvider>
|
||||||
{props.children}
|
{props.children}
|
||||||
</FilesProvider>
|
</FilesProvider>
|
||||||
</ErrorBoundary> */}
|
</ErrorBoundary>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
|
|
@ -10,6 +10,7 @@ import { Tab, Tabs } from "~/components/tabs";
|
||||||
import { isServer } from "solid-js/web";
|
import { isServer } from "solid-js/web";
|
||||||
import { Prompt, PromptApi } from "~/components/prompt";
|
import { Prompt, PromptApi } from "~/components/prompt";
|
||||||
import EditBlankImage from '~/assets/edit-blank.svg'
|
import EditBlankImage from '~/assets/edit-blank.svg'
|
||||||
|
import { useI18n } from "~/features/i18n";
|
||||||
import css from "./edit.module.css";
|
import css from "./edit.module.css";
|
||||||
|
|
||||||
const isInstalledPWA = !isServer && window.matchMedia('(display-mode: standalone)').matches;
|
const isInstalledPWA = !isServer && window.matchMedia('(display-mode: standalone)').matches;
|
||||||
|
@ -72,6 +73,7 @@ export default function Edit(props: ParentProps) {
|
||||||
|
|
||||||
const Editor: Component<{ root: FileSystemDirectoryHandle }> = (props) => {
|
const Editor: Component<{ root: FileSystemDirectoryHandle }> = (props) => {
|
||||||
const filesContext = useFiles();
|
const filesContext = useFiles();
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
const tabs = createMemo(() => filesContext.files().map(({ key, handle }) => {
|
const tabs = createMemo(() => filesContext.files().map(({ key, handle }) => {
|
||||||
const [api, setApi] = createSignal<(GridApi & { addLocale(locale: string): void })>();
|
const [api, setApi] = createSignal<(GridApi & { addLocale(locale: string): void })>();
|
||||||
|
@ -234,18 +236,18 @@ const Editor: Component<{ root: FileSystemDirectoryHandle }> = (props) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
const commands = {
|
const commands = {
|
||||||
open: createCommand('open folder', async () => {
|
open: createCommand(t('page.edit.command.open'), async () => {
|
||||||
const directory = await window.showDirectoryPicker({ mode: 'readwrite' });
|
const directory = await window.showDirectoryPicker({ mode: 'readwrite' });
|
||||||
|
|
||||||
await filesContext.open(directory);
|
await filesContext.open(directory);
|
||||||
}, { key: 'o', modifier: Modifier.Control }),
|
}, { key: 'o', modifier: Modifier.Control }),
|
||||||
close: createCommand('close folder', async () => {
|
close: createCommand(t('page.edit.command.close'), async () => {
|
||||||
filesContext.remove('__root__');
|
filesContext.remove('__root__');
|
||||||
}),
|
}),
|
||||||
closeTab: createCommand('close tab', async (id: string) => {
|
closeTab: createCommand(t('page.edit.command.closeTab'), async (id: string) => {
|
||||||
filesContext.remove(id);
|
filesContext.remove(id);
|
||||||
}, { key: 'w', modifier: Modifier.Control | (isInstalledPWA ? Modifier.None : Modifier.Alt) }),
|
}, { key: 'w', modifier: Modifier.Control | (isInstalledPWA ? Modifier.None : Modifier.Alt) }),
|
||||||
save: createCommand('save', async () => {
|
save: createCommand(t('page.edit.command.save'), async () => {
|
||||||
await Promise.allSettled(mutatedData().map(async ([file, data]) => {
|
await Promise.allSettled(mutatedData().map(async ([file, data]) => {
|
||||||
// TODO :: add the newly created file to the known files list to that the save file picker is not shown again on subsequent saves
|
// TODO :: add the newly created file to the known files list to that the save file picker is not shown again on subsequent saves
|
||||||
const handle = file.existing ? file.handle : await window.showSaveFilePicker({ suggestedName: file.name, excludeAcceptAllOption: true, types: [{ description: 'JSON file', accept: { 'application/json': ['.json'] } }] });
|
const handle = file.existing ? file.handle : await window.showSaveFilePicker({ suggestedName: file.name, excludeAcceptAllOption: true, types: [{ description: 'JSON file', accept: { 'application/json': ['.json'] } }] });
|
||||||
|
@ -257,7 +259,7 @@ const Editor: Component<{ root: FileSystemDirectoryHandle }> = (props) => {
|
||||||
stream.close();
|
stream.close();
|
||||||
}));
|
}));
|
||||||
}, { key: 's', modifier: Modifier.Control }),
|
}, { key: 's', modifier: Modifier.Control }),
|
||||||
saveAs: createCommand('save as', (handle?: FileSystemFileHandle) => {
|
saveAs: createCommand(t('page.edit.command.saveAs'), (handle?: FileSystemFileHandle) => {
|
||||||
console.log('save as ...', handle);
|
console.log('save as ...', handle);
|
||||||
|
|
||||||
window.showSaveFilePicker({
|
window.showSaveFilePicker({
|
||||||
|
@ -271,13 +273,13 @@ const Editor: Component<{ root: FileSystemDirectoryHandle }> = (props) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
}, { key: 's', modifier: Modifier.Control | Modifier.Shift }),
|
}, { key: 's', modifier: Modifier.Control | Modifier.Shift }),
|
||||||
selectAll: createCommand('select all', () => {
|
selectAll: createCommand(t('page.edit.command.selectAll'), () => {
|
||||||
api()?.selectAll();
|
api()?.selectAll();
|
||||||
}, { key: 'a', modifier: Modifier.Control }),
|
}, { key: 'a', modifier: Modifier.Control }),
|
||||||
clearSelection: createCommand('clear selection', () => {
|
clearSelection: createCommand(t('page.edit.command.clearSelection'), () => {
|
||||||
api()?.clear();
|
api()?.clearSelection();
|
||||||
}),
|
}),
|
||||||
delete: createCommand('delete selected items', () => {
|
delete: createCommand(t('page.edit.command.delete'), () => {
|
||||||
const { selection, remove } = api() ?? {};
|
const { selection, remove } = api() ?? {};
|
||||||
|
|
||||||
if (!selection || !remove) {
|
if (!selection || !remove) {
|
||||||
|
@ -286,7 +288,7 @@ const Editor: Component<{ root: FileSystemDirectoryHandle }> = (props) => {
|
||||||
|
|
||||||
remove(selection().map(s => s.key));
|
remove(selection().map(s => s.key));
|
||||||
}, { key: 'delete', modifier: Modifier.None }),
|
}, { key: 'delete', modifier: Modifier.None }),
|
||||||
inserNewKey: createCommand('insert new key', async () => {
|
insertKey: createCommand(t('page.edit.command.insertKey'), async () => {
|
||||||
const formData = await newKeyPrompt()?.showModal();
|
const formData = await newKeyPrompt()?.showModal();
|
||||||
const key = formData?.get('key')?.toString();
|
const key = formData?.get('key')?.toString();
|
||||||
|
|
||||||
|
@ -296,7 +298,7 @@ const Editor: Component<{ root: FileSystemDirectoryHandle }> = (props) => {
|
||||||
|
|
||||||
api()?.addKey(key);
|
api()?.addKey(key);
|
||||||
}),
|
}),
|
||||||
inserNewLanguage: createCommand('insert new language', async () => {
|
insertLanguage: createCommand(t('page.edit.command.insertLanguage'), async () => {
|
||||||
const formData = await newLanguagePrompt()?.showModal();
|
const formData = await newLanguagePrompt()?.showModal();
|
||||||
const locale = formData?.get('locale')?.toString();
|
const locale = formData?.get('locale')?.toString();
|
||||||
|
|
||||||
|
@ -316,27 +318,27 @@ const Editor: Component<{ root: FileSystemDirectoryHandle }> = (props) => {
|
||||||
}</Context.Menu>
|
}</Context.Menu>
|
||||||
|
|
||||||
<Menu.Root>
|
<Menu.Root>
|
||||||
<Menu.Item label="file">
|
<Menu.Item label={t('page.edit.menu.file')}>
|
||||||
<Menu.Item command={commands.open} />
|
<Menu.Item command={commands.open} />
|
||||||
|
|
||||||
<Menu.Item command={commands.save} />
|
<Menu.Item command={commands.close} />
|
||||||
|
|
||||||
<Menu.Separator />
|
<Menu.Separator />
|
||||||
|
|
||||||
<Menu.Item command={commands.close} />
|
<Menu.Item command={commands.save} />
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
|
|
||||||
<Menu.Item label="edit">
|
<Menu.Item label={t('page.edit.menu.edit')}>
|
||||||
<Menu.Item command={commands.inserNewKey} />
|
<Menu.Item command={commands.insertKey} />
|
||||||
|
|
||||||
<Menu.Item command={commands.inserNewLanguage} />
|
<Menu.Item command={commands.insertLanguage} />
|
||||||
|
|
||||||
<Menu.Separator />
|
<Menu.Separator />
|
||||||
|
|
||||||
<Menu.Item command={commands.delete} />
|
<Menu.Item command={commands.delete} />
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
|
|
||||||
<Menu.Item label="selection">
|
<Menu.Item label={t('page.edit.menu.selection')}>
|
||||||
<Menu.Item command={commands.selectAll} />
|
<Menu.Item command={commands.selectAll} />
|
||||||
|
|
||||||
<Menu.Item command={commands.clearSelection} />
|
<Menu.Item command={commands.clearSelection} />
|
||||||
|
|
|
@ -8,11 +8,14 @@
|
||||||
grid: 3em / repeat(5, 3em);
|
grid: 3em / repeat(5, 3em);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
& > h1 {
|
||||||
|
margin-block-end: 0;
|
||||||
|
}
|
||||||
|
|
||||||
ul {
|
ul {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-flow: column;
|
flex-flow: column;
|
||||||
gap: var(--padding-s);
|
gap: var(--padding-s);
|
||||||
padding-inline-start: var(--padding-l);
|
padding-inline-start: var(--padding-l);
|
||||||
margin: 0;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -1,19 +1,21 @@
|
||||||
import { A } from "@solidjs/router";
|
import { A } from "@solidjs/router";
|
||||||
import LandingImage from '../../assets/landing.svg'
|
import LandingImage from '../../assets/landing.svg'
|
||||||
import css from "./welcome.module.css";
|
import css from "./welcome.module.css";
|
||||||
|
import { useI18n } from "~/features/i18n";
|
||||||
|
|
||||||
export default function Welcome() {
|
export default function Welcome() {
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
return <main class={css.main}>
|
return <main class={css.main}>
|
||||||
<LandingImage />
|
<LandingImage />
|
||||||
|
|
||||||
<h1>Hi, welcome!</h1>
|
<h1>{t('page.welcome.title')}</h1>
|
||||||
<b>Lets get started</b>
|
<b>{t('page.welcome.subtitle')}</b>
|
||||||
|
|
||||||
<ul>
|
<ul>
|
||||||
<li><A href="/edit">Start editing</A></li>
|
<li><A href="/edit">{t('page.welcome.edit')}</A></li>
|
||||||
{/* <li><A href="/experimental">Try new features</A></li> */}
|
<li><A href="/instructions">{t('page.welcome.instructions')}</A></li>
|
||||||
<li><A href="/instructions">Read the instructions</A></li>
|
<li><A href="/about">{t('page.welcome.about')}</A></li>
|
||||||
<li><A href="/about">About this app</A></li>
|
|
||||||
</ul>
|
</ul>
|
||||||
</main>;
|
</main>;
|
||||||
}
|
}
|
||||||
|
|
|
@ -77,8 +77,9 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
& > dialog {
|
& > dialog {
|
||||||
inset-inline-end: anchor(right);
|
inset-inline-start: anchor(start);
|
||||||
inset-block-start: anchor(bottom);
|
inset-block-start: anchor(end);
|
||||||
|
position-try-fallbacks: flip-inline;
|
||||||
|
|
||||||
padding: var(--padding-m);
|
padding: var(--padding-m);
|
||||||
border: none;
|
border: none;
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue