diff --git a/apps/gnocchi/.vscode/tasks.json b/apps/gnocchi/.vscode/tasks.json
new file mode 100644
index 00000000..54870a02
--- /dev/null
+++ b/apps/gnocchi/.vscode/tasks.json
@@ -0,0 +1,13 @@
+{
+ "version": "2.0.0",
+ "tasks": [
+ {
+ "type": "npm",
+ "script": "app-dev",
+ "path": "web",
+ "problemMatcher": [],
+ "label": "npm: app-dev - web",
+ "detail": "vite --host --mode development"
+ }
+ ]
+}
diff --git a/apps/marginalia/.vscode/tasks.json b/apps/marginalia/.vscode/tasks.json
new file mode 100644
index 00000000..edb179da
--- /dev/null
+++ b/apps/marginalia/.vscode/tasks.json
@@ -0,0 +1,13 @@
+{
+ "version": "2.0.0",
+ "tasks": [
+ {
+ "type": "npm",
+ "script": "app-dev",
+ "path": "web",
+ "problemMatcher": [],
+ "label": "npm: app-dev - web",
+ "detail": "vite"
+ }
+ ]
+}
diff --git a/apps/marginalia/web/src/components/text/Book.tsx b/apps/marginalia/web/src/components/text/Book.tsx
index 2fc8edb8..f548bf77 100644
--- a/apps/marginalia/web/src/components/text/Book.tsx
+++ b/apps/marginalia/web/src/components/text/Book.tsx
@@ -1,5 +1,5 @@
-import { UsfmRenderer } from '@/components/usfm/UsfmRenderer.jsx';
import { useSuspenseQuery } from '@tanstack/react-query';
+import { UsfmNode } from '../usfm/nodes.jsx';
export interface BookProps {
id: string;
@@ -12,7 +12,11 @@ export function Book({ id }: BookProps) {
return
Loading...
;
}
- return ;
+ return (
+
+
+
+ );
}
function useBook(id: string) {
diff --git a/apps/marginalia/web/src/components/usfm/Reference.tsx b/apps/marginalia/web/src/components/usfm/Reference.tsx
new file mode 100644
index 00000000..e32c5b6e
--- /dev/null
+++ b/apps/marginalia/web/src/components/usfm/Reference.tsx
@@ -0,0 +1,29 @@
+import { Button } from '@a-type/ui/components/button';
+import {
+ Popover,
+ PopoverArrow,
+ PopoverContent,
+ PopoverTrigger,
+} from '@a-type/ui/components/popover';
+import { ReactNode } from 'react';
+
+export interface ReferenceProps {
+ caller: string;
+ children: ReactNode;
+}
+
+export function Reference({ caller, children }: ReferenceProps) {
+ return (
+
+
+
+
+
+
+ {children}
+
+
+ );
+}
diff --git a/apps/marginalia/web/src/components/usfm/UsfmParagraph.tsx b/apps/marginalia/web/src/components/usfm/UsfmParagraph.tsx
deleted file mode 100644
index bd117867..00000000
--- a/apps/marginalia/web/src/components/usfm/UsfmParagraph.tsx
+++ /dev/null
@@ -1,52 +0,0 @@
-import { ReactNode } from 'react';
-import classNames from 'classnames';
-import { UsfmParagraphData } from '@/components/usfm/types.js';
-import { isVerse } from '@/components/usfm/recognizers.js';
-import { UsfmVerse } from '@/components/usfm/UsfmVerse.jsx';
-
-export interface UsfmParagraphProps {
- content: UsfmParagraphData;
-}
-
-export function UsfmParagraph({ content }: UsfmParagraphProps) {
- const { continuation, indentation, opening, alignment, closure, embedded } =
- content.formatting;
- const lines = [...content.lines];
- const verses: ReactNode[] = [];
-
- while (lines.length > 0) {
- const line = lines.shift()!;
- if (isVerse(line)) {
- verses.push();
- } else {
- verses.push(
-
- {line}
- ,
- );
- }
- }
-
- return (
-
- {content.chapterStart && (
-
- {content.chapter}
-
- )}
- {verses}
-
- );
-}
diff --git a/apps/marginalia/web/src/components/usfm/UsfmRenderer.tsx b/apps/marginalia/web/src/components/usfm/UsfmRenderer.tsx
deleted file mode 100644
index 234ec5a6..00000000
--- a/apps/marginalia/web/src/components/usfm/UsfmRenderer.tsx
+++ /dev/null
@@ -1,66 +0,0 @@
-import { UsfmParagraph } from '@/components/usfm/UsfmParagraph.jsx';
-import {
- getParagraphFormatting,
- isChapter,
- isParagraph,
-} from '@/components/usfm/recognizers.js';
-import { UsfmParagraphData } from '@/components/usfm/types.js';
-
-export interface UsfmRendererProps {
- root: string;
-}
-
-export function UsfmRenderer({ root }: UsfmRendererProps) {
- const bookName = root.match(/^\\h (.*)$/m)?.[1];
- const remaining = root.slice(root.indexOf('\\c 1'));
- // is lines is not the right way to split the content?
- // should split on verses instead?
- const lines = remaining.split('\n');
- const paragraphs = useParagraphs(lines);
-
- return (
-
-
{bookName}
- {paragraphs.map((paragraph, index) => (
-
- ))}
-
- );
-}
-
-function useParagraphs(lines: string[]) {
- let chapter = 1;
- let chapterChanged = false;
- const paragraphs: UsfmParagraphData[] = [];
- let currentParagraph: UsfmParagraphData | null = null;
- while (lines.length > 0) {
- const line = lines.shift()!;
- if (isParagraph(line)) {
- if (currentParagraph && currentParagraph.lines.length > 0) {
- paragraphs.push(currentParagraph);
- }
- currentParagraph = {
- lines: [],
- chapter,
- chapterStart: chapterChanged,
- formatting: getParagraphFormatting(line),
- };
- chapterChanged = false;
- } else if (isChapter(line)) {
- const chapterMatch = line.match(/^\\c (\d+)/)?.[1];
- if (chapterMatch) {
- chapter = parseInt(chapterMatch);
- chapterChanged = true;
- }
- } else {
- if (!currentParagraph) {
- throw new Error('No current paragraph!');
- }
- currentParagraph.lines.push(line);
- }
- }
- if (currentParagraph?.lines?.length) {
- paragraphs.push(currentParagraph);
- }
- return paragraphs;
-}
diff --git a/apps/marginalia/web/src/components/usfm/UsfmVerse.tsx b/apps/marginalia/web/src/components/usfm/UsfmVerse.tsx
deleted file mode 100644
index acd9a88c..00000000
--- a/apps/marginalia/web/src/components/usfm/UsfmVerse.tsx
+++ /dev/null
@@ -1,175 +0,0 @@
-import { ReactNode } from 'react';
-import { Tooltip } from '@a-type/ui/components/tooltip';
-import { clsx } from '@a-type/ui';
-
-export interface UsfmVerseProps {
- line: string;
-}
-
-export function UsfmVerse({ line }: UsfmVerseProps) {
- const withoutMarker = line.slice(3);
- // FIXME:
- let [verseNumber, ...rest] = withoutMarker.split(' ');
- let remaining = rest.join(' ');
-
- const words = useParsedWords(remaining);
-
- return (
-
- {verseNumber}
- {words}
-
- );
-}
-
-function useParsedWords(source: string) {
- let remaining = source;
- const words: ReactNode[] = [];
- while (remaining.length > 0) {
- // FIXME: check beginning for marker to make \f work too....
- const nextMarker = remaining.match(/\\(\+?\w+)/);
- if (nextMarker) {
- const nextMarkerPosition = nextMarker.index || 0;
- if (nextMarkerPosition > 0) {
- words.push(
-
- {remaining.slice(0, nextMarkerPosition)}
- ,
- );
- remaining = remaining.slice(nextMarkerPosition);
- } else {
- let nextMarkerName = nextMarker[1];
- let nextMarkerNested = false;
- let searchForEndMarker = nextMarkerName;
- // '+' indicates a nested marker but doesn't otherwise
- // affect behavior.
- if (nextMarkerName.startsWith('+')) {
- nextMarkerName = nextMarkerName.slice(1);
- // since the search is regex we have to escape the +
- searchForEndMarker = '\\+' + nextMarkerName;
- nextMarkerNested = true;
- }
- const nextMarkerContent = remaining.match(
- new RegExp(
- `\\\\${searchForEndMarker}(.*?)\\\\${searchForEndMarker}\\\*`,
- ),
- );
- if (!nextMarkerContent) {
- throw new Error(
- 'No end marker for ' +
- searchForEndMarker +
- ' while searching ' +
- remaining,
- );
- }
- const [whole, content] = nextMarkerContent;
- if (nextMarkerName.startsWith('f')) {
- // just "f" is a footnote
- // "f#" is a footnote with a number
- // "fe" is an endnote
- words.push();
- } else if (nextMarkerName === 'w') {
- words.push(
- ,
- );
- } else if (nextMarkerName === 'wj') {
- // words of jesus
- words.push(
- ,
- );
- } else if (nextMarkerName.startsWith('x')) {
- // cross-reference
- words.push(
- ,
- );
- } else {
- console.warn(`Unknown marker: ${nextMarkerName}`);
- words.push(
- ,
- );
- }
-
- remaining = remaining.slice(remaining.indexOf(whole) + whole.length);
- }
- } else {
- words.push({remaining});
- remaining = '';
- }
- }
-
- return words;
-}
-
-function UsfmWordGroup({
- content,
- type,
- className,
-}: {
- content: string;
- type: string;
- className?: string;
-}) {
- const words = useParsedWords(content);
- return (
-
- {words}
-
- );
-}
-
-function UsfmWord({
- word,
- type,
- className,
- raw,
-}: {
- word: string;
- type: string;
- className?: string;
- raw: string;
-}) {
- const [display] = word.split('|');
-
- return (
-
- {display}
-
- );
-}
-
-function UsfmFootnote({ content }: { content: string }) {
- const textMatch = content.match(/\\ft(.*?)(\\?$)/);
- const text = textMatch?.[1]?.trim() || '';
- if (!text) return null;
- return (
-
- *
-
- );
-}
-
-function UsfmCrossReference({ content }: { content: string }) {
- const textMatch = content.match(/\\x(.*?)(\\?$)/);
- const text = textMatch?.[1]?.trim() || '';
- if (!text) return null;
- return (
-
- *
-
- );
-}
diff --git a/apps/marginalia/web/src/components/usfm/constants.ts b/apps/marginalia/web/src/components/usfm/constants.ts
deleted file mode 100644
index 36012ce7..00000000
--- a/apps/marginalia/web/src/components/usfm/constants.ts
+++ /dev/null
@@ -1,17 +0,0 @@
-export const PARAGRAPH_MARKERS = [
- '\\p',
- '\\m',
- '\\po',
- '\\pr',
- '\\cls',
- '\\pmo',
- '\\pm',
- '\\pmc',
- '\\pmr',
- '\\pi',
- '\\mi',
- '\\nb',
- '\\pc',
- '\\ph',
- '\\b',
-];
diff --git a/apps/marginalia/web/src/components/usfm/nodes.tsx b/apps/marginalia/web/src/components/usfm/nodes.tsx
new file mode 100644
index 00000000..f921c1c9
--- /dev/null
+++ b/apps/marginalia/web/src/components/usfm/nodes.tsx
@@ -0,0 +1,331 @@
+import { ReactNode, JSX } from 'react';
+import { Reference } from './Reference.jsx';
+
+export interface Node {
+ markers: string[];
+ consume?: (
+ line: string,
+ marker: string,
+ ) => { consumed: string; rest: string };
+ render?: (consumed: string, marker: string) => JSX.Element | null;
+ multiline?: boolean;
+}
+
+const MARKER = /\\(\S+)/;
+// by default markers consume to either
+// the end of the line, or an inverse marker
+const DEFAULT_CONSUME = (line: string, marker: string) => {
+ // escape special characters in marker
+ marker = marker.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&');
+ const inverse = new RegExp(`\\\\${marker}\\*`);
+ const inverseMatch = line.match(inverse);
+ if (inverseMatch?.index !== undefined) {
+ // remove both leading and inverse markers
+ const source = line.slice(marker.length + 1, inverseMatch.index);
+ return {
+ consumed: source,
+ rest: line.slice(inverseMatch.index + inverseMatch[0].length),
+ };
+ }
+
+ // otherwise, remove leading marker and return
+ // rest of line
+ return { consumed: line.slice(marker.length + 1), rest: '' };
+};
+const PARAGRAPH_CONSUME = (line: string, marker: string) => {
+ // paragraphs consume up to the next multiline marker
+ const nextMarker = new RegExp(`\\\\(${MULTILINE_MARKERS.join('|')})`);
+ const withoutMarker = line.slice(marker.length + 1);
+ const nextMatch = withoutMarker.match(nextMarker);
+ if (nextMatch?.index !== undefined) {
+ if (nextMatch.index === 0) {
+ debugger;
+ }
+ const source = withoutMarker.slice(0, nextMatch.index);
+ return {
+ consumed: source,
+ rest: withoutMarker.slice(nextMatch.index),
+ };
+ }
+
+ // otherwise, consume the whole line
+ return { consumed: withoutMarker, rest: '' };
+};
+
+// invisible by default
+const DEFAULT_RENDER = () => null;
+
+export function UsfmNode({ text }: { text: string }) {
+ let remaining = text;
+ let content: ReactNode[] = [];
+ let match: Node | undefined = undefined;
+ let prevRemaining = remaining;
+ do {
+ match = undefined; // reset
+
+ const marker = MARKER.exec(remaining);
+ if (marker) {
+ // add any text before the marker
+ if (marker.index > 0) {
+ const leading = remaining.slice(0, marker.index);
+ remaining = remaining.slice(marker.index);
+ content.push({leading});
+ }
+ // find node for marker
+ match = nodeMap.get(marker[1]) ?? DEFAULT_NODE;
+
+ const consume =
+ match.consume ??
+ (match.multiline ? PARAGRAPH_CONSUME : DEFAULT_CONSUME);
+ const render = match.render ?? DEFAULT_RENDER;
+ let matchAgainst;
+ let leftovers;
+ if (match.multiline) {
+ matchAgainst = remaining;
+ leftovers = '';
+ } else {
+ const [line, ...otherLines] = remaining.split('\n');
+ matchAgainst = line;
+ leftovers = otherLines.join('\n');
+ }
+ const { consumed, rest } = consume(matchAgainst, marker[1]);
+ content.push(render(consumed, marker[1]));
+ remaining = rest + leftovers;
+
+ if (prevRemaining === remaining) {
+ throw new Error(
+ 'infinite loop detected. current marker: ' +
+ marker[1] +
+ ' remaining: ' +
+ remaining,
+ );
+ }
+ prevRemaining = remaining;
+ } else {
+ // add any text after markers.
+ content.push({remaining});
+ }
+ } while (remaining.length > 0 && match !== undefined);
+
+ return <>{content}>;
+}
+
+const DEFAULT_NODE: Node = {
+ markers: [],
+ consume: DEFAULT_CONSUME,
+ render: (consumed, marker) => (
+
+ [\{marker}] {consumed}
+
+ ),
+};
+
+const chapter: Node = {
+ markers: ['c'],
+ render: (consumed) => {
+ const number = parseInt(consumed, 10);
+ return (
+
+ {number}
+
+ );
+ },
+};
+
+const paragraph: Node = {
+ markers: ['p'],
+ render: (consumed) => {
+ return (
+
+
+
+ );
+ },
+ multiline: true,
+};
+
+const verse: Node = {
+ markers: ['v'],
+ render: (consumed) => {
+ const trimmed = consumed.trimStart();
+ // first thing should be a number
+ const firstSpaceIndex = trimmed.indexOf(' ');
+ const number = parseInt(trimmed.slice(0, firstSpaceIndex), 10);
+ const rest = trimmed.slice(firstSpaceIndex + 1);
+ return (
+
+ {number}
+
+
+ );
+ },
+};
+
+const word: Node = {
+ markers: ['w', '+w'],
+ render: (consumed) => {
+ // remove strong's reference
+ const [word, strongs, ...extras] = consumed.split('|');
+ if (extras.length > 0) {
+ console.error('unexpected extra data in word: ' + extras.join('|'));
+ }
+ return (
+
+ {word.trim()}
+
+ );
+ },
+};
+
+const quote: Node = {
+ markers: ['q', 'q1', 'q2', 'q3', 'q4', 'q5'],
+ multiline: true,
+ render: (consumed, marker) => {
+ const level = parseInt(marker.slice(1), 10);
+ return (
+
+
+
+ );
+ },
+};
+
+const crossReference: Node = {
+ markers: ['x'],
+ render: (consumed) => {
+ // parse caller
+ const [caller, ...rest] = consumed.trim().split(' ');
+
+ return (
+
+
+
+ );
+ },
+};
+
+const crossReferenceText: Node = {
+ markers: ['xt'],
+ render: (consumed) => {
+ return {consumed};
+ },
+};
+
+const majorTitle: Node = {
+ markers: ['mt1', 'mt2', 'mt3'],
+ render: (consumed, marker) => {
+ if (marker === 'mt1') {
+ return {consumed}
;
+ } else if (marker === 'mt2') {
+ return {consumed}
;
+ }
+ return {consumed};
+ },
+};
+
+const footnote: Node = {
+ markers: ['f'],
+ render: (consumed) => {
+ // parse caller
+ const [caller, ...rest] = consumed.trim().split(' ');
+
+ return (
+
+
+
+ );
+ },
+};
+
+const footnoteText: Node = {
+ markers: ['ft'],
+ render: (consumed) => {
+ return {consumed};
+ },
+};
+
+const wordsOfJesus: Node = {
+ markers: ['wj'],
+ render: (consumed) => {
+ return (
+
+
+
+ );
+ },
+};
+
+// omitted
+const toc: Node = {
+ markers: ['toc1', 'toc2', 'toc3'],
+};
+const id: Node = {
+ markers: ['id', 'ide'],
+};
+const heading: Node = {
+ markers: ['h'],
+};
+const footnoteReference: Node = {
+ markers: ['fr'],
+ consume: (line) => {
+ // only consumes the marker and reference
+ const match = line.match(/\\fr \d+\D\d+/);
+ if (!match) {
+ // invalid reference? just consume the
+ // marker and return the rest
+ const rest = line.split('\\fr')[1];
+ return { consumed: '', rest };
+ }
+ return { consumed: match[0], rest: line.slice(match[0].length) };
+ },
+};
+const crossReferenceReference: Node = {
+ markers: ['xo'],
+ consume: (line) => {
+ // only consumes the marker and reference
+ const match = line.match(/\\xo \d+\D\d+/);
+ if (!match) {
+ // invalid reference? just consume the
+ // marker and return the rest
+ const rest = line.split('\\xo')[1];
+ return { consumed: '', rest };
+ }
+ return { consumed: match[0], rest: line.slice(match[0].length) };
+ },
+};
+
+const nodes = [
+ id,
+ chapter,
+ paragraph,
+ verse,
+ heading,
+ word,
+ quote,
+ crossReference,
+ toc,
+ majorTitle,
+ footnote,
+ footnoteReference,
+ footnoteText,
+ crossReferenceReference,
+ crossReferenceText,
+ wordsOfJesus,
+];
+
+const nodeMap = new Map();
+nodes.forEach((node) => {
+ node.markers.forEach((marker) => {
+ nodeMap.set(marker, node);
+ });
+});
+
+const MULTILINE_MARKERS = nodes
+ .filter((node) => node.multiline)
+ .flatMap((node) => node.markers);
diff --git a/apps/marginalia/web/src/components/usfm/recognizers.ts b/apps/marginalia/web/src/components/usfm/recognizers.ts
deleted file mode 100644
index c9980b25..00000000
--- a/apps/marginalia/web/src/components/usfm/recognizers.ts
+++ /dev/null
@@ -1,35 +0,0 @@
-import { PARAGRAPH_MARKERS } from '@/components/usfm/constants.js';
-import { UsfmParagraphFormatting } from '@/components/usfm/types.js';
-
-export function isParagraph(line: string) {
- return PARAGRAPH_MARKERS.some((marker) => line.startsWith(marker));
-}
-
-export function isChapter(line: string) {
- return line.startsWith('\\c');
-}
-
-export function isVerse(line: string) {
- return line.startsWith('\\v');
-}
-
-export function getParagraphFormatting(line: string): UsfmParagraphFormatting {
- let indentation = 0;
- if (line.startsWith('\\pi') || line.startsWith('\\ph')) {
- // TODO: parse the number of spaces.
- indentation = 1;
- }
-
- return {
- continuation: line.startsWith('\\m'),
- indentation,
- opening: line.startsWith('\\po') || line.startsWith('\\pmo'),
- alignment: line.startsWith('\\pr')
- ? 'right'
- : line.startsWith('\\pc')
- ? 'center'
- : 'left',
- closure: line.startsWith('\\cls') || line.startsWith('\\pmc'),
- embedded: line.startsWith('\\pm'),
- };
-}
diff --git a/apps/marginalia/web/src/components/usfm/types.ts b/apps/marginalia/web/src/components/usfm/types.ts
deleted file mode 100644
index 0c249a43..00000000
--- a/apps/marginalia/web/src/components/usfm/types.ts
+++ /dev/null
@@ -1,15 +0,0 @@
-export interface UsfmParagraphData {
- chapter: number;
- chapterStart: boolean;
- lines: string[];
- formatting: UsfmParagraphFormatting;
-}
-
-export interface UsfmParagraphFormatting {
- continuation?: boolean;
- indentation?: number;
- opening?: boolean;
- alignment?: 'left' | 'center' | 'right';
- closure?: boolean;
- embedded?: boolean;
-}
diff --git a/apps/shopping/.vscode/tasks.json b/apps/shopping/.vscode/tasks.json
new file mode 100644
index 00000000..edb179da
--- /dev/null
+++ b/apps/shopping/.vscode/tasks.json
@@ -0,0 +1,13 @@
+{
+ "version": "2.0.0",
+ "tasks": [
+ {
+ "type": "npm",
+ "script": "app-dev",
+ "path": "web",
+ "problemMatcher": [],
+ "label": "npm: app-dev - web",
+ "detail": "vite"
+ }
+ ]
+}
diff --git a/apps/trip-tick/.vscode/tasks.json b/apps/trip-tick/.vscode/tasks.json
new file mode 100644
index 00000000..edb179da
--- /dev/null
+++ b/apps/trip-tick/.vscode/tasks.json
@@ -0,0 +1,13 @@
+{
+ "version": "2.0.0",
+ "tasks": [
+ {
+ "type": "npm",
+ "script": "app-dev",
+ "path": "web",
+ "problemMatcher": [],
+ "label": "npm: app-dev - web",
+ "detail": "vite"
+ }
+ ]
+}
diff --git a/biscuits.code-workspace b/biscuits.code-workspace
new file mode 100644
index 00000000..e8bcc43b
--- /dev/null
+++ b/biscuits.code-workspace
@@ -0,0 +1,44 @@
+{
+ "folders": [
+ {
+ "path": "server",
+ "name": "💽 Server",
+ },
+ {
+ "path": "web",
+ "name": "🌐 Web",
+ },
+ {
+ "path": "packages",
+ "name": "📦 Packages",
+ },
+ {
+ "path": "apps/gnocchi",
+ "name": "🍝 Gnocchi",
+ },
+ {
+ "path": "apps/trip-tick",
+ "name": "🚗 Trip Tick",
+ },
+ {
+ "path": "apps/shopping",
+ "name": "🛒 Shopping",
+ },
+ {
+ "path": "apps/marginalia",
+ "name": "📚 Marginalia",
+ },
+ {
+ "path": "scripts",
+ "name": "📃 Scripts",
+ },
+ {
+ "path": "cdk",
+ "name": "🛠️ CDK",
+ },
+ {
+ "path": "blog",
+ "name": "📝 Blog",
+ },
+ ],
+}
diff --git a/server/.vscode/tasks.json b/server/.vscode/tasks.json
new file mode 100644
index 00000000..4162a530
--- /dev/null
+++ b/server/.vscode/tasks.json
@@ -0,0 +1,34 @@
+{
+ "version": "2.0.0",
+ "tasks": [
+ {
+ "type": "npm",
+ "script": "dev",
+ "problemMatcher": [],
+ "label": "npm: dev",
+ "detail": "run-p dev:*"
+ },
+ {
+ "type": "npm",
+ "script": "write-schema",
+ "problemMatcher": [],
+ "label": "npm: write-schema",
+ "detail": "env-cmd tsx watch --conditions=development ./scripts/writeSchema.ts",
+ "runOptions": {
+ "runOn": "folderOpen",
+ "instanceLimit": 1,
+ "reevaluateOnRerun": true
+ },
+ "hide": true,
+ "isBackground": true,
+ "presentation": {
+ "echo": true,
+ "reveal": "never",
+ "focus": false,
+ "panel": "shared",
+ "showReuseMessage": true,
+ "clear": false
+ }
+ }
+ ]
+}
diff --git a/web/.vscode/tasks.json b/web/.vscode/tasks.json
new file mode 100644
index 00000000..3752d459
--- /dev/null
+++ b/web/.vscode/tasks.json
@@ -0,0 +1,12 @@
+{
+ "version": "2.0.0",
+ "tasks": [
+ {
+ "type": "npm",
+ "script": "dev",
+ "problemMatcher": [],
+ "label": "npm: dev",
+ "detail": "vite --host --mode=development"
+ }
+ ]
+}