feat(frontend): add ESLint and Prettier with Svelte support
- Install eslint, prettier and related plugins - Add lint and format npm scripts - Configure eslint.config.js with Svelte + TypeScript rules - Configure .prettierrc with Svelte plugin - Fix code to comply with lint rules: - Use resolve() for navigation links - Use SvelteMap for reactive maps - Use writable instead of + - Remove unused imports and variables Note: ignoreGoto is set to true due to eslint-plugin-svelte#1327
This commit is contained in:
parent
609995732b
commit
098b158b75
16 changed files with 2626 additions and 726 deletions
8
frontend/.prettierignore
Normal file
8
frontend/.prettierignore
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
node_modules
|
||||||
|
.svelte-kit
|
||||||
|
paraglide
|
||||||
|
build
|
||||||
|
dist
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
9
frontend/.prettierrc
Normal file
9
frontend/.prettierrc
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
{
|
||||||
|
"plugins": ["svelte"],
|
||||||
|
"overrides": [
|
||||||
|
{
|
||||||
|
"files": ["*.svelte"],
|
||||||
|
"parser": "svelte-eslint-parser"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
47
frontend/eslint.config.js
Normal file
47
frontend/eslint.config.js
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
import js from "@eslint/js";
|
||||||
|
import svelte from "eslint-plugin-svelte";
|
||||||
|
import ts from "typescript-eslint";
|
||||||
|
import globals from "globals";
|
||||||
|
|
||||||
|
export default [
|
||||||
|
{
|
||||||
|
ignores: [
|
||||||
|
".svelte-kit",
|
||||||
|
"node_modules",
|
||||||
|
"build",
|
||||||
|
"dist",
|
||||||
|
"**/paraglide/**",
|
||||||
|
"**/lib/paraglide/**",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
js.configs.recommended,
|
||||||
|
...ts.configs.recommended,
|
||||||
|
...svelte.configs["flat/recommended"],
|
||||||
|
{
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: "latest",
|
||||||
|
sourceType: "module",
|
||||||
|
globals: {
|
||||||
|
...globals.browser,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
"svelte/no-at-html-tags": "off",
|
||||||
|
"svelte/require-each-key": "off",
|
||||||
|
// TODO: Set ignoreGoto to false when https://github.com/sveltejs/eslint-plugin-svelte/issues/1327 is fixed
|
||||||
|
// The rule doesn't detect resolve() when used with string concatenation for query params
|
||||||
|
"svelte/no-navigation-without-resolve": [
|
||||||
|
"error",
|
||||||
|
{ ignoreLinks: true, ignoreGoto: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
files: ["**/*.svelte"],
|
||||||
|
languageOptions: {
|
||||||
|
parserOptions: {
|
||||||
|
parser: ts.parser,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
@ -9,20 +9,30 @@
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"prepare": "svelte-kit sync || echo ''",
|
"prepare": "svelte-kit sync || echo ''",
|
||||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch"
|
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"format": "prettier --write ."
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^10.0.1",
|
||||||
"@internationalized/date": "^3.11.0",
|
"@internationalized/date": "^3.11.0",
|
||||||
"@lucide/svelte": "^0.561.0",
|
"@lucide/svelte": "^0.561.0",
|
||||||
"@sveltejs/adapter-node": "^5.0.0",
|
"@sveltejs/adapter-node": "^5.0.0",
|
||||||
"@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.1",
|
"@tailwindcss/vite": "^4.2.1",
|
||||||
|
"eslint": "^10.0.2",
|
||||||
|
"eslint-plugin-svelte": "^3.15.0",
|
||||||
|
"globals": "^17.4.0",
|
||||||
|
"prettier": "^3.8.1",
|
||||||
|
"prettier-plugin-svelte": "^3.5.0",
|
||||||
"svelte": "^5.51.0",
|
"svelte": "^5.51.0",
|
||||||
"svelte-check": "^4.3.6",
|
"svelte-check": "^4.3.6",
|
||||||
|
"svelte-eslint-parser": "^1.5.1",
|
||||||
"tailwind-variants": "^3.2.2",
|
"tailwind-variants": "^3.2.2",
|
||||||
"tailwindcss": "^4.2.1",
|
"tailwindcss": "^4.2.1",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
|
"typescript-eslint": "^8.56.1",
|
||||||
"vite": "^7.3.1"
|
"vite": "^7.3.1"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|
|
||||||
3127
frontend/pnpm-lock.yaml
generated
3127
frontend/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
|
|
@ -1,6 +1,7 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import '../app.css';
|
import '../app.css';
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
|
import { resolve } from '$app/paths';
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
import LanguageSwitcher from '$lib/components/LanguageSwitcher.svelte';
|
import LanguageSwitcher from '$lib/components/LanguageSwitcher.svelte';
|
||||||
|
|
||||||
|
|
@ -9,13 +10,13 @@
|
||||||
let mobileMenuOpen = $state(false);
|
let mobileMenuOpen = $state(false);
|
||||||
|
|
||||||
const navItems = $derived([
|
const navItems = $derived([
|
||||||
{ href: '/', label: m.nav_dashboard(), icon: '🏠' },
|
{ href: resolve('/'), label: m.nav_dashboard(), icon: '🏠' },
|
||||||
{ href: '/products', label: m.nav_products(), icon: '🧴' },
|
{ href: resolve('/products'), label: m.nav_products(), icon: '🧴' },
|
||||||
{ href: '/routines', label: m.nav_routines(), icon: '📋' },
|
{ href: resolve('/routines'), label: m.nav_routines(), icon: '📋' },
|
||||||
{ href: '/routines/grooming-schedule', label: m.nav_grooming(), icon: '🪒' },
|
{ href: resolve('/routines/grooming-schedule'), label: m.nav_grooming(), icon: '🪒' },
|
||||||
{ href: '/health/medications', label: m.nav_medications(), icon: '💊' },
|
{ href: resolve('/health/medications'), label: m.nav_medications(), icon: '💊' },
|
||||||
{ href: '/health/lab-results', label: m["nav_labResults"](), icon: '🔬' },
|
{ href: resolve('/health/lab-results'), label: m["nav_labResults"](), icon: '🔬' },
|
||||||
{ href: '/skin', label: m.nav_skin(), icon: '✨' }
|
{ href: resolve('/skin'), label: m.nav_skin(), icon: '✨' }
|
||||||
]);
|
]);
|
||||||
|
|
||||||
function isActive(href: string) {
|
function isActive(href: string) {
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { enhance } from '$app/forms';
|
import { enhance } from '$app/forms';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { resolve } from '$app/paths';
|
||||||
import type { ActionData, PageData } from './$types';
|
import type { ActionData, PageData } from './$types';
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
import { Badge } from '$lib/components/ui/badge';
|
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
|
||||||
import { Input } from '$lib/components/ui/input';
|
import { Input } from '$lib/components/ui/input';
|
||||||
|
|
@ -16,7 +17,6 @@
|
||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow
|
TableRow
|
||||||
} from '$lib/components/ui/table';
|
} from '$lib/components/ui/table';
|
||||||
import { goto } from '$app/navigation';
|
|
||||||
|
|
||||||
let { data, form }: { data: PageData; form: ActionData } = $props();
|
let { data, form }: { data: PageData; form: ActionData } = $props();
|
||||||
|
|
||||||
|
|
@ -33,6 +33,12 @@
|
||||||
let showForm = $state(false);
|
let showForm = $state(false);
|
||||||
let selectedFlag = $state('');
|
let selectedFlag = $state('');
|
||||||
let filterFlag = $derived(data.flag ?? '');
|
let filterFlag = $derived(data.flag ?? '');
|
||||||
|
|
||||||
|
function onFlagChange(v: string) {
|
||||||
|
const base = resolve('/health/lab-results');
|
||||||
|
const url = v ? base + '?flag=' + v : base;
|
||||||
|
goto(url, { replaceState: true });
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head><title>{m["labResults_title"]()} — innercontext</title></svelte:head>
|
<svelte:head><title>{m["labResults_title"]()} — innercontext</title></svelte:head>
|
||||||
|
|
@ -61,9 +67,7 @@
|
||||||
<Select
|
<Select
|
||||||
type="single"
|
type="single"
|
||||||
value={filterFlag}
|
value={filterFlag}
|
||||||
onValueChange={(v) => {
|
onValueChange={onFlagChange}
|
||||||
goto(v ? `/health/lab-results?flag=${v}` : '/health/lab-results');
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<SelectTrigger class="w-32">{filterFlag || m["labResults_flagAll"]()}</SelectTrigger>
|
<SelectTrigger class="w-32">{filterFlag || m["labResults_flagAll"]()}</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { PageData } from './$types';
|
import type { PageData } from './$types';
|
||||||
import type { Product } from '$lib/types';
|
import type { Product } from '$lib/types';
|
||||||
|
import { resolve } from '$app/paths';
|
||||||
|
import { SvelteMap } from 'svelte/reactivity';
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
import { Badge } from '$lib/components/ui/badge';
|
import { Badge } from '$lib/components/ui/badge';
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
|
|
@ -37,7 +39,7 @@
|
||||||
return bc !== 0 ? bc : a.name.localeCompare(b.name);
|
return bc !== 0 ? bc : a.name.localeCompare(b.name);
|
||||||
});
|
});
|
||||||
|
|
||||||
const map = new Map<string, Product[]>();
|
const map = new SvelteMap<string, Product[]>();
|
||||||
for (const p of items) {
|
for (const p of items) {
|
||||||
if (!map.has(p.category)) map.set(p.category, []);
|
if (!map.has(p.category)) map.set(p.category, []);
|
||||||
map.get(p.category)!.push(p);
|
map.get(p.category)!.push(p);
|
||||||
|
|
@ -64,8 +66,8 @@
|
||||||
<p class="text-muted-foreground">{m.products_count({ count: totalCount })}</p>
|
<p class="text-muted-foreground">{m.products_count({ count: totalCount })}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<Button href="/products/suggest" variant="outline">✨ {m["products_suggest"]()}</Button>
|
<Button href={resolve('/products/suggest')} variant="outline">✨ {m["products_suggest"]()}</Button>
|
||||||
<Button href="/products/new">{m["products_addNew"]()}</Button>
|
<Button href={resolve('/products/new')}>{m["products_addNew"]()}</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { enhance } from '$app/forms';
|
import { enhance } from '$app/forms';
|
||||||
|
import { resolve } from '$app/paths';
|
||||||
import type { ActionData, PageData } from './$types';
|
import type { ActionData, PageData } from './$types';
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
import { Badge } from '$lib/components/ui/badge';
|
import { Badge } from '$lib/components/ui/badge';
|
||||||
|
|
@ -21,7 +22,7 @@
|
||||||
|
|
||||||
<div class="max-w-2xl space-y-6">
|
<div class="max-w-2xl space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<a href="/products" class="text-sm text-muted-foreground hover:underline">{m["products_backToList"]()}</a>
|
<a href={resolve('/products')} class="text-sm text-muted-foreground hover:underline">{m["products_backToList"]()}</a>
|
||||||
<h2 class="mt-1 text-2xl font-bold tracking-tight">{product.name}</h2>
|
<h2 class="mt-1 text-2xl font-bold tracking-tight">{product.name}</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -184,9 +184,8 @@ export const actions: Actions = {
|
||||||
const contextRules = parseContextRules(form);
|
const contextRules = parseContextRules(form);
|
||||||
if (contextRules) payload.context_rules = contextRules;
|
if (contextRules) payload.context_rules = contextRules;
|
||||||
|
|
||||||
let product;
|
|
||||||
try {
|
try {
|
||||||
product = await createProduct(payload);
|
await createProduct(payload);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return fail(500, { error: (e as Error).message });
|
return fail(500, { error: (e as Error).message });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { enhance } from '$app/forms';
|
import { enhance } from '$app/forms';
|
||||||
|
import { resolve } from '$app/paths';
|
||||||
import type { ActionData } from './$types';
|
import type { ActionData } from './$types';
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
|
|
@ -20,7 +21,7 @@
|
||||||
|
|
||||||
<div class="max-w-2xl space-y-6">
|
<div class="max-w-2xl space-y-6">
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex items-center gap-4">
|
||||||
<a href="/products" class="text-sm text-muted-foreground hover:underline">{m["products_backToList"]()}</a>
|
<a href={resolve('/products')} class="text-sm text-muted-foreground hover:underline">{m["products_backToList"]()}</a>
|
||||||
<h2 class="text-2xl font-bold tracking-tight">{m["products_newTitle"]()}</h2>
|
<h2 class="text-2xl font-bold tracking-tight">{m["products_newTitle"]()}</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
import type { ActionData } from './$types';
|
|
||||||
import { fail } from '@sveltejs/kit';
|
import { fail } from '@sveltejs/kit';
|
||||||
import type { Actions } from './$types';
|
import type { Actions } from './$types';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,12 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { enhance } from '$app/forms';
|
import { enhance } from '$app/forms';
|
||||||
import { resolve } from '$app/paths';
|
import { resolve } from '$app/paths';
|
||||||
import type { ActionData, PageData } from './$types';
|
|
||||||
import type { ProductSuggestion } from '$lib/types';
|
import type { ProductSuggestion } from '$lib/types';
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
import { Badge } from '$lib/components/ui/badge';
|
import { Badge } from '$lib/components/ui/badge';
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
|
||||||
|
|
||||||
let { data, form }: { data: PageData; form: ActionData } = $props();
|
|
||||||
|
|
||||||
let suggestions = $state<ProductSuggestion[] | null>(null);
|
let suggestions = $state<ProductSuggestion[] | null>(null);
|
||||||
let reasoning = $state('');
|
let reasoning = $state('');
|
||||||
let loading = $state(false);
|
let loading = $state(false);
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { PageData } from './$types';
|
import type { PageData } from './$types';
|
||||||
|
import { resolve } from '$app/paths';
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
import { Badge } from '$lib/components/ui/badge';
|
import { Badge } from '$lib/components/ui/badge';
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
|
|
@ -28,8 +29,8 @@
|
||||||
<p class="text-muted-foreground">{m.routines_count({ count: data.routines.length })}</p>
|
<p class="text-muted-foreground">{m.routines_count({ count: data.routines.length })}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-wrap gap-2">
|
<div class="flex flex-wrap gap-2">
|
||||||
<Button href="/routines/suggest" variant="outline">{m["routines_suggestAI"]()}</Button>
|
<Button href={resolve('/routines/suggest')} variant="outline">{m["routines_suggestAI"]()}</Button>
|
||||||
<Button href="/routines/new">{m["routines_addNew"]()}</Button>
|
<Button href={resolve('/routines/new')}>{m["routines_addNew"]()}</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -19,15 +19,13 @@
|
||||||
SelectTrigger
|
SelectTrigger
|
||||||
} from '$lib/components/ui/select';
|
} from '$lib/components/ui/select';
|
||||||
import { Separator } from '$lib/components/ui/separator';
|
import { Separator } from '$lib/components/ui/separator';
|
||||||
|
import { SvelteMap } from 'svelte/reactivity';
|
||||||
|
|
||||||
let { data, form }: { data: PageData; form: ActionData } = $props();
|
let { data, form }: { data: PageData; form: ActionData } = $props();
|
||||||
let { routine, products } = $derived(data);
|
let { routine, products } = $derived(data);
|
||||||
|
|
||||||
// ── Steps local state (synced from server data) ───────────────
|
// ── Steps local state (synced from server data) ───────────────
|
||||||
let steps = $state<RoutineStep[]>([]);
|
let steps = $derived([...(routine.steps ?? [])].sort((a, b) => a.order_index - b.order_index));
|
||||||
$effect(() => {
|
|
||||||
steps = [...(routine.steps ?? [])].sort((a, b) => a.order_index - b.order_index);
|
|
||||||
});
|
|
||||||
|
|
||||||
const nextOrderIndex = $derived(
|
const nextOrderIndex = $derived(
|
||||||
steps.length ? Math.max(...steps.map((s) => s.order_index)) + 1 : 0
|
steps.length ? Math.max(...steps.map((s) => s.order_index)) + 1 : 0
|
||||||
|
|
@ -122,7 +120,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
const groupedProducts = $derived.by(() => {
|
const groupedProducts = $derived.by(() => {
|
||||||
const groups = new Map<string, typeof products>();
|
const groups = new SvelteMap<string, typeof products>();
|
||||||
for (const p of products) {
|
for (const p of products) {
|
||||||
const key = p.category ?? 'other';
|
const key = p.category ?? 'other';
|
||||||
if (!groups.has(key)) groups.set(key, []);
|
if (!groups.has(key)) groups.set(key, []);
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
import { enhance } from '$app/forms';
|
import { enhance } from '$app/forms';
|
||||||
import { resolve } from '$app/paths';
|
import { resolve } from '$app/paths';
|
||||||
import { SvelteSet } from 'svelte/reactivity';
|
import { SvelteSet } from 'svelte/reactivity';
|
||||||
import type { ActionData, PageData } from './$types';
|
import type { PageData } from './$types';
|
||||||
import type { BatchSuggestion, RoutineSuggestion, SuggestedStep } from '$lib/types';
|
import type { BatchSuggestion, RoutineSuggestion, SuggestedStep } from '$lib/types';
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
import { Badge } from '$lib/components/ui/badge';
|
import { Badge } from '$lib/components/ui/badge';
|
||||||
|
|
@ -13,7 +13,7 @@
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger } from '$lib/components/ui/select';
|
import { Select, SelectContent, SelectItem, SelectTrigger } from '$lib/components/ui/select';
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '$lib/components/ui/tabs';
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '$lib/components/ui/tabs';
|
||||||
|
|
||||||
let { data, form }: { data: PageData; form: ActionData } = $props();
|
let { data }: { data: PageData } = $props();
|
||||||
|
|
||||||
const productMap = $derived(Object.fromEntries(data.products.map((p) => [p.id, p])));
|
const productMap = $derived(Object.fromEntries(data.products.map((p) => [p.id, p])));
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue