Compare commits

..

9 Commits

Author SHA1 Message Date
5cadb878ea feat: surface per-user credentials to feed source providers
Add credentials parameter to FeedSourceProvider.feedSourceForUser so
providers can receive decrypted per-user credentials (OAuth tokens,
passwords) from the user_sources table.

Wire CredentialEncryptor into UserSessionManager to handle
encrypt/decrypt. Providers receive plaintext and handle validation
internally. Existing providers ignore the new parameter.

Co-authored-by: Ona <no-reply@ona.com>
2026-04-05 14:03:59 +00:00
bfc25fa704 refactor: replace eslint/prettier with oxlint/oxfmt in admin-dashboard (#109)
Co-authored-by: Ona <no-reply@ona.com>
2026-04-04 16:16:03 +01:00
4097470656 feat: switch default LLM to glm-4.7-flash (#108)
Co-authored-by: Ona <no-reply@ona.com>
2026-03-30 00:00:53 +01:00
f549859a44 feat: combine TFL alerts into single feed item (#107)
TflSource.fetchItems() now returns one TflStatusFeedItem with an
alerts array instead of separate items per line disruption. Signals
use the highest severity. Alerts sorted by station distance.

Co-authored-by: Ona <no-reply@ona.com>
2026-03-30 00:00:41 +01:00
1483805f13 fix: handle empty lines array in TFL source (#106)
Empty lines array caused fetchLineStatuses to build /Line//Status
URL, resulting in a 404 from the TFL API. Now defaults to all
lines when the array is empty.

Also switches fetchStations to Promise.allSettled so individual
line failures don't break the entire station fetch.

Co-authored-by: Ona <no-reply@ona.com>
2026-03-29 23:19:34 +01:00
68932f83c3 feat: enable bun debugger for backend dev server (#105)
Add --inspect flag to the dev script and print the
debug.bun.sh URL with the Tailscale IP in the automation.

Co-authored-by: Ona <no-reply@ona.com>
2026-03-29 22:19:02 +01:00
4916886adf feat: combine daily weather into single feed item (#102)
Co-authored-by: Ona <no-reply@ona.com>
2026-03-29 15:40:47 +01:00
f1c2f399f2 feat: add TfL source config to admin dashboard (#104)
Co-authored-by: Ona <no-reply@ona.com>
2026-03-29 15:36:32 +01:00
7a85990c24 feat: register TfL source provider (#103)
Co-authored-by: Ona <no-reply@ona.com>
2026-03-29 15:34:50 +01:00
72 changed files with 3727 additions and 3251 deletions

View File

@@ -26,6 +26,12 @@ services:
commands: commands:
start: | start: |
gitpod --context environment environment port open 3000 --name "Aelis Backend" --protocol http gitpod --context environment environment port open 3000 --name "Aelis Backend" --protocol http
TS_IP=$(tailscale ip -4)
echo ""
echo "------------------ Bun Debugger ------------------"
echo "https://debug.bun.sh/#${TS_IP}:6499"
echo "------------------ Bun Debugger ------------------"
echo ""
cd apps/aelis-backend && bun run dev cd apps/aelis-backend && bun run dev
admin-dashboard: admin-dashboard:

View File

@@ -1,7 +0,0 @@
node_modules/
coverage/
.pnpm-store/
pnpm-lock.yaml
package-lock.json
pnpm-lock.yaml
yarn.lock

View File

@@ -1,11 +0,0 @@
{
"endOfLine": "lf",
"semi": false,
"singleQuote": false,
"tabWidth": 2,
"trailingComma": "es5",
"printWidth": 80,
"plugins": ["prettier-plugin-tailwindcss"],
"tailwindStylesheet": "src/index.css",
"tailwindFunctions": ["cn", "cva"]
}

View File

@@ -1,25 +1,25 @@
{ {
"$schema": "https://ui.shadcn.com/schema.json", "$schema": "https://ui.shadcn.com/schema.json",
"style": "radix-mira", "style": "radix-mira",
"rsc": false, "rsc": false,
"tsx": true, "tsx": true,
"tailwind": { "tailwind": {
"config": "", "config": "",
"css": "src/index.css", "css": "src/index.css",
"baseColor": "neutral", "baseColor": "neutral",
"cssVariables": true, "cssVariables": true,
"prefix": "" "prefix": ""
}, },
"iconLibrary": "lucide", "iconLibrary": "lucide",
"rtl": false, "rtl": false,
"aliases": { "aliases": {
"components": "@/components", "components": "@/components",
"utils": "@/lib/utils", "utils": "@/lib/utils",
"ui": "@/components/ui", "ui": "@/components/ui",
"lib": "@/lib", "lib": "@/lib",
"hooks": "@/hooks" "hooks": "@/hooks"
}, },
"menuColor": "default", "menuColor": "default",
"menuAccent": "subtle", "menuAccent": "subtle",
"registries": {} "registries": {}
} }

View File

@@ -1,23 +0,0 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])

View File

@@ -1,13 +1,13 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" /> <link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>vite-app</title> <title>vite-app</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
<script type="module" src="/src/main.tsx"></script> <script type="module" src="/src/main.tsx"></script>
</body> </body>
</html> </html>

View File

@@ -1,48 +1,40 @@
{ {
"name": "admin-dashboard", "name": "admin-dashboard",
"private": true, "version": "0.0.1",
"version": "0.0.1", "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "tsc -b && vite build", "build": "tsc -b && vite build",
"lint": "eslint .", "lint": "oxlint .",
"format": "prettier --write \"**/*.{ts,tsx}\"", "format": "oxfmt --write .",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@fontsource-variable/inter": "^5.2.8", "@fontsource-variable/inter": "^5.2.8",
"@tailwindcss/vite": "^4.1.17", "@tailwindcss/vite": "^4.1.17",
"@tanstack/react-query": "^5.95.0", "@tanstack/react-query": "^5.95.0",
"@tanstack/react-router": "^1.168.2", "@tanstack/react-router": "^1.168.2",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"lucide-react": "^0.577.0", "lucide-react": "^0.577.0",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"radix-ui": "^1.4.3", "radix-ui": "^1.4.3",
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0", "react-dom": "^19.2.0",
"shadcn": "^4.0.8", "shadcn": "^4.0.8",
"sonner": "^2.0.7", "sonner": "^2.0.7",
"tailwind-merge": "^3.5.0", "tailwind-merge": "^3.5.0",
"tailwindcss": "^4.1.17", "tailwindcss": "^4.1.17",
"tw-animate-css": "^1.4.0" "tw-animate-css": "^1.4.0"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.39.1", "@types/node": "^24.10.1",
"@types/node": "^24.10.1", "@types/react": "^19.2.5",
"@types/react": "^19.2.5", "@types/react-dom": "^19.2.3",
"@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^5.1.1",
"@vitejs/plugin-react": "^5.1.1", "typescript": "~5.9.3",
"eslint": "^9.39.1", "vite": "^7.2.4"
"eslint-plugin-react-hooks": "^7.0.1", }
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"prettier": "^3.8.1",
"prettier-plugin-tailwindcss": "^0.7.2",
"typescript": "~5.9.3",
"typescript-eslint": "^8.46.4",
"vite": "^7.2.4"
}
} }

View File

@@ -1,25 +1,25 @@
import { createRouter, RouterProvider } from "@tanstack/react-router"
import { useQueryClient, type QueryClient } from "@tanstack/react-query" import { useQueryClient, type QueryClient } from "@tanstack/react-query"
import { createRouter, RouterProvider } from "@tanstack/react-router"
import { routeTree } from "./route-tree.gen" import { routeTree } from "./route-tree.gen"
const router = createRouter({ const router = createRouter({
routeTree, routeTree,
defaultPreload: "intent", defaultPreload: "intent",
context: { context: {
queryClient: undefined! as QueryClient, queryClient: undefined! as QueryClient,
}, },
}) })
declare module "@tanstack/react-router" { declare module "@tanstack/react-router" {
interface Register { interface Register {
router: typeof router router: typeof router
} }
} }
export function App() { export function App() {
const queryClient = useQueryClient() const queryClient = useQueryClient()
return <RouterProvider router={router} context={{ queryClient }} /> return <RouterProvider router={router} context={{ queryClient }} />
} }
export default App export default App

View File

@@ -1,146 +1,144 @@
import { useQuery } from "@tanstack/react-query" import { useQuery } from "@tanstack/react-query"
import { useState } from "react"
import { Loader2, RefreshCw, TriangleAlert } from "lucide-react" import { Loader2, RefreshCw, TriangleAlert } from "lucide-react"
import { useState } from "react"
import type { FeedItem } from "@/lib/api" import type { FeedItem } from "@/lib/api"
import { fetchFeed } from "@/lib/api"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { fetchFeed } from "@/lib/api"
export function FeedPanel() { export function FeedPanel() {
const { const {
data: feed, data: feed,
error: feedError, error: feedError,
isFetching, isFetching,
refetch, refetch,
} = useQuery({ } = useQuery({
queryKey: ["feed"], queryKey: ["feed"],
queryFn: fetchFeed, queryFn: fetchFeed,
enabled: false, enabled: false,
}) })
const error = feedError?.message ?? null const error = feedError?.message ?? null
return ( return (
<div className="mx-auto max-w-2xl space-y-6"> <div className="mx-auto max-w-2xl space-y-6">
<div className="flex items-center justify-between gap-4"> <div className="flex items-center justify-between gap-4">
<div className="space-y-1"> <div className="space-y-1">
<h2 className="text-lg font-semibold tracking-tight">Feed</h2> <h2 className="text-lg font-semibold tracking-tight">Feed</h2>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">Query the feed as the current user.</p>
Query the feed as the current user. </div>
</p> <Button onClick={() => refetch()} disabled={isFetching} size="sm">
</div> {isFetching ? (
<Button onClick={() => refetch()} disabled={isFetching} size="sm"> <Loader2 className="size-3.5 animate-spin" />
{isFetching ? ( ) : (
<Loader2 className="size-3.5 animate-spin" /> <RefreshCw className="size-3.5" />
) : ( )}
<RefreshCw className="size-3.5" /> {feed ? "Refresh" : "Fetch"}
)} </Button>
{feed ? "Refresh" : "Fetch"} </div>
</Button>
</div>
{error && ( {error && (
<Card className="-mx-4 border-destructive"> <Card className="-mx-4 border-destructive">
<CardContent className="flex items-center gap-2 text-sm text-destructive"> <CardContent className="flex items-center gap-2 text-sm text-destructive">
<TriangleAlert className="size-4 shrink-0" /> <TriangleAlert className="size-4 shrink-0" />
{error} {error}
</CardContent> </CardContent>
</Card> </Card>
)} )}
{feed && feed.errors.length > 0 && ( {feed && feed.errors.length > 0 && (
<Card className="-mx-4"> <Card className="-mx-4">
<CardHeader className="pb-3"> <CardHeader className="pb-3">
<CardTitle className="text-sm">Source Errors</CardTitle> <CardTitle className="text-sm">Source Errors</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-2"> <CardContent className="space-y-2">
{feed.errors.map((e) => ( {feed.errors.map((e) => (
<div key={e.sourceId} className="flex items-start gap-2 text-sm"> <div key={e.sourceId} className="flex items-start gap-2 text-sm">
<Badge variant="outline" className="shrink-0 font-mono text-xs"> <Badge variant="outline" className="shrink-0 font-mono text-xs">
{e.sourceId} {e.sourceId}
</Badge> </Badge>
<span className="select-text text-muted-foreground">{e.error}</span> <span className="select-text text-muted-foreground">{e.error}</span>
</div> </div>
))} ))}
</CardContent> </CardContent>
</Card> </Card>
)} )}
{feed && ( {feed && (
<div className="space-y-3"> <div className="space-y-3">
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{feed.items.length} {feed.items.length === 1 ? "item" : "items"} {feed.items.length} {feed.items.length === 1 ? "item" : "items"}
</p> </p>
{feed.items.length === 0 && ( {feed.items.length === 0 && (
<p className="text-sm text-muted-foreground">No items in feed.</p> <p className="text-sm text-muted-foreground">No items in feed.</p>
)} )}
{feed.items.map((item) => ( {feed.items.map((item) => (
<FeedItemCard key={item.id} item={item} /> <FeedItemCard key={item.id} item={item} />
))} ))}
</div> </div>
)} )}
</div> </div>
) )
} }
function FeedItemCard({ item }: { item: FeedItem }) { function FeedItemCard({ item }: { item: FeedItem }) {
const [expanded, setExpanded] = useState(false) const [expanded, setExpanded] = useState(false)
return ( return (
<Card className="-mx-4"> <Card className="-mx-4">
<CardHeader className="pb-3"> <CardHeader className="pb-3">
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<CardTitle className="text-sm">{item.type}</CardTitle> <CardTitle className="text-sm">{item.type}</CardTitle>
<Badge variant="secondary" className="font-mono text-xs"> <Badge variant="secondary" className="font-mono text-xs">
{item.sourceId} {item.sourceId}
</Badge> </Badge>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{item.signals?.timeRelevance && ( {item.signals?.timeRelevance && (
<Badge variant="outline" className="text-xs"> <Badge variant="outline" className="text-xs">
{item.signals.timeRelevance} {item.signals.timeRelevance}
</Badge> </Badge>
)} )}
{item.signals?.urgency !== undefined && ( {item.signals?.urgency !== undefined && (
<Badge variant="outline" className="text-xs"> <Badge variant="outline" className="text-xs">
urgency: {item.signals.urgency} urgency: {item.signals.urgency}
</Badge> </Badge>
)} )}
</div> </div>
</div> </div>
<p className="select-text font-mono text-xs text-muted-foreground">{item.id}</p> <p className="select-text font-mono text-xs text-muted-foreground">{item.id}</p>
</CardHeader> </CardHeader>
<CardContent className="space-y-3"> <CardContent className="space-y-3">
{item.slots && Object.keys(item.slots).length > 0 && ( {item.slots && Object.keys(item.slots).length > 0 && (
<div className="space-y-1.5"> <div className="space-y-1.5">
{Object.entries(item.slots).map(([name, slot]) => ( {Object.entries(item.slots).map(([name, slot]) => (
<div key={name} className="text-sm"> <div key={name} className="text-sm">
<span className="font-medium">{name}: </span> <span className="font-medium">{name}: </span>
<span className="select-text text-muted-foreground"> <span className="select-text text-muted-foreground">
{slot.content ?? <span className="italic">pending</span>} {slot.content ?? <span className="italic">pending</span>}
</span> </span>
</div> </div>
))} ))}
</div> </div>
)} )}
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
className="h-auto px-0 text-xs text-muted-foreground" className="h-auto px-0 text-xs text-muted-foreground"
onClick={() => setExpanded(!expanded)} onClick={() => setExpanded(!expanded)}
> >
{expanded ? "Hide" : "Show"} raw data {expanded ? "Hide" : "Show"} raw data
</Button> </Button>
{expanded && ( {expanded && (
<pre className="select-text overflow-auto rounded-md bg-muted p-3 font-mono text-xs"> <pre className="select-text overflow-auto rounded-md bg-muted p-3 font-mono text-xs">
{JSON.stringify(item.data, null, 2)} {JSON.stringify(item.data, null, 2)}
</pre> </pre>
)} )}
</CardContent> </CardContent>
</Card> </Card>
) )
} }

View File

@@ -1,75 +1,70 @@
import { useQuery } from "@tanstack/react-query" import { useQuery } from "@tanstack/react-query"
import { CircleCheck, CircleX, Loader2 } from "lucide-react" import { CircleCheck, CircleX, Loader2 } from "lucide-react"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { getServerUrl } from "@/lib/server-url" import { getServerUrl } from "@/lib/server-url"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
async function checkHealth(serverUrl: string): Promise<boolean> { async function checkHealth(serverUrl: string): Promise<boolean> {
const res = await fetch(`${serverUrl}/health`) const res = await fetch(`${serverUrl}/health`)
if (!res.ok) throw new Error(`HTTP ${res.status}`) if (!res.ok) throw new Error(`HTTP ${res.status}`)
const data = (await res.json()) as { status: string } const data = (await res.json()) as { status: string }
if (data.status !== "ok") throw new Error("Unexpected response") if (data.status !== "ok") throw new Error("Unexpected response")
return true return true
} }
export function GeneralSettingsPanel() { export function GeneralSettingsPanel() {
const serverUrl = getServerUrl() const serverUrl = getServerUrl()
const { isLoading, isError, error } = useQuery({ const { isLoading, isError, error } = useQuery({
queryKey: ["health", serverUrl], queryKey: ["health", serverUrl],
queryFn: () => checkHealth(serverUrl), queryFn: () => checkHealth(serverUrl),
}) })
const status = isLoading ? "checking" : isError ? "error" : "ok" const status = isLoading ? "checking" : isError ? "error" : "ok"
const errorMsg = error instanceof Error ? error.message : null const errorMsg = error instanceof Error ? error.message : null
return ( return (
<div className="mx-auto max-w-xl space-y-6"> <div className="mx-auto max-w-xl space-y-6">
<div className="space-y-1"> <div className="space-y-1">
<h2 className="text-lg font-semibold tracking-tight">General</h2> <h2 className="text-lg font-semibold tracking-tight">General</h2>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">Backend server information.</p>
Backend server information. </div>
</p>
</div>
<Card className="-mx-4"> <Card className="-mx-4">
<CardHeader className="pb-4"> <CardHeader className="pb-4">
<CardTitle className="text-sm">Server</CardTitle> <CardTitle className="text-sm">Server</CardTitle>
<CardDescription> <CardDescription>Connected backend instance.</CardDescription>
Connected backend instance. </CardHeader>
</CardDescription> <CardContent>
</CardHeader> <div className="space-y-3 text-sm">
<CardContent> <div className="flex items-center justify-between gap-4">
<div className="space-y-3 text-sm"> <span className="shrink-0 text-muted-foreground">URL</span>
<div className="flex items-center justify-between gap-4"> <span className="select-text truncate font-mono text-xs">{serverUrl}</span>
<span className="shrink-0 text-muted-foreground">URL</span> </div>
<span className="select-text truncate font-mono text-xs">{serverUrl}</span> <div className="flex items-center justify-between">
</div> <span className="text-muted-foreground">Status</span>
<div className="flex items-center justify-between"> {status === "checking" && (
<span className="text-muted-foreground">Status</span> <span className="flex items-center gap-1.5 text-xs text-muted-foreground">
{status === "checking" && ( <Loader2 className="size-3 animate-spin" />
<span className="flex items-center gap-1.5 text-xs text-muted-foreground"> Checking
<Loader2 className="size-3 animate-spin" /> </span>
Checking )}
</span> {status === "ok" && (
)} <span className="flex items-center gap-1.5 text-xs text-muted-foreground">
{status === "ok" && ( <CircleCheck className="size-3.5 text-primary" />
<span className="flex items-center gap-1.5 text-xs text-muted-foreground"> Connected
<CircleCheck className="size-3.5 text-primary" /> </span>
Connected )}
</span> {status === "error" && (
)} <span className="flex items-center gap-1.5 text-xs text-destructive">
{status === "error" && ( <CircleX className="size-3.5" />
<span className="flex items-center gap-1.5 text-xs text-destructive"> {errorMsg ?? "Unreachable"}
<CircleX className="size-3.5" /> </span>
{errorMsg ?? "Unreachable"} )}
</span> </div>
)} </div>
</div> </CardContent>
</div> </Card>
</CardContent> </div>
</Card> )
</div>
)
} }

View File

@@ -1,100 +1,98 @@
import { useMutation } from "@tanstack/react-query" import { useMutation } from "@tanstack/react-query"
import { useState } from "react"
import { Loader2, Settings2 } from "lucide-react" import { Loader2, Settings2 } from "lucide-react"
import { useState } from "react"
import { toast } from "sonner" import { toast } from "sonner"
import type { AuthSession } from "@/lib/auth" import type { AuthSession } from "@/lib/auth"
import { signIn } from "@/lib/auth"
import { getServerUrl, setServerUrl } from "@/lib/server-url"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label" import { Label } from "@/components/ui/label"
import { signIn } from "@/lib/auth"
import { getServerUrl, setServerUrl } from "@/lib/server-url"
interface LoginPageProps { interface LoginPageProps {
onLogin: (session: AuthSession) => void onLogin: (session: AuthSession) => void
} }
export function LoginPage({ onLogin }: LoginPageProps) { export function LoginPage({ onLogin }: LoginPageProps) {
const [serverUrlInput, setServerUrlInput] = useState(getServerUrl) const [serverUrlInput, setServerUrlInput] = useState(getServerUrl)
const [email, setEmail] = useState("") const [email, setEmail] = useState("")
const [password, setPassword] = useState("") const [password, setPassword] = useState("")
const loginMutation = useMutation({ const loginMutation = useMutation({
mutationFn: async () => { mutationFn: async () => {
setServerUrl(serverUrlInput) setServerUrl(serverUrlInput)
return signIn(email, password) return signIn(email, password)
}, },
onSuccess(session) { onSuccess(session) {
onLogin(session) onLogin(session)
}, },
onError(err) { onError(err) {
toast.error(err.message) toast.error(err.message)
}, },
}) })
function handleSubmit(e: React.FormEvent) { function handleSubmit(e: React.FormEvent) {
e.preventDefault() e.preventDefault()
loginMutation.mutate() loginMutation.mutate()
} }
const loading = loginMutation.isPending const loading = loginMutation.isPending
return ( return (
<div className="flex min-h-svh items-center justify-center bg-background p-4"> <div className="flex min-h-svh items-center justify-center bg-background p-4">
<Card className="w-full max-w-sm"> <Card className="w-full max-w-sm">
<CardHeader className="text-center"> <CardHeader className="text-center">
<div className="mx-auto mb-2 flex size-10 items-center justify-center rounded-lg bg-primary/10"> <div className="mx-auto mb-2 flex size-10 items-center justify-center rounded-lg bg-primary/10">
<Settings2 className="size-5 text-primary" /> <Settings2 className="size-5 text-primary" />
</div> </div>
<CardTitle>Admin Dashboard</CardTitle> <CardTitle>Admin Dashboard</CardTitle>
<CardDescription>Sign in to manage source configuration.</CardDescription> <CardDescription>Sign in to manage source configuration.</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="server-url">Server URL</Label> <Label htmlFor="server-url">Server URL</Label>
<Input <Input
id="server-url" id="server-url"
type="url" type="url"
value={serverUrlInput} value={serverUrlInput}
onChange={(e) => setServerUrlInput(e.target.value)} onChange={(e) => setServerUrlInput(e.target.value)}
placeholder="http://localhost:3000" placeholder="http://localhost:3000"
required required
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="email">Email</Label> <Label htmlFor="email">Email</Label>
<Input <Input
id="email" id="email"
type="email" type="email"
value={email} value={email}
onChange={(e) => setEmail(e.target.value)} onChange={(e) => setEmail(e.target.value)}
placeholder="admin@aelis.local" placeholder="admin@aelis.local"
required required
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="password">Password</Label> <Label htmlFor="password">Password</Label>
<Input <Input
id="password" id="password"
type="password" type="password"
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
required required
/> />
</div> </div>
<Button type="submit" className="w-full" disabled={loading}>
<Button type="submit" className="w-full" disabled={loading}> {loading && <Loader2 className="size-4 animate-spin" />}
{loading && <Loader2 className="size-4 animate-spin" />} {loading ? "Signing in" : "Sign in"}
{loading ? "Signing in…" : "Sign in"} </Button>
</Button> </form>
</CardContent>
</form> </Card>
</CardContent> </div>
</Card> )
</div>
)
} }

View File

@@ -1,10 +1,9 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { useState } from "react"
import { Info, Loader2, MapPin, Trash2 } from "lucide-react" import { Info, Loader2, MapPin, Trash2 } from "lucide-react"
import { useState } from "react"
import { toast } from "sonner" import { toast } from "sonner"
import type { ConfigFieldDef, SourceDefinition } from "@/lib/api" import type { ConfigFieldDef, SourceDefinition } from "@/lib/api"
import { fetchSourceConfig, pushLocation, replaceSource, updateProviderConfig } from "@/lib/api"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
@@ -12,453 +11,489 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label" import { Label } from "@/components/ui/label"
import { import {
Select, Select,
SelectContent, SelectContent,
SelectItem, SelectItem,
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select" } from "@/components/ui/select"
import { Separator } from "@/components/ui/separator" import { Separator } from "@/components/ui/separator"
import { Switch } from "@/components/ui/switch" import { Switch } from "@/components/ui/switch"
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip" import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
import { fetchSourceConfig, pushLocation, replaceSource, updateProviderConfig } from "@/lib/api"
interface SourceConfigPanelProps { interface SourceConfigPanelProps {
source: SourceDefinition source: SourceDefinition
onUpdate: () => void onUpdate: () => void
} }
export function SourceConfigPanel({ source, onUpdate }: SourceConfigPanelProps) { export function SourceConfigPanel({ source, onUpdate }: SourceConfigPanelProps) {
const queryClient = useQueryClient() const queryClient = useQueryClient()
const [dirty, setDirty] = useState<Record<string, unknown>>({}) const [dirty, setDirty] = useState<Record<string, unknown>>({})
const { data: serverConfig, isLoading } = useQuery({ const { data: serverConfig, isLoading } = useQuery({
queryKey: ["sourceConfig", source.id], queryKey: ["sourceConfig", source.id],
queryFn: () => fetchSourceConfig(source.id), queryFn: () => fetchSourceConfig(source.id),
}) })
const enabled = serverConfig?.enabled ?? false const enabled = serverConfig?.enabled ?? false
const serverValues = buildInitialValues(source.fields, serverConfig?.config) const serverValues = buildInitialValues(source.fields, serverConfig?.config)
const formValues = { ...serverValues, ...dirty } const formValues = { ...serverValues, ...dirty }
function isCredentialField(field: ConfigFieldDef): boolean { function isCredentialField(field: ConfigFieldDef): boolean {
return !!(field.secret && field.required) return !!(field.secret && field.required)
} }
function getUserConfig(): Record<string, unknown> { function getUserConfig(): Record<string, unknown> {
const result: Record<string, unknown> = {} const result: Record<string, unknown> = {}
for (const [name, value] of Object.entries(formValues)) { for (const [name, value] of Object.entries(formValues)) {
const field = source.fields[name] const field = source.fields[name]
if (field && !isCredentialField(field)) { if (field && !isCredentialField(field)) {
result[name] = value result[name] = value
} }
} }
return result return result
} }
function getCredentialFields(): Record<string, unknown> { function getCredentialFields(): Record<string, unknown> {
const creds: Record<string, unknown> = {} const creds: Record<string, unknown> = {}
for (const [name, value] of Object.entries(formValues)) { for (const [name, value] of Object.entries(formValues)) {
const field = source.fields[name] const field = source.fields[name]
if (field && isCredentialField(field)) { if (field && isCredentialField(field)) {
creds[name] = value creds[name] = value
} }
} }
return creds return creds
} }
function invalidate() { function invalidate() {
queryClient.invalidateQueries({ queryKey: ["sourceConfig", source.id] }) queryClient.invalidateQueries({ queryKey: ["sourceConfig", source.id] })
queryClient.invalidateQueries({ queryKey: ["configs"] }) queryClient.invalidateQueries({ queryKey: ["configs"] })
onUpdate() onUpdate()
} }
const saveMutation = useMutation({ const saveMutation = useMutation({
mutationFn: async () => { mutationFn: async () => {
const promises: Promise<void>[] = [ const promises: Promise<void>[] = [
replaceSource(source.id, { enabled, config: getUserConfig() }), replaceSource(source.id, { enabled, config: getUserConfig() }),
] ]
const credentialFields = getCredentialFields() const credentialFields = getCredentialFields()
const hasCredentials = Object.values(credentialFields).some( const hasCredentials = Object.values(credentialFields).some(
(v) => typeof v === "string" && v.length > 0, (v) => typeof v === "string" && v.length > 0,
) )
if (hasCredentials) { if (hasCredentials) {
promises.push( promises.push(updateProviderConfig(source.id, { credentials: credentialFields }))
updateProviderConfig(source.id, { credentials: credentialFields }), }
)
}
await Promise.all(promises) await Promise.all(promises)
}, },
onSuccess() { onSuccess() {
setDirty({}) setDirty({})
invalidate() invalidate()
toast.success("Configuration saved") toast.success("Configuration saved")
}, },
onError(err) { onError(err) {
toast.error(err.message) toast.error(err.message)
}, },
}) })
const toggleMutation = useMutation({ const toggleMutation = useMutation({
mutationFn: (checked: boolean) => mutationFn: (checked: boolean) =>
replaceSource(source.id, { enabled: checked, config: getUserConfig() }), replaceSource(source.id, { enabled: checked, config: getUserConfig() }),
onSuccess(_data, checked) { onSuccess(_data, checked) {
invalidate() invalidate()
toast.success(`Source ${checked ? "enabled" : "disabled"}`) toast.success(`Source ${checked ? "enabled" : "disabled"}`)
}, },
onError(err) { onError(err) {
toast.error(err.message) toast.error(err.message)
}, },
}) })
const deleteMutation = useMutation({ const deleteMutation = useMutation({
mutationFn: () => replaceSource(source.id, { enabled: false, config: {} }), mutationFn: () => replaceSource(source.id, { enabled: false, config: {} }),
onSuccess() { onSuccess() {
setDirty({}) setDirty({})
invalidate() invalidate()
toast.success("Configuration deleted") toast.success("Configuration deleted")
}, },
onError(err) { onError(err) {
toast.error(err.message) toast.error(err.message)
}, },
}) })
function handleFieldChange(fieldName: string, value: unknown) { function handleFieldChange(fieldName: string, value: unknown) {
setDirty((prev) => ({ ...prev, [fieldName]: value })) setDirty((prev) => ({ ...prev, [fieldName]: value }))
} }
const fieldEntries = Object.entries(source.fields) const fieldEntries = Object.entries(source.fields)
const hasFields = fieldEntries.length > 0 const hasFields = fieldEntries.length > 0
const busy = saveMutation.isPending || toggleMutation.isPending || deleteMutation.isPending const busy = saveMutation.isPending || toggleMutation.isPending || deleteMutation.isPending
const requiredFields = fieldEntries.filter(([, f]) => f.required) const requiredFields = fieldEntries.filter(([, f]) => f.required)
const optionalFields = fieldEntries.filter(([, f]) => !f.required) const optionalFields = fieldEntries.filter(([, f]) => !f.required)
if (isLoading) { if (isLoading) {
return ( return (
<div className="flex items-center justify-center py-12"> <div className="flex items-center justify-center py-12">
<Loader2 className="size-5 animate-spin text-muted-foreground" /> <Loader2 className="size-5 animate-spin text-muted-foreground" />
</div> </div>
) )
} }
return ( return (
<div className="mx-auto max-w-xl space-y-6"> <div className="mx-auto max-w-xl space-y-6">
{/* Header */} {/* Header */}
<div className="flex items-center justify-between gap-4"> <div className="flex items-center justify-between gap-4">
<div className="space-y-1"> <div className="space-y-1">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<h2 className="text-lg font-semibold tracking-tight">{source.name}</h2> <h2 className="text-lg font-semibold tracking-tight">{source.name}</h2>
{source.alwaysEnabled ? ( {source.alwaysEnabled ? (
<Badge variant="secondary">Always on</Badge> <Badge variant="secondary">Always on</Badge>
) : enabled ? ( ) : enabled ? (
<Badge className="bg-primary/10 text-primary">Enabled</Badge> <Badge className="bg-primary/10 text-primary">Enabled</Badge>
) : ( ) : (
<Badge variant="outline">Disabled</Badge> <Badge variant="outline">Disabled</Badge>
)} )}
</div>
<p className="text-sm text-muted-foreground">{source.description}</p>
</div>
{!source.alwaysEnabled && (
<Switch
checked={enabled}
onCheckedChange={(checked) => toggleMutation.mutate(checked)}
disabled={busy}
/>
)}
</div>
</div> {/* Config form */}
<p className="text-sm text-muted-foreground">{source.description}</p> {hasFields && !source.alwaysEnabled && (
</div> <>
{!source.alwaysEnabled && ( {/* Required fields */}
<Switch {requiredFields.length > 0 && (
checked={enabled} <Card className="-mx-4">
onCheckedChange={(checked) => toggleMutation.mutate(checked)} <CardHeader className="pb-4">
disabled={busy} <CardTitle className="text-sm">Credentials</CardTitle>
/> <CardDescription>Required fields to connect this source.</CardDescription>
)} </CardHeader>
</div> <CardContent className="space-y-4">
{requiredFields.map(([name, field]) => (
<FieldInput
key={name}
name={name}
field={field}
value={formValues[name]}
onChange={(v) => handleFieldChange(name, v)}
disabled={busy}
/>
))}
</CardContent>
</Card>
)}
{/* Config form */} {/* Optional fields */}
{hasFields && !source.alwaysEnabled && ( {optionalFields.length > 0 && (
<> <Card className="-mx-4">
{/* Required fields */} <CardHeader className="pb-4">
{requiredFields.length > 0 && ( <CardTitle className="text-sm">Options</CardTitle>
<Card className="-mx-4"> <CardDescription>Optional configuration for this source.</CardDescription>
<CardHeader className="pb-4"> </CardHeader>
<CardTitle className="text-sm">Credentials</CardTitle> <CardContent>
<CardDescription>Required fields to connect this source.</CardDescription> <div className={`grid gap-4 ${optionalFields.length > 1 ? "grid-cols-2" : ""}`}>
</CardHeader> {optionalFields.map(([name, field]) => (
<CardContent className="space-y-4"> <FieldInput
{requiredFields.map(([name, field]) => ( key={name}
<FieldInput name={name}
key={name} field={field}
name={name} value={formValues[name]}
field={field} onChange={(v) => handleFieldChange(name, v)}
value={formValues[name]} disabled={busy}
onChange={(v) => handleFieldChange(name, v)} />
disabled={busy} ))}
/> </div>
))} </CardContent>
</CardContent> </Card>
</Card> )}
)}
{/* Optional fields */} {/* Actions */}
{optionalFields.length > 0 && ( <div className="flex items-center justify-end gap-3">
<Card className="-mx-4"> {serverConfig && (
<CardHeader className="pb-4"> <Button
<CardTitle className="text-sm">Options</CardTitle> onClick={() => deleteMutation.mutate()}
<CardDescription>Optional configuration for this source.</CardDescription> disabled={busy}
</CardHeader> variant="outline"
<CardContent> className="text-destructive hover:text-destructive"
<div className={`grid gap-4 ${optionalFields.length > 1 ? "grid-cols-2" : ""}`}> >
{optionalFields.map(([name, field]) => ( {deleteMutation.isPending ? (
<FieldInput <Loader2 className="size-4 animate-spin" />
key={name} ) : (
name={name} <Trash2 className="size-4" />
field={field} )}
value={formValues[name]} {deleteMutation.isPending ? "Deleting…" : "Delete configuration"}
onChange={(v) => handleFieldChange(name, v)} </Button>
disabled={busy} )}
/> <Button onClick={() => saveMutation.mutate()} disabled={busy}>
))} {saveMutation.isPending && <Loader2 className="size-4 animate-spin" />}
</div> {saveMutation.isPending ? "Saving…" : "Save configuration"}
</CardContent> </Button>
</Card> </div>
)} </>
)}
{/* Actions */} {/* Always-on sources */}
<div className="flex items-center justify-end gap-3"> {source.alwaysEnabled && source.id !== "aelis.location" && (
{serverConfig && ( <>
<Button <Separator />
onClick={() => deleteMutation.mutate()} <p className="text-sm text-muted-foreground">
disabled={busy} This source is always enabled and requires no configuration.
variant="outline" </p>
className="text-destructive hover:text-destructive" </>
> )}
{deleteMutation.isPending ? (
<Loader2 className="size-4 animate-spin" />
) : (
<Trash2 className="size-4" />
)}
{deleteMutation.isPending ? "Deleting…" : "Delete configuration"}
</Button>
)}
<Button onClick={() => saveMutation.mutate()} disabled={busy}>
{saveMutation.isPending && <Loader2 className="size-4 animate-spin" />}
{saveMutation.isPending ? "Saving…" : "Save configuration"}
</Button>
</div>
</>
)}
{/* Always-on sources */} {source.id === "aelis.location" && <LocationCard />}
{source.alwaysEnabled && source.id !== "aelis.location" && ( </div>
<> )
<Separator />
<p className="text-sm text-muted-foreground">
This source is always enabled and requires no configuration.
</p>
</>
)}
{source.id === "aelis.location" && <LocationCard />}
</div>
)
} }
function LocationCard() { function LocationCard() {
const [lat, setLat] = useState("") const [lat, setLat] = useState("")
const [lng, setLng] = useState("") const [lng, setLng] = useState("")
const locationMutation = useMutation({ const locationMutation = useMutation({
mutationFn: (coords: { lat: number; lng: number }) => mutationFn: (coords: { lat: number; lng: number }) =>
pushLocation({ lat: coords.lat, lng: coords.lng, accuracy: 10 }), pushLocation({ lat: coords.lat, lng: coords.lng, accuracy: 10 }),
onSuccess() { onSuccess() {
toast.success("Location updated") toast.success("Location updated")
}, },
onError(err) { onError(err) {
toast.error(err.message) toast.error(err.message)
}, },
}) })
function handlePush() { function handlePush() {
const latNum = parseFloat(lat) const latNum = parseFloat(lat)
const lngNum = parseFloat(lng) const lngNum = parseFloat(lng)
if (isNaN(latNum) || isNaN(lngNum)) return if (isNaN(latNum) || isNaN(lngNum)) return
locationMutation.mutate({ lat: latNum, lng: lngNum }) locationMutation.mutate({ lat: latNum, lng: lngNum })
} }
function handleUseDevice() { function handleUseDevice() {
navigator.geolocation.getCurrentPosition( navigator.geolocation.getCurrentPosition(
(pos) => { (pos) => {
setLat(String(pos.coords.latitude)) setLat(String(pos.coords.latitude))
setLng(String(pos.coords.longitude)) setLng(String(pos.coords.longitude))
locationMutation.mutate({ locationMutation.mutate({
lat: pos.coords.latitude, lat: pos.coords.latitude,
lng: pos.coords.longitude, lng: pos.coords.longitude,
}) })
}, },
(err) => { (err) => {
locationMutation.reset() locationMutation.reset()
alert(`Geolocation error: ${err.message}`) alert(`Geolocation error: ${err.message}`)
}, },
) )
} }
return ( return (
<Card className="-mx-4"> <Card className="-mx-4">
<CardHeader className="pb-4"> <CardHeader className="pb-4">
<CardTitle className="text-sm">Push Location</CardTitle> <CardTitle className="text-sm">Push Location</CardTitle>
<CardDescription>Send a location update to the backend.</CardDescription> <CardDescription>Send a location update to the backend.</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="loc-lat" className="text-xs font-medium">Latitude</Label> <Label htmlFor="loc-lat" className="text-xs font-medium">
<Input Latitude
id="loc-lat" </Label>
type="number" <Input
step="any" id="loc-lat"
value={lat} type="number"
onChange={(e) => setLat(e.target.value)} step="any"
placeholder="51.5074" value={lat}
disabled={locationMutation.isPending} onChange={(e) => setLat(e.target.value)}
/> placeholder="51.5074"
</div> disabled={locationMutation.isPending}
<div className="space-y-2"> />
<Label htmlFor="loc-lng" className="text-xs font-medium">Longitude</Label> </div>
<Input <div className="space-y-2">
id="loc-lng" <Label htmlFor="loc-lng" className="text-xs font-medium">
type="number" Longitude
step="any" </Label>
value={lng} <Input
onChange={(e) => setLng(e.target.value)} id="loc-lng"
placeholder="-0.1278" type="number"
disabled={locationMutation.isPending} step="any"
/> value={lng}
</div> onChange={(e) => setLng(e.target.value)}
</div> placeholder="-0.1278"
disabled={locationMutation.isPending}
/>
</div>
</div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Button <Button
size="sm" size="sm"
variant="outline" variant="outline"
onClick={handleUseDevice} onClick={handleUseDevice}
disabled={locationMutation.isPending} disabled={locationMutation.isPending}
> >
<MapPin className="size-3.5" /> <MapPin className="size-3.5" />
Use device location Use device location
</Button> </Button>
<Button <Button
size="sm" size="sm"
onClick={handlePush} onClick={handlePush}
disabled={locationMutation.isPending || !lat || !lng} disabled={locationMutation.isPending || !lat || !lng}
> >
{locationMutation.isPending && <Loader2 className="size-3.5 animate-spin" />} {locationMutation.isPending && <Loader2 className="size-3.5 animate-spin" />}
Push Push
</Button> </Button>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
) )
} }
function FieldInput({ function FieldInput({
name, name,
field, field,
value, value,
onChange, onChange,
disabled, disabled,
}: { }: {
name: string name: string
field: ConfigFieldDef field: ConfigFieldDef
value: unknown value: unknown
onChange: (value: unknown) => void onChange: (value: unknown) => void
disabled?: boolean disabled?: boolean
}) { }) {
const labelContent = ( const labelContent = (
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<span>{field.label}</span> <span>{field.label}</span>
{field.required && <span className="text-destructive">*</span>} {field.required && <span className="text-destructive">*</span>}
{field.description && ( {field.description && (
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Info className="size-3 text-muted-foreground cursor-help" /> <Info className="size-3 text-muted-foreground cursor-help" />
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="top" className="max-w-xs text-xs"> <TooltipContent side="top" className="max-w-xs text-xs">
{field.description} {field.description}
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
)} )}
</div> </div>
) )
if (field.type === "select" && field.options) { if (field.type === "select" && field.options) {
return ( return (
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor={name} className="text-xs font-medium"> <Label htmlFor={name} className="text-xs font-medium">
{labelContent} {labelContent}
</Label> </Label>
<Select value={String(value ?? "")} onValueChange={onChange} disabled={disabled}> <Select value={String(value ?? "")} onValueChange={onChange} disabled={disabled}>
<SelectTrigger id={name}> <SelectTrigger id={name}>
<SelectValue placeholder={`Select ${field.label.toLowerCase()}`} /> <SelectValue placeholder={`Select ${field.label.toLowerCase()}`} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{field.options.map((opt) => ( {field.options.map((opt) => (
<SelectItem key={opt.value} value={opt.value}> <SelectItem key={opt.value} value={opt.value}>
{opt.label} {opt.label}
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
) )
} }
if (field.type === "number") { if (field.type === "multiselect" && field.options) {
return ( const selected = Array.isArray(value) ? (value as string[]) : []
<div className="space-y-2">
<Label htmlFor={name} className="text-xs font-medium">
{labelContent}
</Label>
<Input
id={name}
type="number"
value={value === undefined || value === null ? "" : String(value)}
onChange={(e) => {
const v = e.target.value
onChange(v === "" ? undefined : Number(v))
}}
placeholder={field.defaultValue !== undefined ? String(field.defaultValue) : undefined}
disabled={disabled}
/>
</div>
)
}
return ( function toggle(optValue: string) {
<div className="space-y-2"> const next = selected.includes(optValue)
<Label htmlFor={name} className="text-xs font-medium"> ? selected.filter((v) => v !== optValue)
{labelContent} : [...selected, optValue]
</Label> onChange(next)
<Input }
id={name}
type={field.secret ? "password" : "text"} return (
value={String(value ?? "")} <div className="space-y-2">
onChange={(e) => onChange(e.target.value)} <Label className="text-xs font-medium">{labelContent}</Label>
placeholder={field.defaultValue !== undefined ? String(field.defaultValue) : undefined} <div className="flex flex-wrap gap-1.5">
disabled={disabled} {field.options!.map((opt) => {
/> const isSelected = selected.includes(opt.value)
</div> return (
) <Badge
key={opt.value}
variant={isSelected ? "default" : "outline"}
className={`cursor-pointer select-none ${isSelected ? "" : "opacity-60 hover:opacity-100"}`}
onClick={() => !disabled && toggle(opt.value)}
>
{opt.label}
</Badge>
)
})}
</div>
</div>
)
}
if (field.type === "number") {
return (
<div className="space-y-2">
<Label htmlFor={name} className="text-xs font-medium">
{labelContent}
</Label>
<Input
id={name}
type="number"
value={value === undefined || value === null ? "" : String(value)}
onChange={(e) => {
const v = e.target.value
onChange(v === "" ? undefined : Number(v))
}}
placeholder={field.defaultValue !== undefined ? String(field.defaultValue) : undefined}
disabled={disabled}
/>
</div>
)
}
return (
<div className="space-y-2">
<Label htmlFor={name} className="text-xs font-medium">
{labelContent}
</Label>
<Input
id={name}
type={field.secret ? "password" : "text"}
value={String(value ?? "")}
onChange={(e) => onChange(e.target.value)}
placeholder={field.defaultValue !== undefined ? String(field.defaultValue) : undefined}
disabled={disabled}
/>
</div>
)
} }
function buildInitialValues( function buildInitialValues(
fields: Record<string, ConfigFieldDef>, fields: Record<string, ConfigFieldDef>,
saved: Record<string, unknown> | undefined, saved: Record<string, unknown> | undefined,
): Record<string, unknown> { ): Record<string, unknown> {
const values: Record<string, unknown> = {} const values: Record<string, unknown> = {}
for (const [name, field] of Object.entries(fields)) { for (const [name, field] of Object.entries(fields)) {
if (saved && name in saved) { if (saved && name in saved) {
values[name] = saved[name] values[name] = saved[name]
} else if (field.defaultValue !== undefined) { } else if (field.defaultValue !== undefined) {
values[name] = field.defaultValue values[name] = field.defaultValue
} else { } else if (field.type === "multiselect") {
values[name] = field.type === "number" ? undefined : "" values[name] = []
} } else {
} values[name] = field.type === "number" ? undefined : ""
return values }
}
return values
} }

View File

@@ -5,226 +5,219 @@ type Theme = "dark" | "light" | "system"
type ResolvedTheme = "dark" | "light" type ResolvedTheme = "dark" | "light"
type ThemeProviderProps = { type ThemeProviderProps = {
children: React.ReactNode children: React.ReactNode
defaultTheme?: Theme defaultTheme?: Theme
storageKey?: string storageKey?: string
disableTransitionOnChange?: boolean disableTransitionOnChange?: boolean
} }
type ThemeProviderState = { type ThemeProviderState = {
theme: Theme theme: Theme
setTheme: (theme: Theme) => void setTheme: (theme: Theme) => void
} }
const COLOR_SCHEME_QUERY = "(prefers-color-scheme: dark)" const COLOR_SCHEME_QUERY = "(prefers-color-scheme: dark)"
const THEME_VALUES: Theme[] = ["dark", "light", "system"] const THEME_VALUES: Theme[] = ["dark", "light", "system"]
const ThemeProviderContext = React.createContext< const ThemeProviderContext = React.createContext<ThemeProviderState | undefined>(undefined)
ThemeProviderState | undefined
>(undefined)
function isTheme(value: string | null): value is Theme { function isTheme(value: string | null): value is Theme {
if (value === null) { if (value === null) {
return false return false
} }
return THEME_VALUES.includes(value as Theme) return THEME_VALUES.includes(value as Theme)
} }
function getSystemTheme(): ResolvedTheme { function getSystemTheme(): ResolvedTheme {
if (window.matchMedia(COLOR_SCHEME_QUERY).matches) { if (window.matchMedia(COLOR_SCHEME_QUERY).matches) {
return "dark" return "dark"
} }
return "light" return "light"
} }
function disableTransitionsTemporarily() { function disableTransitionsTemporarily() {
const style = document.createElement("style") const style = document.createElement("style")
style.appendChild( style.appendChild(
document.createTextNode( document.createTextNode(
"*,*::before,*::after{-webkit-transition:none!important;transition:none!important}" "*,*::before,*::after{-webkit-transition:none!important;transition:none!important}",
) ),
) )
document.head.appendChild(style) document.head.appendChild(style)
return () => { return () => {
window.getComputedStyle(document.body) window.getComputedStyle(document.body)
requestAnimationFrame(() => { requestAnimationFrame(() => {
requestAnimationFrame(() => { requestAnimationFrame(() => {
style.remove() style.remove()
}) })
}) })
} }
} }
function isEditableTarget(target: EventTarget | null) { function isEditableTarget(target: EventTarget | null) {
if (!(target instanceof HTMLElement)) { if (!(target instanceof HTMLElement)) {
return false return false
} }
if (target.isContentEditable) { if (target.isContentEditable) {
return true return true
} }
const editableParent = target.closest( const editableParent = target.closest("input, textarea, select, [contenteditable='true']")
"input, textarea, select, [contenteditable='true']" if (editableParent) {
) return true
if (editableParent) { }
return true
}
return false return false
} }
export function ThemeProvider({ export function ThemeProvider({
children, children,
defaultTheme = "system", defaultTheme = "system",
storageKey = "theme", storageKey = "theme",
disableTransitionOnChange = true, disableTransitionOnChange = true,
...props ...props
}: ThemeProviderProps) { }: ThemeProviderProps) {
const [theme, setThemeState] = React.useState<Theme>(() => { const [theme, setThemeState] = React.useState<Theme>(() => {
const storedTheme = localStorage.getItem(storageKey) const storedTheme = localStorage.getItem(storageKey)
if (isTheme(storedTheme)) { if (isTheme(storedTheme)) {
return storedTheme return storedTheme
} }
return defaultTheme return defaultTheme
}) })
const setTheme = React.useCallback( const setTheme = React.useCallback(
(nextTheme: Theme) => { (nextTheme: Theme) => {
localStorage.setItem(storageKey, nextTheme) localStorage.setItem(storageKey, nextTheme)
setThemeState(nextTheme) setThemeState(nextTheme)
}, },
[storageKey] [storageKey],
) )
const applyTheme = React.useCallback( const applyTheme = React.useCallback(
(nextTheme: Theme) => { (nextTheme: Theme) => {
const root = document.documentElement const root = document.documentElement
const resolvedTheme = const resolvedTheme = nextTheme === "system" ? getSystemTheme() : nextTheme
nextTheme === "system" ? getSystemTheme() : nextTheme const restoreTransitions = disableTransitionOnChange ? disableTransitionsTemporarily() : null
const restoreTransitions = disableTransitionOnChange
? disableTransitionsTemporarily()
: null
root.classList.remove("light", "dark") root.classList.remove("light", "dark")
root.classList.add(resolvedTheme) root.classList.add(resolvedTheme)
if (restoreTransitions) { if (restoreTransitions) {
restoreTransitions() restoreTransitions()
} }
}, },
[disableTransitionOnChange] [disableTransitionOnChange],
) )
React.useEffect(() => { React.useEffect(() => {
applyTheme(theme) applyTheme(theme)
if (theme !== "system") { if (theme !== "system") {
return undefined return undefined
} }
const mediaQuery = window.matchMedia(COLOR_SCHEME_QUERY) const mediaQuery = window.matchMedia(COLOR_SCHEME_QUERY)
const handleChange = () => { const handleChange = () => {
applyTheme("system") applyTheme("system")
} }
mediaQuery.addEventListener("change", handleChange) mediaQuery.addEventListener("change", handleChange)
return () => { return () => {
mediaQuery.removeEventListener("change", handleChange) mediaQuery.removeEventListener("change", handleChange)
} }
}, [theme, applyTheme]) }, [theme, applyTheme])
React.useEffect(() => { React.useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => { const handleKeyDown = (event: KeyboardEvent) => {
if (event.repeat) { if (event.repeat) {
return return
} }
if (event.metaKey || event.ctrlKey || event.altKey) { if (event.metaKey || event.ctrlKey || event.altKey) {
return return
} }
if (isEditableTarget(event.target)) { if (isEditableTarget(event.target)) {
return return
} }
if (event.key.toLowerCase() !== "d") { if (event.key.toLowerCase() !== "d") {
return return
} }
setThemeState((currentTheme) => { setThemeState((currentTheme) => {
const nextTheme = const nextTheme =
currentTheme === "dark" currentTheme === "dark"
? "light" ? "light"
: currentTheme === "light" : currentTheme === "light"
? "dark" ? "dark"
: getSystemTheme() === "dark" : getSystemTheme() === "dark"
? "light" ? "light"
: "dark" : "dark"
localStorage.setItem(storageKey, nextTheme) localStorage.setItem(storageKey, nextTheme)
return nextTheme return nextTheme
}) })
} }
window.addEventListener("keydown", handleKeyDown) window.addEventListener("keydown", handleKeyDown)
return () => { return () => {
window.removeEventListener("keydown", handleKeyDown) window.removeEventListener("keydown", handleKeyDown)
} }
}, [storageKey]) }, [storageKey])
React.useEffect(() => { React.useEffect(() => {
const handleStorageChange = (event: StorageEvent) => { const handleStorageChange = (event: StorageEvent) => {
if (event.storageArea !== localStorage) { if (event.storageArea !== localStorage) {
return return
} }
if (event.key !== storageKey) { if (event.key !== storageKey) {
return return
} }
if (isTheme(event.newValue)) { if (isTheme(event.newValue)) {
setThemeState(event.newValue) setThemeState(event.newValue)
return return
} }
setThemeState(defaultTheme) setThemeState(defaultTheme)
} }
window.addEventListener("storage", handleStorageChange) window.addEventListener("storage", handleStorageChange)
return () => { return () => {
window.removeEventListener("storage", handleStorageChange) window.removeEventListener("storage", handleStorageChange)
} }
}, [defaultTheme, storageKey]) }, [defaultTheme, storageKey])
const value = React.useMemo( const value = React.useMemo(
() => ({ () => ({
theme, theme,
setTheme, setTheme,
}), }),
[theme, setTheme] [theme, setTheme],
) )
return ( return (
<ThemeProviderContext.Provider {...props} value={value}> <ThemeProviderContext.Provider {...props} value={value}>
{children} {children}
</ThemeProviderContext.Provider> </ThemeProviderContext.Provider>
) )
} }
export const useTheme = () => { export const useTheme = () => {
const context = React.useContext(ThemeProviderContext) const context = React.useContext(ThemeProviderContext)
if (context === undefined) { if (context === undefined) {
throw new Error("useTheme must be used within a ThemeProvider") throw new Error("useTheme must be used within a ThemeProvider")
} }
return context return context
} }

View File

@@ -1,84 +1,84 @@
"use client" "use client"
import * as React from "react" import { ChevronDownIcon, ChevronUpIcon } from "lucide-react"
import { Accordion as AccordionPrimitive } from "radix-ui" import { Accordion as AccordionPrimitive } from "radix-ui"
import * as React from "react"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { ChevronDownIcon, ChevronUpIcon } from "lucide-react"
function Accordion({ function Accordion({ className, ...props }: React.ComponentProps<typeof AccordionPrimitive.Root>) {
className, return (
...props <AccordionPrimitive.Root
}: React.ComponentProps<typeof AccordionPrimitive.Root>) { data-slot="accordion"
return ( className={cn("flex w-full flex-col overflow-hidden rounded-md border", className)}
<AccordionPrimitive.Root {...props}
data-slot="accordion" />
className={cn( )
"flex w-full flex-col overflow-hidden rounded-md border",
className
)}
{...props}
/>
)
} }
function AccordionItem({ function AccordionItem({
className, className,
...props ...props
}: React.ComponentProps<typeof AccordionPrimitive.Item>) { }: React.ComponentProps<typeof AccordionPrimitive.Item>) {
return ( return (
<AccordionPrimitive.Item <AccordionPrimitive.Item
data-slot="accordion-item" data-slot="accordion-item"
className={cn("not-last:border-b data-open:bg-muted/50", className)} className={cn("not-last:border-b data-open:bg-muted/50", className)}
{...props} {...props}
/> />
) )
} }
function AccordionTrigger({ function AccordionTrigger({
className, className,
children, children,
...props ...props
}: React.ComponentProps<typeof AccordionPrimitive.Trigger>) { }: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
return ( return (
<AccordionPrimitive.Header className="flex"> <AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger <AccordionPrimitive.Trigger
data-slot="accordion-trigger" data-slot="accordion-trigger"
className={cn( className={cn(
"group/accordion-trigger relative flex flex-1 items-start justify-between gap-6 border border-transparent p-2 text-left text-xs/relaxed font-medium transition-all outline-none hover:underline disabled:pointer-events-none disabled:opacity-50 **:data-[slot=accordion-trigger-icon]:ml-auto **:data-[slot=accordion-trigger-icon]:size-4 **:data-[slot=accordion-trigger-icon]:text-muted-foreground", "group/accordion-trigger relative flex flex-1 items-start justify-between gap-6 border border-transparent p-2 text-left text-xs/relaxed font-medium transition-all outline-none hover:underline disabled:pointer-events-none disabled:opacity-50 **:data-[slot=accordion-trigger-icon]:ml-auto **:data-[slot=accordion-trigger-icon]:size-4 **:data-[slot=accordion-trigger-icon]:text-muted-foreground",
className className,
)} )}
{...props} {...props}
> >
{children} {children}
<ChevronDownIcon data-slot="accordion-trigger-icon" className="pointer-events-none shrink-0 group-aria-expanded/accordion-trigger:hidden" /> <ChevronDownIcon
<ChevronUpIcon data-slot="accordion-trigger-icon" className="pointer-events-none hidden shrink-0 group-aria-expanded/accordion-trigger:inline" /> data-slot="accordion-trigger-icon"
</AccordionPrimitive.Trigger> className="pointer-events-none shrink-0 group-aria-expanded/accordion-trigger:hidden"
</AccordionPrimitive.Header> />
) <ChevronUpIcon
data-slot="accordion-trigger-icon"
className="pointer-events-none hidden shrink-0 group-aria-expanded/accordion-trigger:inline"
/>
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
)
} }
function AccordionContent({ function AccordionContent({
className, className,
children, children,
...props ...props
}: React.ComponentProps<typeof AccordionPrimitive.Content>) { }: React.ComponentProps<typeof AccordionPrimitive.Content>) {
return ( return (
<AccordionPrimitive.Content <AccordionPrimitive.Content
data-slot="accordion-content" data-slot="accordion-content"
className="overflow-hidden px-2 text-xs/relaxed data-open:animate-accordion-down data-closed:animate-accordion-up" className="overflow-hidden px-2 text-xs/relaxed data-open:animate-accordion-down data-closed:animate-accordion-up"
{...props} {...props}
> >
<div <div
className={cn( className={cn(
"h-(--radix-accordion-content-height) pt-0 pb-4 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground [&_p:not(:last-child)]:mb-4", "h-(--radix-accordion-content-height) pt-0 pb-4 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground [&_p:not(:last-child)]:mb-4",
className className,
)} )}
> >
{children} {children}
</div> </div>
</AccordionPrimitive.Content> </AccordionPrimitive.Content>
) )
} }
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent } export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }

View File

@@ -1,76 +1,73 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority" import { cva, type VariantProps } from "class-variance-authority"
import * as React from "react"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
const alertVariants = cva( const alertVariants = cva(
"group/alert relative grid w-full gap-0.5 rounded-lg border px-2 py-1.5 text-left text-xs/relaxed has-data-[slot=alert-action]:relative has-data-[slot=alert-action]:pr-18 has-[>svg]:grid-cols-[auto_1fr] has-[>svg]:gap-x-1.5 *:[svg]:row-span-2 *:[svg]:translate-y-0.5 *:[svg]:text-current *:[svg:not([class*='size-'])]:size-3.5", "group/alert relative grid w-full gap-0.5 rounded-lg border px-2 py-1.5 text-left text-xs/relaxed has-data-[slot=alert-action]:relative has-data-[slot=alert-action]:pr-18 has-[>svg]:grid-cols-[auto_1fr] has-[>svg]:gap-x-1.5 *:[svg]:row-span-2 *:[svg]:translate-y-0.5 *:[svg]:text-current *:[svg:not([class*='size-'])]:size-3.5",
{ {
variants: { variants: {
variant: { variant: {
default: "bg-card text-card-foreground", default: "bg-card text-card-foreground",
destructive: destructive:
"bg-card text-destructive *:data-[slot=alert-description]:text-destructive/90 *:[svg]:text-current", "bg-card text-destructive *:data-[slot=alert-description]:text-destructive/90 *:[svg]:text-current",
}, },
}, },
defaultVariants: { defaultVariants: {
variant: "default", variant: "default",
}, },
} },
) )
function Alert({ function Alert({
className, className,
variant, variant,
...props ...props
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) { }: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
return ( return (
<div <div
data-slot="alert" data-slot="alert"
role="alert" role="alert"
className={cn(alertVariants({ variant }), className)} className={cn(alertVariants({ variant }), className)}
{...props} {...props}
/> />
) )
} }
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) { function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
return ( return (
<div <div
data-slot="alert-title" data-slot="alert-title"
className={cn( className={cn(
"font-medium group-has-[>svg]/alert:col-start-2 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground", "font-medium group-has-[>svg]/alert:col-start-2 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground",
className className,
)} )}
{...props} {...props}
/> />
) )
} }
function AlertDescription({ function AlertDescription({ className, ...props }: React.ComponentProps<"div">) {
className, return (
...props <div
}: React.ComponentProps<"div">) { data-slot="alert-description"
return ( className={cn(
<div "text-xs/relaxed text-balance text-muted-foreground md:text-pretty [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground [&_p:not(:last-child)]:mb-4",
data-slot="alert-description" className,
className={cn( )}
"text-xs/relaxed text-balance text-muted-foreground md:text-pretty [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground [&_p:not(:last-child)]:mb-4", {...props}
className />
)} )
{...props}
/>
)
} }
function AlertAction({ className, ...props }: React.ComponentProps<"div">) { function AlertAction({ className, ...props }: React.ComponentProps<"div">) {
return ( return (
<div <div
data-slot="alert-action" data-slot="alert-action"
className={cn("absolute top-1.5 right-2", className)} className={cn("absolute top-1.5 right-2", className)}
{...props} {...props}
/> />
) )
} }
export { Alert, AlertTitle, AlertDescription, AlertAction } export { Alert, AlertTitle, AlertDescription, AlertAction }

View File

@@ -1,49 +1,46 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority" import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui" import { Slot } from "radix-ui"
import * as React from "react"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
const badgeVariants = cva( const badgeVariants = cva(
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border border-transparent px-2 py-0.5 text-[0.625rem] font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-2.5!", "group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border border-transparent px-2 py-0.5 text-[0.625rem] font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-2.5!",
{ {
variants: { variants: {
variant: { variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80", default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
secondary: secondary: "bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80", destructive:
destructive: "bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20", outline:
outline: "border-border bg-input/20 text-foreground dark:bg-input/30 [a]:hover:bg-muted [a]:hover:text-muted-foreground",
"border-border bg-input/20 text-foreground dark:bg-input/30 [a]:hover:bg-muted [a]:hover:text-muted-foreground", ghost: "hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
ghost: link: "text-primary underline-offset-4 hover:underline",
"hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50", },
link: "text-primary underline-offset-4 hover:underline", },
}, defaultVariants: {
}, variant: "default",
defaultVariants: { },
variant: "default", },
},
}
) )
function Badge({ function Badge({
className, className,
variant = "default", variant = "default",
asChild = false, asChild = false,
...props ...props
}: React.ComponentProps<"span"> & }: React.ComponentProps<"span"> & VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
VariantProps<typeof badgeVariants> & { asChild?: boolean }) { const Comp = asChild ? Slot.Root : "span"
const Comp = asChild ? Slot.Root : "span"
return ( return (
<Comp <Comp
data-slot="badge" data-slot="badge"
data-variant={variant} data-variant={variant}
className={cn(badgeVariants({ variant }), className)} className={cn(badgeVariants({ variant }), className)}
{...props} {...props}
/> />
) )
} }
export { Badge, badgeVariants } export { Badge, badgeVariants }

View File

@@ -1,65 +1,65 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority" import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui" import { Slot } from "radix-ui"
import * as React from "react"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
const buttonVariants = cva( const buttonVariants = cva(
"group/button inline-flex shrink-0 items-center justify-center rounded-md border border-transparent bg-clip-padding text-xs/relaxed font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/30 active:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-2 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", "group/button inline-flex shrink-0 items-center justify-center rounded-md border border-transparent bg-clip-padding text-xs/relaxed font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/30 active:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-2 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{ {
variants: { variants: {
variant: { variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/80", default: "bg-primary text-primary-foreground hover:bg-primary/80",
outline: outline:
"border-border hover:bg-input/50 hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:bg-input/30", "border-border hover:bg-input/50 hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:bg-input/30",
secondary: secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground", "bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
ghost: ghost:
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50", "hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
destructive: destructive:
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40", "bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
link: "text-primary underline-offset-4 hover:underline", link: "text-primary underline-offset-4 hover:underline",
}, },
size: { size: {
default: default:
"h-7 gap-1 px-2 text-xs/relaxed has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5", "h-7 gap-1 px-2 text-xs/relaxed has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
xs: "h-5 gap-1 rounded-sm px-2 text-[0.625rem] has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-2.5", xs: "h-5 gap-1 rounded-sm px-2 text-[0.625rem] has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-2.5",
sm: "h-6 gap-1 px-2 text-xs/relaxed has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3", sm: "h-6 gap-1 px-2 text-xs/relaxed has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
lg: "h-8 gap-1 px-2.5 text-xs/relaxed has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2 [&_svg:not([class*='size-'])]:size-4", lg: "h-8 gap-1 px-2.5 text-xs/relaxed has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2 [&_svg:not([class*='size-'])]:size-4",
icon: "size-7 [&_svg:not([class*='size-'])]:size-3.5", icon: "size-7 [&_svg:not([class*='size-'])]:size-3.5",
"icon-xs": "size-5 rounded-sm [&_svg:not([class*='size-'])]:size-2.5", "icon-xs": "size-5 rounded-sm [&_svg:not([class*='size-'])]:size-2.5",
"icon-sm": "size-6 [&_svg:not([class*='size-'])]:size-3", "icon-sm": "size-6 [&_svg:not([class*='size-'])]:size-3",
"icon-lg": "size-8 [&_svg:not([class*='size-'])]:size-4", "icon-lg": "size-8 [&_svg:not([class*='size-'])]:size-4",
}, },
}, },
defaultVariants: { defaultVariants: {
variant: "default", variant: "default",
size: "default", size: "default",
}, },
} },
) )
function Button({ function Button({
className, className,
variant = "default", variant = "default",
size = "default", size = "default",
asChild = false, asChild = false,
...props ...props
}: React.ComponentProps<"button"> & }: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & { VariantProps<typeof buttonVariants> & {
asChild?: boolean asChild?: boolean
}) { }) {
const Comp = asChild ? Slot.Root : "button" const Comp = asChild ? Slot.Root : "button"
return ( return (
<Comp <Comp
data-slot="button" data-slot="button"
data-variant={variant} data-variant={variant}
data-size={size} data-size={size}
className={cn(buttonVariants({ variant, size, className }))} className={cn(buttonVariants({ variant, size, className }))}
{...props} {...props}
/> />
) )
} }
export { Button, buttonVariants } export { Button, buttonVariants }

View File

@@ -3,98 +3,81 @@ import * as React from "react"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
function Card({ function Card({
className, className,
size = "default", size = "default",
...props ...props
}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) { }: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
return ( return (
<div <div
data-slot="card" data-slot="card"
data-size={size} data-size={size}
className={cn( className={cn(
"group/card flex flex-col gap-4 overflow-hidden rounded-lg bg-card py-4 text-xs/relaxed text-card-foreground ring-1 ring-foreground/10 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 *:[img:first-child]:rounded-t-lg *:[img:last-child]:rounded-b-lg", "group/card flex flex-col gap-4 overflow-hidden rounded-lg bg-card py-4 text-xs/relaxed text-card-foreground ring-1 ring-foreground/10 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 *:[img:first-child]:rounded-t-lg *:[img:last-child]:rounded-b-lg",
className className,
)} )}
{...props} {...props}
/> />
) )
} }
function CardHeader({ className, ...props }: React.ComponentProps<"div">) { function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return ( return (
<div <div
data-slot="card-header" data-slot="card-header"
className={cn( className={cn(
"group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-lg px-4 group-data-[size=sm]/card:px-3 has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-4 group-data-[size=sm]/card:[.border-b]:pb-3", "group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-lg px-4 group-data-[size=sm]/card:px-3 has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-4 group-data-[size=sm]/card:[.border-b]:pb-3",
className className,
)} )}
{...props} {...props}
/> />
) )
} }
function CardTitle({ className, ...props }: React.ComponentProps<"div">) { function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return ( return <div data-slot="card-title" className={cn("text-sm font-medium", className)} {...props} />
<div
data-slot="card-title"
className={cn("text-sm font-medium", className)}
{...props}
/>
)
} }
function CardDescription({ className, ...props }: React.ComponentProps<"div">) { function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return ( return (
<div <div
data-slot="card-description" data-slot="card-description"
className={cn("text-xs/relaxed text-muted-foreground", className)} className={cn("text-xs/relaxed text-muted-foreground", className)}
{...props} {...props}
/> />
) )
} }
function CardAction({ className, ...props }: React.ComponentProps<"div">) { function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return ( return (
<div <div
data-slot="card-action" data-slot="card-action"
className={cn( className={cn("col-start-2 row-span-2 row-start-1 self-start justify-self-end", className)}
"col-start-2 row-span-2 row-start-1 self-start justify-self-end", {...props}
className />
)} )
{...props}
/>
)
} }
function CardContent({ className, ...props }: React.ComponentProps<"div">) { function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return ( return (
<div <div
data-slot="card-content" data-slot="card-content"
className={cn("px-4 group-data-[size=sm]/card:px-3", className)} className={cn("px-4 group-data-[size=sm]/card:px-3", className)}
{...props} {...props}
/> />
) )
} }
function CardFooter({ className, ...props }: React.ComponentProps<"div">) { function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return ( return (
<div <div
data-slot="card-footer" data-slot="card-footer"
className={cn( className={cn(
"flex items-center rounded-b-lg px-4 group-data-[size=sm]/card:px-3 [.border-t]:pt-4 group-data-[size=sm]/card:[.border-t]:pt-3", "flex items-center rounded-b-lg px-4 group-data-[size=sm]/card:px-3 [.border-t]:pt-4 group-data-[size=sm]/card:[.border-t]:pt-3",
className className,
)} )}
{...props} {...props}
/> />
) )
} }
export { export { Card, CardHeader, CardFooter, CardTitle, CardAction, CardDescription, CardContent }
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}

View File

@@ -2,32 +2,20 @@
import { Collapsible as CollapsiblePrimitive } from "radix-ui" import { Collapsible as CollapsiblePrimitive } from "radix-ui"
function Collapsible({ function Collapsible({ ...props }: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
...props return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
} }
function CollapsibleTrigger({ function CollapsibleTrigger({
...props ...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) { }: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
return ( return <CollapsiblePrimitive.CollapsibleTrigger data-slot="collapsible-trigger" {...props} />
<CollapsiblePrimitive.CollapsibleTrigger
data-slot="collapsible-trigger"
{...props}
/>
)
} }
function CollapsibleContent({ function CollapsibleContent({
...props ...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) { }: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
return ( return <CollapsiblePrimitive.CollapsibleContent data-slot="collapsible-content" {...props} />
<CollapsiblePrimitive.CollapsibleContent
data-slot="collapsible-content"
{...props}
/>
)
} }
export { Collapsible, CollapsibleTrigger, CollapsibleContent } export { Collapsible, CollapsibleTrigger, CollapsibleContent }

View File

@@ -3,17 +3,17 @@ import * as React from "react"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) { function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return ( return (
<input <input
type={type} type={type}
data-slot="input" data-slot="input"
className={cn( className={cn(
"h-7 w-full min-w-0 rounded-md border border-input bg-input/20 px-2 py-0.5 text-sm transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-xs/relaxed file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/30 disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-2 aria-invalid:ring-destructive/20 md:text-xs/relaxed dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40", "h-7 w-full min-w-0 rounded-md border border-input bg-input/20 px-2 py-0.5 text-sm transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-xs/relaxed file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/30 disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-2 aria-invalid:ring-destructive/20 md:text-xs/relaxed dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className className,
)} )}
{...props} {...props}
/> />
) )
} }
export { Input } export { Input }

View File

@@ -1,22 +1,19 @@
import * as React from "react"
import { Label as LabelPrimitive } from "radix-ui" import { Label as LabelPrimitive } from "radix-ui"
import * as React from "react"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
function Label({ function Label({ className, ...props }: React.ComponentProps<typeof LabelPrimitive.Root>) {
className, return (
...props <LabelPrimitive.Root
}: React.ComponentProps<typeof LabelPrimitive.Root>) { data-slot="label"
return ( className={cn(
<LabelPrimitive.Root "flex items-center gap-2 text-xs/relaxed leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
data-slot="label" className,
className={cn( )}
"flex items-center gap-2 text-xs/relaxed leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50", {...props}
className />
)} )
{...props}
/>
)
} }
export { Label } export { Label }

View File

@@ -1,193 +1,183 @@
import * as React from "react" import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react"
import { Select as SelectPrimitive } from "radix-ui" import { Select as SelectPrimitive } from "radix-ui"
import * as React from "react"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react"
function Select({ function Select({ ...props }: React.ComponentProps<typeof SelectPrimitive.Root>) {
...props return <SelectPrimitive.Root data-slot="select" {...props} />
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />
} }
function SelectGroup({ function SelectGroup({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.Group>) {
className, return (
...props <SelectPrimitive.Group
}: React.ComponentProps<typeof SelectPrimitive.Group>) { data-slot="select-group"
return ( className={cn("scroll-my-1 p-1", className)}
<SelectPrimitive.Group {...props}
data-slot="select-group" />
className={cn("scroll-my-1 p-1", className)} )
{...props}
/>
)
} }
function SelectValue({ function SelectValue({ ...props }: React.ComponentProps<typeof SelectPrimitive.Value>) {
...props return <SelectPrimitive.Value data-slot="select-value" {...props} />
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />
} }
function SelectTrigger({ function SelectTrigger({
className, className,
size = "default", size = "default",
children, children,
...props ...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & { }: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default" size?: "sm" | "default"
}) { }) {
return ( return (
<SelectPrimitive.Trigger <SelectPrimitive.Trigger
data-slot="select-trigger" data-slot="select-trigger"
data-size={size} data-size={size}
className={cn( className={cn(
"flex w-fit items-center justify-between gap-1.5 rounded-md border border-input bg-input/20 px-2 py-1.5 text-xs/relaxed whitespace-nowrap transition-colors outline-none focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/30 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-2 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-7 data-[size=sm]:h-6 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5", "flex w-fit items-center justify-between gap-1.5 rounded-md border border-input bg-input/20 px-2 py-1.5 text-xs/relaxed whitespace-nowrap transition-colors outline-none focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/30 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-2 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-7 data-[size=sm]:h-6 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5",
className className,
)} )}
{...props} {...props}
> >
{children} {children}
<SelectPrimitive.Icon asChild> <SelectPrimitive.Icon asChild>
<ChevronDownIcon className="pointer-events-none size-3.5 text-muted-foreground" /> <ChevronDownIcon className="pointer-events-none size-3.5 text-muted-foreground" />
</SelectPrimitive.Icon> </SelectPrimitive.Icon>
</SelectPrimitive.Trigger> </SelectPrimitive.Trigger>
) )
} }
function SelectContent({ function SelectContent({
className, className,
children, children,
position = "item-aligned", position = "item-aligned",
align = "center", align = "center",
...props ...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) { }: React.ComponentProps<typeof SelectPrimitive.Content>) {
return ( return (
<SelectPrimitive.Portal> <SelectPrimitive.Portal>
<SelectPrimitive.Content <SelectPrimitive.Content
data-slot="select-content" data-slot="select-content"
data-align-trigger={position === "item-aligned"} data-align-trigger={position === "item-aligned"}
className={cn("relative z-50 max-h-(--radix-select-content-available-height) min-w-32 origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", position ==="popper"&&"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1", className )} className={cn(
position={position} "relative z-50 max-h-(--radix-select-content-available-height) min-w-32 origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
align={align} position === "popper" &&
{...props} "data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
> className,
<SelectScrollUpButton /> )}
<SelectPrimitive.Viewport position={position}
data-position={position} align={align}
className={cn( {...props}
"data-[position=popper]:h-(--radix-select-trigger-height) data-[position=popper]:w-full data-[position=popper]:min-w-(--radix-select-trigger-width)", >
position === "popper" && "" <SelectScrollUpButton />
)} <SelectPrimitive.Viewport
> data-position={position}
{children} className={cn(
</SelectPrimitive.Viewport> "data-[position=popper]:h-(--radix-select-trigger-height) data-[position=popper]:w-full data-[position=popper]:min-w-(--radix-select-trigger-width)",
<SelectScrollDownButton /> position === "popper" && "",
</SelectPrimitive.Content> )}
</SelectPrimitive.Portal> >
) {children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
)
} }
function SelectLabel({ function SelectLabel({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.Label>) {
className, return (
...props <SelectPrimitive.Label
}: React.ComponentProps<typeof SelectPrimitive.Label>) { data-slot="select-label"
return ( className={cn("px-2 py-1.5 text-xs text-muted-foreground", className)}
<SelectPrimitive.Label {...props}
data-slot="select-label" />
className={cn("px-2 py-1.5 text-xs text-muted-foreground", className)} )
{...props}
/>
)
} }
function SelectItem({ function SelectItem({
className, className,
children, children,
...props ...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) { }: React.ComponentProps<typeof SelectPrimitive.Item>) {
return ( return (
<SelectPrimitive.Item <SelectPrimitive.Item
data-slot="select-item" data-slot="select-item"
className={cn( className={cn(
"relative flex min-h-7 w-full cursor-default items-center gap-2 rounded-md px-2 py-1 text-xs/relaxed outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2", "relative flex min-h-7 w-full cursor-default items-center gap-2 rounded-md px-2 py-1 text-xs/relaxed outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className className,
)} )}
{...props} {...props}
> >
<span className="pointer-events-none absolute right-2 flex items-center justify-center"> <span className="pointer-events-none absolute right-2 flex items-center justify-center">
<SelectPrimitive.ItemIndicator> <SelectPrimitive.ItemIndicator>
<CheckIcon className="pointer-events-none" /> <CheckIcon className="pointer-events-none" />
</SelectPrimitive.ItemIndicator> </SelectPrimitive.ItemIndicator>
</span> </span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText> <SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item> </SelectPrimitive.Item>
) )
} }
function SelectSeparator({ function SelectSeparator({
className, className,
...props ...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) { }: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return ( return (
<SelectPrimitive.Separator <SelectPrimitive.Separator
data-slot="select-separator" data-slot="select-separator"
className={cn( className={cn("pointer-events-none -mx-1 my-1 h-px bg-border/50", className)}
"pointer-events-none -mx-1 my-1 h-px bg-border/50", {...props}
className />
)} )
{...props}
/>
)
} }
function SelectScrollUpButton({ function SelectScrollUpButton({
className, className,
...props ...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) { }: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return ( return (
<SelectPrimitive.ScrollUpButton <SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button" data-slot="select-scroll-up-button"
className={cn( className={cn(
"z-10 flex cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-3.5", "z-10 flex cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-3.5",
className className,
)} )}
{...props} {...props}
> >
<ChevronUpIcon <ChevronUpIcon />
/> </SelectPrimitive.ScrollUpButton>
</SelectPrimitive.ScrollUpButton> )
)
} }
function SelectScrollDownButton({ function SelectScrollDownButton({
className, className,
...props ...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) { }: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return ( return (
<SelectPrimitive.ScrollDownButton <SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button" data-slot="select-scroll-down-button"
className={cn( className={cn(
"z-10 flex cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-3.5", "z-10 flex cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-3.5",
className className,
)} )}
{...props} {...props}
> >
<ChevronDownIcon <ChevronDownIcon />
/> </SelectPrimitive.ScrollDownButton>
</SelectPrimitive.ScrollDownButton> )
)
} }
export { export {
Select, Select,
SelectContent, SelectContent,
SelectGroup, SelectGroup,
SelectItem, SelectItem,
SelectLabel, SelectLabel,
SelectScrollDownButton, SelectScrollDownButton,
SelectScrollUpButton, SelectScrollUpButton,
SelectSeparator, SelectSeparator,
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} }

View File

@@ -1,26 +1,26 @@
import * as React from "react"
import { Separator as SeparatorPrimitive } from "radix-ui" import { Separator as SeparatorPrimitive } from "radix-ui"
import * as React from "react"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
function Separator({ function Separator({
className, className,
orientation = "horizontal", orientation = "horizontal",
decorative = true, decorative = true,
...props ...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) { }: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return ( return (
<SeparatorPrimitive.Root <SeparatorPrimitive.Root
data-slot="separator" data-slot="separator"
decorative={decorative} decorative={decorative}
orientation={orientation} orientation={orientation}
className={cn( className={cn(
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch", "shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
className className,
)} )}
{...props} {...props}
/> />
) )
} }
export { Separator } export { Separator }

View File

@@ -1,142 +1,128 @@
import * as React from "react"
import { Dialog as SheetPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { XIcon } from "lucide-react" import { XIcon } from "lucide-react"
import { Dialog as SheetPrimitive } from "radix-ui"
import * as React from "react"
import { Button } from "@/components/ui/button"
import { cn } from "@/lib/utils"
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) { function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
return <SheetPrimitive.Root data-slot="sheet" {...props} /> return <SheetPrimitive.Root data-slot="sheet" {...props} />
} }
function SheetTrigger({ function SheetTrigger({ ...props }: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
...props return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
} }
function SheetClose({ function SheetClose({ ...props }: React.ComponentProps<typeof SheetPrimitive.Close>) {
...props return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
} }
function SheetPortal({ function SheetPortal({ ...props }: React.ComponentProps<typeof SheetPrimitive.Portal>) {
...props return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
} }
function SheetOverlay({ function SheetOverlay({
className, className,
...props ...props
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) { }: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
return ( return (
<SheetPrimitive.Overlay <SheetPrimitive.Overlay
data-slot="sheet-overlay" data-slot="sheet-overlay"
className={cn( className={cn(
"fixed inset-0 z-50 bg-black/80 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0", "fixed inset-0 z-50 bg-black/80 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
className className,
)} )}
{...props} {...props}
/> />
) )
} }
function SheetContent({ function SheetContent({
className, className,
children, children,
side = "right", side = "right",
showCloseButton = true, showCloseButton = true,
...props ...props
}: React.ComponentProps<typeof SheetPrimitive.Content> & { }: React.ComponentProps<typeof SheetPrimitive.Content> & {
side?: "top" | "right" | "bottom" | "left" side?: "top" | "right" | "bottom" | "left"
showCloseButton?: boolean showCloseButton?: boolean
}) { }) {
return ( return (
<SheetPortal> <SheetPortal>
<SheetOverlay /> <SheetOverlay />
<SheetPrimitive.Content <SheetPrimitive.Content
data-slot="sheet-content" data-slot="sheet-content"
data-side={side} data-side={side}
className={cn( className={cn(
"fixed z-50 flex flex-col bg-background bg-clip-padding text-xs/relaxed shadow-lg transition duration-200 ease-in-out data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-[side=bottom]:data-open:slide-in-from-bottom-10 data-[side=left]:data-open:slide-in-from-left-10 data-[side=right]:data-open:slide-in-from-right-10 data-[side=top]:data-open:slide-in-from-top-10 data-closed:animate-out data-closed:fade-out-0 data-[side=bottom]:data-closed:slide-out-to-bottom-10 data-[side=left]:data-closed:slide-out-to-left-10 data-[side=right]:data-closed:slide-out-to-right-10 data-[side=top]:data-closed:slide-out-to-top-10", "fixed z-50 flex flex-col bg-background bg-clip-padding text-xs/relaxed shadow-lg transition duration-200 ease-in-out data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-[side=bottom]:data-open:slide-in-from-bottom-10 data-[side=left]:data-open:slide-in-from-left-10 data-[side=right]:data-open:slide-in-from-right-10 data-[side=top]:data-open:slide-in-from-top-10 data-closed:animate-out data-closed:fade-out-0 data-[side=bottom]:data-closed:slide-out-to-bottom-10 data-[side=left]:data-closed:slide-out-to-left-10 data-[side=right]:data-closed:slide-out-to-right-10 data-[side=top]:data-closed:slide-out-to-top-10",
className className,
)} )}
{...props} {...props}
> >
{children} {children}
{showCloseButton && ( {showCloseButton && (
<SheetPrimitive.Close data-slot="sheet-close" asChild> <SheetPrimitive.Close data-slot="sheet-close" asChild>
<Button <Button variant="ghost" className="absolute top-4 right-4" size="icon-sm">
variant="ghost" <XIcon />
className="absolute top-4 right-4" <span className="sr-only">Close</span>
size="icon-sm" </Button>
> </SheetPrimitive.Close>
<XIcon )}
/> </SheetPrimitive.Content>
<span className="sr-only">Close</span> </SheetPortal>
</Button> )
</SheetPrimitive.Close>
)}
</SheetPrimitive.Content>
</SheetPortal>
)
} }
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) { function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
return ( return (
<div <div
data-slot="sheet-header" data-slot="sheet-header"
className={cn("flex flex-col gap-1.5 p-6", className)} className={cn("flex flex-col gap-1.5 p-6", className)}
{...props} {...props}
/> />
) )
} }
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) { function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
return ( return (
<div <div
data-slot="sheet-footer" data-slot="sheet-footer"
className={cn("mt-auto flex flex-col gap-2 p-6", className)} className={cn("mt-auto flex flex-col gap-2 p-6", className)}
{...props} {...props}
/> />
) )
} }
function SheetTitle({ function SheetTitle({ className, ...props }: React.ComponentProps<typeof SheetPrimitive.Title>) {
className, return (
...props <SheetPrimitive.Title
}: React.ComponentProps<typeof SheetPrimitive.Title>) { data-slot="sheet-title"
return ( className={cn("text-sm font-medium text-foreground", className)}
<SheetPrimitive.Title {...props}
data-slot="sheet-title" />
className={cn("text-sm font-medium text-foreground", className)} )
{...props}
/>
)
} }
function SheetDescription({ function SheetDescription({
className, className,
...props ...props
}: React.ComponentProps<typeof SheetPrimitive.Description>) { }: React.ComponentProps<typeof SheetPrimitive.Description>) {
return ( return (
<SheetPrimitive.Description <SheetPrimitive.Description
data-slot="sheet-description" data-slot="sheet-description"
className={cn("text-xs/relaxed text-muted-foreground", className)} className={cn("text-xs/relaxed text-muted-foreground", className)}
{...props} {...props}
/> />
) )
} }
export { export {
Sheet, Sheet,
SheetTrigger, SheetTrigger,
SheetClose, SheetClose,
SheetContent, SheetContent,
SheetHeader, SheetHeader,
SheetFooter, SheetFooter,
SheetTitle, SheetTitle,
SheetDescription, SheetDescription,
} }

File diff suppressed because it is too large Load Diff

View File

@@ -1,13 +1,13 @@
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
function Skeleton({ className, ...props }: React.ComponentProps<"div">) { function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return ( return (
<div <div
data-slot="skeleton" data-slot="skeleton"
className={cn("animate-pulse rounded-md bg-muted", className)} className={cn("animate-pulse rounded-md bg-muted", className)}
{...props} {...props}
/> />
) )
} }
export { Skeleton } export { Skeleton }

View File

@@ -1,49 +1,46 @@
"use client" "use client"
import { useTheme } from "@/components/theme-provider" import {
CircleCheckIcon,
InfoIcon,
TriangleAlertIcon,
OctagonXIcon,
Loader2Icon,
} from "lucide-react"
import { Toaster as Sonner, type ToasterProps } from "sonner" import { Toaster as Sonner, type ToasterProps } from "sonner"
import { CircleCheckIcon, InfoIcon, TriangleAlertIcon, OctagonXIcon, Loader2Icon } from "lucide-react"
import { useTheme } from "@/components/theme-provider"
const Toaster = ({ ...props }: ToasterProps) => { const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme() const { theme = "system" } = useTheme()
return ( return (
<Sonner <Sonner
theme={theme as ToasterProps["theme"]} theme={theme as ToasterProps["theme"]}
className="toaster group" className="toaster group"
icons={{ icons={{
success: ( success: <CircleCheckIcon className="size-4" />,
<CircleCheckIcon className="size-4" /> info: <InfoIcon className="size-4" />,
), warning: <TriangleAlertIcon className="size-4" />,
info: ( error: <OctagonXIcon className="size-4" />,
<InfoIcon className="size-4" /> loading: <Loader2Icon className="size-4 animate-spin" />,
), }}
warning: ( style={
<TriangleAlertIcon className="size-4" /> {
), "--normal-bg": "var(--popover)",
error: ( "--normal-text": "var(--popover-foreground)",
<OctagonXIcon className="size-4" /> "--normal-border": "var(--border)",
), "--border-radius": "var(--radius)",
loading: ( } as React.CSSProperties
<Loader2Icon className="size-4 animate-spin" /> }
), toastOptions={{
}} classNames: {
style={ toast: "cn-toast",
{ },
"--normal-bg": "var(--popover)", }}
"--normal-text": "var(--popover-foreground)", {...props}
"--normal-border": "var(--border)", />
"--border-radius": "var(--radius)", )
} as React.CSSProperties
}
toastOptions={{
classNames: {
toast: "cn-toast",
},
}}
{...props}
/>
)
} }
export { Toaster } export { Toaster }

View File

@@ -1,33 +1,33 @@
"use client" "use client"
import * as React from "react"
import { Switch as SwitchPrimitive } from "radix-ui" import { Switch as SwitchPrimitive } from "radix-ui"
import * as React from "react"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
function Switch({ function Switch({
className, className,
size = "default", size = "default",
...props ...props
}: React.ComponentProps<typeof SwitchPrimitive.Root> & { }: React.ComponentProps<typeof SwitchPrimitive.Root> & {
size?: "sm" | "default" size?: "sm" | "default"
}) { }) {
return ( return (
<SwitchPrimitive.Root <SwitchPrimitive.Root
data-slot="switch" data-slot="switch"
data-size={size} data-size={size}
className={cn( className={cn(
"peer group/switch relative inline-flex shrink-0 items-center rounded-full border border-transparent transition-all outline-none after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/30 aria-invalid:border-destructive aria-invalid:ring-2 aria-invalid:ring-destructive/20 data-[size=default]:h-[16.6px] data-[size=default]:w-[28px] data-[size=sm]:h-[14px] data-[size=sm]:w-[24px] dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:bg-primary data-unchecked:bg-input dark:data-unchecked:bg-input/80 data-disabled:cursor-not-allowed data-disabled:opacity-50", "peer group/switch relative inline-flex shrink-0 items-center rounded-full border border-transparent transition-all outline-none after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/30 aria-invalid:border-destructive aria-invalid:ring-2 aria-invalid:ring-destructive/20 data-[size=default]:h-[16.6px] data-[size=default]:w-[28px] data-[size=sm]:h-[14px] data-[size=sm]:w-[24px] dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:bg-primary data-unchecked:bg-input dark:data-unchecked:bg-input/80 data-disabled:cursor-not-allowed data-disabled:opacity-50",
className className,
)} )}
{...props} {...props}
> >
<SwitchPrimitive.Thumb <SwitchPrimitive.Thumb
data-slot="switch-thumb" data-slot="switch-thumb"
className="pointer-events-none block rounded-full bg-background ring-0 transition-transform group-data-[size=default]/switch:size-3.5 group-data-[size=sm]/switch:size-3 group-data-[size=default]/switch:data-checked:translate-x-[calc(100%-2px)] group-data-[size=sm]/switch:data-checked:translate-x-[calc(100%-2px)] dark:data-checked:bg-primary-foreground group-data-[size=default]/switch:data-unchecked:translate-x-0 group-data-[size=sm]/switch:data-unchecked:translate-x-0 dark:data-unchecked:bg-foreground" className="pointer-events-none block rounded-full bg-background ring-0 transition-transform group-data-[size=default]/switch:size-3.5 group-data-[size=sm]/switch:size-3 group-data-[size=default]/switch:data-checked:translate-x-[calc(100%-2px)] group-data-[size=sm]/switch:data-checked:translate-x-[calc(100%-2px)] dark:data-checked:bg-primary-foreground group-data-[size=default]/switch:data-unchecked:translate-x-0 group-data-[size=sm]/switch:data-unchecked:translate-x-0 dark:data-unchecked:bg-foreground"
/> />
</SwitchPrimitive.Root> </SwitchPrimitive.Root>
) )
} }
export { Switch } export { Switch }

View File

@@ -1,57 +1,53 @@
"use client" "use client"
import * as React from "react"
import { Tooltip as TooltipPrimitive } from "radix-ui" import { Tooltip as TooltipPrimitive } from "radix-ui"
import * as React from "react"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
function TooltipProvider({ function TooltipProvider({
delayDuration = 0, delayDuration = 0,
...props ...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) { }: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return ( return (
<TooltipPrimitive.Provider <TooltipPrimitive.Provider
data-slot="tooltip-provider" data-slot="tooltip-provider"
delayDuration={delayDuration} delayDuration={delayDuration}
{...props} {...props}
/> />
) )
} }
function Tooltip({ function Tooltip({ ...props }: React.ComponentProps<typeof TooltipPrimitive.Root>) {
...props return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
} }
function TooltipTrigger({ function TooltipTrigger({ ...props }: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
...props return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
} }
function TooltipContent({ function TooltipContent({
className, className,
sideOffset = 0, sideOffset = 0,
children, children,
...props ...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) { }: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return ( return (
<TooltipPrimitive.Portal> <TooltipPrimitive.Portal>
<TooltipPrimitive.Content <TooltipPrimitive.Content
data-slot="tooltip-content" data-slot="tooltip-content"
sideOffset={sideOffset} sideOffset={sideOffset}
className={cn( className={cn(
"z-50 inline-flex w-fit max-w-xs origin-(--radix-tooltip-content-transform-origin) items-center gap-1.5 rounded-md bg-foreground px-3 py-1.5 text-xs text-background has-data-[slot=kbd]:pr-1.5 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-sm data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", "z-50 inline-flex w-fit max-w-xs origin-(--radix-tooltip-content-transform-origin) items-center gap-1.5 rounded-md bg-foreground px-3 py-1.5 text-xs text-background has-data-[slot=kbd]:pr-1.5 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-sm data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className className,
)} )}
{...props} {...props}
> >
{children} {children}
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground" /> <TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground" />
</TooltipPrimitive.Content> </TooltipPrimitive.Content>
</TooltipPrimitive.Portal> </TooltipPrimitive.Portal>
) )
} }
export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger }

View File

@@ -3,17 +3,17 @@ import * as React from "react"
const MOBILE_BREAKPOINT = 768 const MOBILE_BREAKPOINT = 768
export function useIsMobile() { export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined) const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
React.useEffect(() => { React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`) const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
const onChange = () => { const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT) setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
} }
mql.addEventListener("change", onChange) mql.addEventListener("change", onChange)
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT) setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
return () => mql.removeEventListener("change", onChange) return () => mql.removeEventListener("change", onChange)
}, []) }, [])
return !!isMobile return !!isMobile
} }

View File

@@ -6,124 +6,124 @@
@custom-variant dark (&:is(.dark *)); @custom-variant dark (&:is(.dark *));
:root { :root {
--background: oklch(1 0 0); --background: oklch(1 0 0);
--foreground: oklch(0.145 0 0); --foreground: oklch(0.145 0 0);
--card: oklch(1 0 0); --card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0); --card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0); --popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0); --popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.511 0.096 186.391); --primary: oklch(0.511 0.096 186.391);
--primary-foreground: oklch(0.984 0.014 180.72); --primary-foreground: oklch(0.984 0.014 180.72);
--secondary: oklch(0.967 0.001 286.375); --secondary: oklch(0.967 0.001 286.375);
--secondary-foreground: oklch(0.21 0.006 285.885); --secondary-foreground: oklch(0.21 0.006 285.885);
--muted: oklch(0.97 0 0); --muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0); --muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0); --accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0); --accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325); --destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0); --border: oklch(0.922 0 0);
--input: oklch(0.922 0 0); --input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0); --ring: oklch(0.708 0 0);
--chart-1: oklch(0.855 0.138 181.071); --chart-1: oklch(0.855 0.138 181.071);
--chart-2: oklch(0.704 0.14 182.503); --chart-2: oklch(0.704 0.14 182.503);
--chart-3: oklch(0.6 0.118 184.704); --chart-3: oklch(0.6 0.118 184.704);
--chart-4: oklch(0.511 0.096 186.391); --chart-4: oklch(0.511 0.096 186.391);
--chart-5: oklch(0.437 0.078 188.216); --chart-5: oklch(0.437 0.078 188.216);
--radius: 0.625rem; --radius: 0.625rem;
--sidebar: oklch(0.985 0 0); --sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0); --sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.6 0.118 184.704); --sidebar-primary: oklch(0.6 0.118 184.704);
--sidebar-primary-foreground: oklch(0.984 0.014 180.72); --sidebar-primary-foreground: oklch(0.984 0.014 180.72);
--sidebar-accent: oklch(0.97 0 0); --sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0); --sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0); --sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0); --sidebar-ring: oklch(0.708 0 0);
} }
.dark { .dark {
--background: oklch(0.145 0 0); --background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0); --foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0); --card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0); --card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0); --popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0); --popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.437 0.078 188.216); --primary: oklch(0.437 0.078 188.216);
--primary-foreground: oklch(0.984 0.014 180.72); --primary-foreground: oklch(0.984 0.014 180.72);
--secondary: oklch(0.274 0.006 286.033); --secondary: oklch(0.274 0.006 286.033);
--secondary-foreground: oklch(0.985 0 0); --secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0); --muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0); --muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0); --accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0); --accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216); --destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%); --border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%); --input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0); --ring: oklch(0.556 0 0);
--chart-1: oklch(0.855 0.138 181.071); --chart-1: oklch(0.855 0.138 181.071);
--chart-2: oklch(0.704 0.14 182.503); --chart-2: oklch(0.704 0.14 182.503);
--chart-3: oklch(0.6 0.118 184.704); --chart-3: oklch(0.6 0.118 184.704);
--chart-4: oklch(0.511 0.096 186.391); --chart-4: oklch(0.511 0.096 186.391);
--chart-5: oklch(0.437 0.078 188.216); --chart-5: oklch(0.437 0.078 188.216);
--sidebar: oklch(0.205 0 0); --sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0); --sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.704 0.14 182.503); --sidebar-primary: oklch(0.704 0.14 182.503);
--sidebar-primary-foreground: oklch(0.277 0.046 192.524); --sidebar-primary-foreground: oklch(0.277 0.046 192.524);
--sidebar-accent: oklch(0.269 0 0); --sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0); --sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%); --sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0); --sidebar-ring: oklch(0.556 0 0);
} }
@theme inline { @theme inline {
--font-sans: 'Inter Variable', sans-serif; --font-sans: "Inter Variable", sans-serif;
--color-sidebar-ring: var(--sidebar-ring); --color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border); --color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground); --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent); --color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground); --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary); --color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground); --color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar); --color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5); --color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4); --color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3); --color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2); --color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1); --color-chart-1: var(--chart-1);
--color-ring: var(--ring); --color-ring: var(--ring);
--color-input: var(--input); --color-input: var(--input);
--color-border: var(--border); --color-border: var(--border);
--color-destructive: var(--destructive); --color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground); --color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent); --color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground); --color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted); --color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground); --color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary); --color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground); --color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary); --color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground); --color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover); --color-popover: var(--popover);
--color-card-foreground: var(--card-foreground); --color-card-foreground: var(--card-foreground);
--color-card: var(--card); --color-card: var(--card);
--color-foreground: var(--foreground); --color-foreground: var(--foreground);
--color-background: var(--background); --color-background: var(--background);
--radius-sm: calc(var(--radius) * 0.6); --radius-sm: calc(var(--radius) * 0.6);
--radius-md: calc(var(--radius) * 0.8); --radius-md: calc(var(--radius) * 0.8);
--radius-lg: var(--radius); --radius-lg: var(--radius);
--radius-xl: calc(var(--radius) * 1.4); --radius-xl: calc(var(--radius) * 1.4);
--radius-2xl: calc(var(--radius) * 1.8); --radius-2xl: calc(var(--radius) * 1.8);
--radius-3xl: calc(var(--radius) * 2.2); --radius-3xl: calc(var(--radius) * 2.2);
--radius-4xl: calc(var(--radius) * 2.6); --radius-4xl: calc(var(--radius) * 2.6);
} }
@layer base { @layer base {
* { * {
@apply border-border outline-ring/50; @apply border-border outline-ring/50;
} }
body { body {
@apply bg-background text-foreground select-none; @apply bg-background text-foreground select-none;
} }
html { html {
@apply font-sans; @apply font-sans;
} }
} }

View File

@@ -1,164 +1,217 @@
import { getServerUrl } from "./server-url" import { getServerUrl } from "./server-url"
function apiBase() { function apiBase() {
return `${getServerUrl()}/api/admin` return `${getServerUrl()}/api/admin`
} }
function serverBase() { function serverBase() {
return `${getServerUrl()}/api` return `${getServerUrl()}/api`
} }
export interface ConfigFieldDef { export interface ConfigFieldDef {
type: "string" | "number" | "select" type: "string" | "number" | "select" | "multiselect"
label: string label: string
required?: boolean required?: boolean
description?: string description?: string
secret?: boolean secret?: boolean
defaultValue?: string | number defaultValue?: string | number | string[]
options?: { label: string; value: string }[] options?: { label: string; value: string }[]
} }
export interface SourceDefinition { export interface SourceDefinition {
id: string id: string
name: string name: string
description: string description: string
alwaysEnabled?: boolean alwaysEnabled?: boolean
fields: Record<string, ConfigFieldDef> fields: Record<string, ConfigFieldDef>
} }
export interface SourceConfig { export interface SourceConfig {
sourceId: string sourceId: string
enabled: boolean enabled: boolean
config: Record<string, unknown> config: Record<string, unknown>
} }
const sourceDefinitions: SourceDefinition[] = [ const sourceDefinitions: SourceDefinition[] = [
{ {
id: "aelis.location", id: "aelis.location",
name: "Location", name: "Location",
description: "Device location provider. Always enabled as a dependency for other sources.", description: "Device location provider. Always enabled as a dependency for other sources.",
alwaysEnabled: true, alwaysEnabled: true,
fields: {}, fields: {},
}, },
{ {
id: "aelis.weather", id: "aelis.weather",
name: "WeatherKit", name: "WeatherKit",
description: "Apple WeatherKit weather data. Requires Apple Developer credentials.", description: "Apple WeatherKit weather data. Requires Apple Developer credentials.",
fields: { fields: {
privateKey: { type: "string", label: "Private Key", required: true, secret: true, description: "Apple WeatherKit private key (PEM format)" }, privateKey: {
keyId: { type: "string", label: "Key ID", required: true, secret: true }, type: "string",
teamId: { type: "string", label: "Team ID", required: true, secret: true }, label: "Private Key",
serviceId: { type: "string", label: "Service ID", required: true, secret: true }, required: true,
units: { type: "select", label: "Units", options: [{ label: "Metric", value: "metric" }, { label: "Imperial", value: "imperial" }], defaultValue: "metric" }, secret: true,
hourlyLimit: { type: "number", label: "Hourly Forecast Limit", defaultValue: 12, description: "Number of hourly forecasts to include" }, description: "Apple WeatherKit private key (PEM format)",
dailyLimit: { type: "number", label: "Daily Forecast Limit", defaultValue: 7, description: "Number of daily forecasts to include" }, },
}, keyId: { type: "string", label: "Key ID", required: true, secret: true },
}, teamId: { type: "string", label: "Team ID", required: true, secret: true },
serviceId: { type: "string", label: "Service ID", required: true, secret: true },
units: {
type: "select",
label: "Units",
options: [
{ label: "Metric", value: "metric" },
{ label: "Imperial", value: "imperial" },
],
defaultValue: "metric",
},
hourlyLimit: {
type: "number",
label: "Hourly Forecast Limit",
defaultValue: 12,
description: "Number of hourly forecasts to include",
},
dailyLimit: {
type: "number",
label: "Daily Forecast Limit",
defaultValue: 7,
description: "Number of daily forecasts to include",
},
},
},
{
id: "aelis.tfl",
name: "TfL",
description: "Transport for London tube line status alerts.",
fields: {
lines: {
type: "multiselect",
label: "Lines",
description: "Lines to monitor. Leave empty for all lines.",
defaultValue: [],
options: [
{ label: "Bakerloo", value: "bakerloo" },
{ label: "Central", value: "central" },
{ label: "Circle", value: "circle" },
{ label: "District", value: "district" },
{ label: "Hammersmith & City", value: "hammersmith-city" },
{ label: "Jubilee", value: "jubilee" },
{ label: "Metropolitan", value: "metropolitan" },
{ label: "Northern", value: "northern" },
{ label: "Piccadilly", value: "piccadilly" },
{ label: "Victoria", value: "victoria" },
{ label: "Waterloo & City", value: "waterloo-city" },
{ label: "Lioness", value: "lioness" },
{ label: "Mildmay", value: "mildmay" },
{ label: "Windrush", value: "windrush" },
{ label: "Weaver", value: "weaver" },
{ label: "Suffragette", value: "suffragette" },
{ label: "Liberty", value: "liberty" },
{ label: "Elizabeth", value: "elizabeth" },
],
},
},
},
] ]
export function fetchSources(): Promise<SourceDefinition[]> { export function fetchSources(): Promise<SourceDefinition[]> {
return Promise.resolve(sourceDefinitions) return Promise.resolve(sourceDefinitions)
} }
export async function fetchSourceConfig( export async function fetchSourceConfig(sourceId: string): Promise<SourceConfig | null> {
sourceId: string, const res = await fetch(`${serverBase()}/sources/${sourceId}`, {
): Promise<SourceConfig | null> { credentials: "include",
const res = await fetch(`${serverBase()}/sources/${sourceId}`, { })
credentials: "include", if (res.status === 404) return null
}) if (!res.ok) throw new Error(`Failed to fetch source config: ${res.status}`)
if (res.status === 404) return null const data = (await res.json()) as { enabled: boolean; config: Record<string, unknown> }
if (!res.ok) throw new Error(`Failed to fetch source config: ${res.status}`) return { sourceId, enabled: data.enabled, config: data.config }
const data = (await res.json()) as { enabled: boolean; config: Record<string, unknown> }
return { sourceId, enabled: data.enabled, config: data.config }
} }
export async function fetchConfigs(): Promise<SourceConfig[]> { export async function fetchConfigs(): Promise<SourceConfig[]> {
const results = await Promise.all( const results = await Promise.all(sourceDefinitions.map((s) => fetchSourceConfig(s.id)))
sourceDefinitions.map((s) => fetchSourceConfig(s.id)), return results.filter((c): c is SourceConfig => c !== null)
)
return results.filter((c): c is SourceConfig => c !== null)
} }
export async function replaceSource( export async function replaceSource(
sourceId: string, sourceId: string,
body: { enabled: boolean; config: unknown }, body: { enabled: boolean; config: unknown },
): Promise<void> { ): Promise<void> {
const res = await fetch(`${serverBase()}/sources/${sourceId}`, { const res = await fetch(`${serverBase()}/sources/${sourceId}`, {
method: "PUT", method: "PUT",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
credentials: "include", credentials: "include",
body: JSON.stringify(body), body: JSON.stringify(body),
}) })
if (!res.ok) { if (!res.ok) {
const data = (await res.json()) as { error?: string } const data = (await res.json()) as { error?: string }
throw new Error(data.error ?? `Failed to replace source config: ${res.status}`) throw new Error(data.error ?? `Failed to replace source config: ${res.status}`)
} }
} }
export async function updateProviderConfig( export async function updateProviderConfig(
sourceId: string, sourceId: string,
body: Record<string, unknown>, body: Record<string, unknown>,
): Promise<void> { ): Promise<void> {
const res = await fetch(`${apiBase()}/${sourceId}/config`, { const res = await fetch(`${apiBase()}/${sourceId}/config`, {
method: "PUT", method: "PUT",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
credentials: "include", credentials: "include",
body: JSON.stringify(body), body: JSON.stringify(body),
}) })
if (!res.ok) { if (!res.ok) {
const data = (await res.json()) as { error?: string } const data = (await res.json()) as { error?: string }
throw new Error(data.error ?? `Failed to update provider config: ${res.status}`) throw new Error(data.error ?? `Failed to update provider config: ${res.status}`)
} }
} }
export interface LocationInput { export interface LocationInput {
lat: number lat: number
lng: number lng: number
accuracy: number accuracy: number
} }
export async function pushLocation(location: LocationInput): Promise<void> { export async function pushLocation(location: LocationInput): Promise<void> {
const res = await fetch(`${serverBase()}/location`, { const res = await fetch(`${serverBase()}/location`, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
credentials: "include", credentials: "include",
body: JSON.stringify({ body: JSON.stringify({
...location, ...location,
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
}), }),
}) })
if (!res.ok) { if (!res.ok) {
const data = (await res.json()) as { error?: string } const data = (await res.json()) as { error?: string }
throw new Error(data.error ?? `Failed to push location: ${res.status}`) throw new Error(data.error ?? `Failed to push location: ${res.status}`)
} }
} }
export interface FeedItemSlot { export interface FeedItemSlot {
description: string description: string
content: string | null content: string | null
} }
export interface FeedItem { export interface FeedItem {
id: string id: string
sourceId: string sourceId: string
type: string type: string
timestamp: string timestamp: string
data: Record<string, unknown> data: Record<string, unknown>
signals?: { signals?: {
urgency?: number urgency?: number
timeRelevance?: string timeRelevance?: string
} }
slots?: Record<string, FeedItemSlot> slots?: Record<string, FeedItemSlot>
ui?: unknown ui?: unknown
} }
export interface FeedResponse { export interface FeedResponse {
items: FeedItem[] items: FeedItem[]
errors: { sourceId: string; error: string }[] errors: { sourceId: string; error: string }[]
} }
export async function fetchFeed(): Promise<FeedResponse> { export async function fetchFeed(): Promise<FeedResponse> {
const res = await fetch(`${serverBase()}/feed`, { credentials: "include" }) const res = await fetch(`${serverBase()}/feed`, { credentials: "include" })
if (!res.ok) throw new Error(`Failed to fetch feed: ${res.status}`) if (!res.ok) throw new Error(`Failed to fetch feed: ${res.status}`)
return res.json() as Promise<FeedResponse> return res.json() as Promise<FeedResponse>
} }

View File

@@ -1,47 +1,47 @@
import { getServerUrl } from "./server-url" import { getServerUrl } from "./server-url"
function authBase() { function authBase() {
return `${getServerUrl()}/api/auth` return `${getServerUrl()}/api/auth`
} }
export interface AuthUser { export interface AuthUser {
id: string id: string
name: string name: string
email: string email: string
image: string | null image: string | null
} }
export interface AuthSession { export interface AuthSession {
user: AuthUser user: AuthUser
session: { id: string; token: string } session: { id: string; token: string }
} }
export async function getSession(): Promise<AuthSession | null> { export async function getSession(): Promise<AuthSession | null> {
const res = await fetch(`${authBase()}/get-session`, { const res = await fetch(`${authBase()}/get-session`, {
credentials: "include", credentials: "include",
}) })
if (!res.ok) return null if (!res.ok) return null
const data = (await res.json()) as AuthSession | null const data = (await res.json()) as AuthSession | null
return data return data
} }
export async function signIn(email: string, password: string): Promise<AuthSession> { export async function signIn(email: string, password: string): Promise<AuthSession> {
const res = await fetch(`${authBase()}/sign-in/email`, { const res = await fetch(`${authBase()}/sign-in/email`, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
credentials: "include", credentials: "include",
body: JSON.stringify({ email, password }), body: JSON.stringify({ email, password }),
}) })
if (!res.ok) { if (!res.ok) {
const data = (await res.json()) as { message?: string } const data = (await res.json()) as { message?: string }
throw new Error(data.message ?? `Sign in failed: ${res.status}`) throw new Error(data.message ?? `Sign in failed: ${res.status}`)
} }
return (await res.json()) as AuthSession return (await res.json()) as AuthSession
} }
export async function signOut(): Promise<void> { export async function signOut(): Promise<void> {
await fetch(`${authBase()}/sign-out`, { await fetch(`${authBase()}/sign-out`, {
method: "POST", method: "POST",
credentials: "include", credentials: "include",
}) })
} }

View File

@@ -2,9 +2,9 @@ const STORAGE_KEY = "aelis-server-url"
const DEFAULT_URL = "https://3000--019cf276-6ed6-7529-a425-210182693908.eu-runner.flex.doptig.cloud" const DEFAULT_URL = "https://3000--019cf276-6ed6-7529-a425-210182693908.eu-runner.flex.doptig.cloud"
export function getServerUrl(): string { export function getServerUrl(): string {
return localStorage.getItem(STORAGE_KEY) ?? DEFAULT_URL return localStorage.getItem(STORAGE_KEY) ?? DEFAULT_URL
} }
export function setServerUrl(url: string): void { export function setServerUrl(url: string): void {
localStorage.setItem(STORAGE_KEY, url.replace(/\/+$/, "")) localStorage.setItem(STORAGE_KEY, url.replace(/\/+$/, ""))
} }

View File

@@ -2,5 +2,5 @@ import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge" import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) { export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs)) return twMerge(clsx(inputs))
} }

View File

@@ -3,26 +3,27 @@ import { StrictMode } from "react"
import { createRoot } from "react-dom/client" import { createRoot } from "react-dom/client"
import "./index.css" import "./index.css"
import App from "./App.tsx"
import { ThemeProvider } from "@/components/theme-provider.tsx" import { ThemeProvider } from "@/components/theme-provider.tsx"
import { Toaster } from "@/components/ui/sonner.tsx" import { Toaster } from "@/components/ui/sonner.tsx"
import App from "./App.tsx"
const queryClient = new QueryClient({ const queryClient = new QueryClient({
defaultOptions: { defaultOptions: {
queries: { queries: {
retry: false, retry: false,
refetchOnWindowFocus: false, refetchOnWindowFocus: false,
}, },
}, },
}) })
createRoot(document.getElementById("root")!).render( createRoot(document.getElementById("root")!).render(
<StrictMode> <StrictMode>
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<ThemeProvider> <ThemeProvider>
<App /> <App />
<Toaster /> <Toaster />
</ThemeProvider> </ThemeProvider>
</QueryClientProvider> </QueryClientProvider>
</StrictMode> </StrictMode>,
) )

View File

@@ -1,15 +1,11 @@
import { Route as rootRoute } from "./routes/__root" import { Route as rootRoute } from "./routes/__root"
import { Route as loginRoute } from "./routes/login"
import { Route as dashboardRoute } from "./routes/_dashboard" import { Route as dashboardRoute } from "./routes/_dashboard"
import { Route as dashboardIndexRoute } from "./routes/_dashboard/index"
import { Route as dashboardFeedRoute } from "./routes/_dashboard/feed" import { Route as dashboardFeedRoute } from "./routes/_dashboard/feed"
import { Route as dashboardIndexRoute } from "./routes/_dashboard/index"
import { Route as dashboardSourceRoute } from "./routes/_dashboard/sources.$sourceId" import { Route as dashboardSourceRoute } from "./routes/_dashboard/sources.$sourceId"
import { Route as loginRoute } from "./routes/login"
export const routeTree = rootRoute.addChildren([ export const routeTree = rootRoute.addChildren([
loginRoute, loginRoute,
dashboardRoute.addChildren([ dashboardRoute.addChildren([dashboardIndexRoute, dashboardFeedRoute, dashboardSourceRoute]),
dashboardIndexRoute,
dashboardFeedRoute,
dashboardSourceRoute,
]),
]) ])

View File

@@ -1,13 +1,15 @@
import { createRootRouteWithContext, Outlet } from "@tanstack/react-router"
import type { QueryClient } from "@tanstack/react-query" import type { QueryClient } from "@tanstack/react-query"
import { createRootRouteWithContext, Outlet } from "@tanstack/react-router"
import { TooltipProvider } from "@/components/ui/tooltip" import { TooltipProvider } from "@/components/ui/tooltip"
export const Route = createRootRouteWithContext<{ queryClient: QueryClient }>()({ export const Route = createRootRouteWithContext<{ queryClient: QueryClient }>()({
component: function RootLayout() { component: function RootLayout() {
return ( return (
<TooltipProvider> <TooltipProvider>
<Outlet /> <Outlet />
</TooltipProvider> </TooltipProvider>
) )
}, },
}) })

View File

@@ -1,206 +1,219 @@
import { createRoute, Outlet, redirect, useMatchRoute, useNavigate, Link } from "@tanstack/react-router"
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { import {
Calendar, createRoute,
CalendarDays, Outlet,
CircleDot, redirect,
CloudSun, useMatchRoute,
Loader2, useNavigate,
LogOut, Link,
MapPin, } from "@tanstack/react-router"
Rss, import {
Server, Calendar,
TriangleAlert, CalendarDays,
CircleDot,
CloudSun,
Loader2,
TrainFront,
LogOut,
MapPin,
Rss,
Server,
TriangleAlert,
} from "lucide-react" } from "lucide-react"
import { fetchConfigs, fetchSources } from "@/lib/api"
import { getSession, signOut } from "@/lib/auth"
import { Alert, AlertDescription } from "@/components/ui/alert" import { Alert, AlertDescription } from "@/components/ui/alert"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Separator } from "@/components/ui/separator" import { Separator } from "@/components/ui/separator"
import { import {
Sidebar, Sidebar,
SidebarContent, SidebarContent,
SidebarGroup, SidebarGroup,
SidebarGroupContent, SidebarGroupContent,
SidebarGroupLabel, SidebarGroupLabel,
SidebarHeader, SidebarHeader,
SidebarInset, SidebarInset,
SidebarMenu, SidebarMenu,
SidebarMenuBadge, SidebarMenuBadge,
SidebarMenuButton, SidebarMenuButton,
SidebarMenuItem, SidebarMenuItem,
SidebarProvider, SidebarProvider,
SidebarTrigger, SidebarTrigger,
} from "@/components/ui/sidebar" } from "@/components/ui/sidebar"
import { fetchConfigs, fetchSources } from "@/lib/api"
import { getSession, signOut } from "@/lib/auth"
import { Route as rootRoute } from "./__root" import { Route as rootRoute } from "./__root"
const SOURCE_ICONS: Record<string, React.ComponentType<{ className?: string }>> = { const SOURCE_ICONS: Record<string, React.ComponentType<{ className?: string }>> = {
"aelis.location": MapPin, "aelis.location": MapPin,
"aelis.weather": CloudSun, "aelis.weather": CloudSun,
"aelis.caldav": CalendarDays, "aelis.caldav": CalendarDays,
"aelis.google-calendar": Calendar, "aelis.google-calendar": Calendar,
"aelis.tfl": TrainFront,
} }
export const Route = createRoute({ export const Route = createRoute({
getParentRoute: () => rootRoute, getParentRoute: () => rootRoute,
id: "dashboard", id: "dashboard",
beforeLoad: async ({ context }) => { beforeLoad: async ({ context }) => {
let session: Awaited<ReturnType<typeof getSession>> | null = null let session: Awaited<ReturnType<typeof getSession>> | null = null
try { try {
session = await context.queryClient.ensureQueryData({ session = await context.queryClient.ensureQueryData({
queryKey: ["session"], queryKey: ["session"],
queryFn: getSession, queryFn: getSession,
}) })
} catch { } catch {
throw redirect({ to: "/login" }) throw redirect({ to: "/login" })
} }
if (!session?.user) { if (!session?.user) {
throw redirect({ to: "/login" }) throw redirect({ to: "/login" })
} }
return { user: session.user } return { user: session.user }
}, },
component: DashboardLayout, component: DashboardLayout,
pendingComponent: () => ( pendingComponent: () => (
<div className="flex min-h-svh items-center justify-center"> <div className="flex min-h-svh items-center justify-center">
<Loader2 className="size-5 animate-spin text-muted-foreground" /> <Loader2 className="size-5 animate-spin text-muted-foreground" />
</div> </div>
), ),
}) })
function DashboardLayout() { function DashboardLayout() {
const { user } = Route.useRouteContext() const { user } = Route.useRouteContext()
const navigate = useNavigate() const navigate = useNavigate()
const queryClient = useQueryClient() const queryClient = useQueryClient()
const matchRoute = useMatchRoute() const matchRoute = useMatchRoute()
const { data: sources = [] } = useQuery({ const { data: sources = [] } = useQuery({
queryKey: ["sources"], queryKey: ["sources"],
queryFn: fetchSources, queryFn: fetchSources,
}) })
const { const {
data: configs = [], data: configs = [],
error: configsError, error: configsError,
refetch: refetchConfigs, refetch: refetchConfigs,
} = useQuery({ } = useQuery({
queryKey: ["configs"], queryKey: ["configs"],
queryFn: fetchConfigs, queryFn: fetchConfigs,
}) })
const logoutMutation = useMutation({ const logoutMutation = useMutation({
mutationFn: signOut, mutationFn: signOut,
onSuccess() { onSuccess() {
queryClient.setQueryData(["session"], null) queryClient.setQueryData(["session"], null)
queryClient.clear() queryClient.clear()
navigate({ to: "/login" }) navigate({ to: "/login" })
}, },
}) })
const error = configsError?.message ?? null const error = configsError?.message ?? null
const configMap = new Map(configs.map((c) => [c.sourceId, c])) const configMap = new Map(configs.map((c) => [c.sourceId, c]))
return ( return (
<SidebarProvider> <SidebarProvider>
<Sidebar> <Sidebar>
<SidebarHeader> <SidebarHeader>
<div className="flex items-center justify-between px-2 py-1"> <div className="flex items-center justify-between px-2 py-1">
<div className="min-w-0"> <div className="min-w-0">
<p className="truncate text-sm font-medium">{user.name}</p> <p className="truncate text-sm font-medium">{user.name}</p>
<p className="truncate text-xs text-muted-foreground">{user.email}</p> <p className="truncate text-xs text-muted-foreground">{user.email}</p>
</div> </div>
<Button variant="ghost" size="icon" className="size-7 shrink-0" onClick={() => logoutMutation.mutate()}> <Button
<LogOut className="size-3.5" /> variant="ghost"
</Button> size="icon"
</div> className="size-7 shrink-0"
</SidebarHeader> onClick={() => logoutMutation.mutate()}
<SidebarContent> >
<SidebarGroup> <LogOut className="size-3.5" />
<SidebarGroupLabel>General</SidebarGroupLabel> </Button>
<SidebarGroupContent> </div>
<SidebarMenu> </SidebarHeader>
<SidebarMenuItem> <SidebarContent>
<SidebarMenuButton <SidebarGroup>
isActive={!!matchRoute({ to: "/" })} <SidebarGroupLabel>General</SidebarGroupLabel>
asChild <SidebarGroupContent>
> <SidebarMenu>
<Link to="/"> <SidebarMenuItem>
<Server className="size-4" /> <SidebarMenuButton isActive={!!matchRoute({ to: "/" })} asChild>
<span>Server</span> <Link to="/">
</Link> <Server className="size-4" />
</SidebarMenuButton> <span>Server</span>
</SidebarMenuItem> </Link>
<SidebarMenuItem> </SidebarMenuButton>
<SidebarMenuButton </SidebarMenuItem>
isActive={!!matchRoute({ to: "/feed" })} <SidebarMenuItem>
asChild <SidebarMenuButton isActive={!!matchRoute({ to: "/feed" })} asChild>
> <Link to="/feed">
<Link to="/feed"> <Rss className="size-4" />
<Rss className="size-4" /> <span>Feed</span>
<span>Feed</span> </Link>
</Link> </SidebarMenuButton>
</SidebarMenuButton> </SidebarMenuItem>
</SidebarMenuItem> </SidebarMenu>
</SidebarMenu> </SidebarGroupContent>
</SidebarGroupContent> </SidebarGroup>
</SidebarGroup>
<SidebarGroup> <SidebarGroup>
<SidebarGroupLabel>Sources</SidebarGroupLabel> <SidebarGroupLabel>Sources</SidebarGroupLabel>
<SidebarGroupContent> <SidebarGroupContent>
<SidebarMenu> <SidebarMenu>
{sources.map((source) => { {sources.map((source) => {
const Icon = SOURCE_ICONS[source.id] ?? CircleDot const Icon = SOURCE_ICONS[source.id] ?? CircleDot
const cfg = configMap.get(source.id) const cfg = configMap.get(source.id)
const isEnabled = source.alwaysEnabled || cfg?.enabled const isEnabled = source.alwaysEnabled || cfg?.enabled
return ( return (
<SidebarMenuItem key={source.id}> <SidebarMenuItem key={source.id}>
<SidebarMenuButton <SidebarMenuButton
isActive={!!matchRoute({ to: "/sources/$sourceId", params: { sourceId: source.id } })} isActive={
asChild !!matchRoute({
> to: "/sources/$sourceId",
<Link to="/sources/$sourceId" params={{ sourceId: source.id }}> params: { sourceId: source.id },
<Icon className="size-4" /> })
<span>{source.name}</span> }
</Link> asChild
</SidebarMenuButton> >
{isEnabled && ( <Link to="/sources/$sourceId" params={{ sourceId: source.id }}>
<SidebarMenuBadge> <Icon className="size-4" />
<CircleDot className="size-2.5 text-primary" /> <span>{source.name}</span>
</SidebarMenuBadge> </Link>
)} </SidebarMenuButton>
</SidebarMenuItem> {isEnabled && (
) <SidebarMenuBadge>
})} <CircleDot className="size-2.5 text-primary" />
</SidebarMenu> </SidebarMenuBadge>
</SidebarGroupContent> )}
</SidebarGroup> </SidebarMenuItem>
</SidebarContent> )
</Sidebar> })}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>
</Sidebar>
<SidebarInset> <SidebarInset>
<header className="flex h-12 items-center gap-2 border-b px-4"> <header className="flex h-12 items-center gap-2 border-b px-4">
<SidebarTrigger className="-ml-1" /> <SidebarTrigger className="-ml-1" />
<Separator orientation="vertical" className="mr-2 !h-4" /> <Separator orientation="vertical" className="mr-2 !h-4" />
</header> </header>
<main className="flex-1 p-6"> <main className="flex-1 p-6">
{error && ( {error && (
<Alert variant="destructive" className="mb-6"> <Alert variant="destructive" className="mb-6">
<TriangleAlert className="size-4" /> <TriangleAlert className="size-4" />
<AlertDescription className="flex items-center justify-between"> <AlertDescription className="flex items-center justify-between">
<span>{error}</span> <span>{error}</span>
<Button variant="ghost" size="sm" onClick={() => refetchConfigs()}> <Button variant="ghost" size="sm" onClick={() => refetchConfigs()}>
Retry Retry
</Button> </Button>
</AlertDescription> </AlertDescription>
</Alert> </Alert>
)} )}
<Outlet /> <Outlet />
</main> </main>
</SidebarInset> </SidebarInset>
</SidebarProvider> </SidebarProvider>
) )
} }

View File

@@ -1,10 +1,11 @@
import { createRoute } from "@tanstack/react-router" import { createRoute } from "@tanstack/react-router"
import { FeedPanel } from "@/components/feed-panel" import { FeedPanel } from "@/components/feed-panel"
import { Route as dashboardRoute } from "../_dashboard" import { Route as dashboardRoute } from "../_dashboard"
export const Route = createRoute({ export const Route = createRoute({
getParentRoute: () => dashboardRoute, getParentRoute: () => dashboardRoute,
path: "/feed", path: "/feed",
component: FeedPanel, component: FeedPanel,
}) })

View File

@@ -1,10 +1,11 @@
import { createRoute } from "@tanstack/react-router" import { createRoute } from "@tanstack/react-router"
import { GeneralSettingsPanel } from "@/components/general-settings-panel" import { GeneralSettingsPanel } from "@/components/general-settings-panel"
import { Route as dashboardRoute } from "../_dashboard" import { Route as dashboardRoute } from "../_dashboard"
export const Route = createRoute({ export const Route = createRoute({
getParentRoute: () => dashboardRoute, getParentRoute: () => dashboardRoute,
path: "/", path: "/",
component: GeneralSettingsPanel, component: GeneralSettingsPanel,
}) })

View File

@@ -1,34 +1,35 @@
import { createRoute } from "@tanstack/react-router"
import { useQuery, useQueryClient } from "@tanstack/react-query" import { useQuery, useQueryClient } from "@tanstack/react-query"
import { createRoute } from "@tanstack/react-router"
import { fetchSources } from "@/lib/api"
import { SourceConfigPanel } from "@/components/source-config-panel" import { SourceConfigPanel } from "@/components/source-config-panel"
import { fetchSources } from "@/lib/api"
import { Route as dashboardRoute } from "../_dashboard" import { Route as dashboardRoute } from "../_dashboard"
export const Route = createRoute({ export const Route = createRoute({
getParentRoute: () => dashboardRoute, getParentRoute: () => dashboardRoute,
path: "/sources/$sourceId", path: "/sources/$sourceId",
component: SourceRoute, component: SourceRoute,
}) })
function SourceRoute() { function SourceRoute() {
const { sourceId } = Route.useParams() const { sourceId } = Route.useParams()
const queryClient = useQueryClient() const queryClient = useQueryClient()
const { data: sources = [] } = useQuery({ const { data: sources = [] } = useQuery({
queryKey: ["sources"], queryKey: ["sources"],
queryFn: fetchSources, queryFn: fetchSources,
}) })
const source = sources.find((s) => s.id === sourceId) const source = sources.find((s) => s.id === sourceId)
if (!source) { if (!source) {
return <p className="text-sm text-muted-foreground">Source not found.</p> return <p className="text-sm text-muted-foreground">Source not found.</p>
} }
return ( return (
<SourceConfigPanel <SourceConfigPanel
key={source.id} key={source.id}
source={source} source={source}
onUpdate={() => queryClient.invalidateQueries({ queryKey: ["configs"] })} onUpdate={() => queryClient.invalidateQueries({ queryKey: ["configs"] })}
/> />
) )
} }

View File

@@ -1,22 +1,24 @@
import { createRoute, useNavigate } from "@tanstack/react-router"
import { useQueryClient } from "@tanstack/react-query" import { useQueryClient } from "@tanstack/react-query"
import { createRoute, useNavigate } from "@tanstack/react-router"
import type { AuthSession } from "@/lib/auth" import type { AuthSession } from "@/lib/auth"
import { LoginPage } from "@/components/login-page" import { LoginPage } from "@/components/login-page"
import { Route as rootRoute } from "./__root" import { Route as rootRoute } from "./__root"
export const Route = createRoute({ export const Route = createRoute({
getParentRoute: () => rootRoute, getParentRoute: () => rootRoute,
path: "/login", path: "/login",
component: function LoginRoute() { component: function LoginRoute() {
const navigate = useNavigate() const navigate = useNavigate()
const queryClient = useQueryClient() const queryClient = useQueryClient()
function handleLogin(session: AuthSession) { function handleLogin(session: AuthSession) {
queryClient.setQueryData(["session"], session) queryClient.setQueryData(["session"], session)
navigate({ to: "/" }) navigate({ to: "/" })
} }
return <LoginPage onLogin={handleLogin} /> return <LoginPage onLogin={handleLogin} />
}, },
}) })

View File

@@ -1,32 +1,32 @@
{ {
"compilerOptions": { "compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022", "target": "ES2022",
"useDefineForClassFields": true, "useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"], "lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext", "module": "ESNext",
"types": ["vite/client"], "types": ["vite/client"],
"skipLibCheck": true, "skipLibCheck": true,
/* Bundler mode */ /* Bundler mode */
"moduleResolution": "bundler", "moduleResolution": "bundler",
"allowImportingTsExtensions": true, "allowImportingTsExtensions": true,
"verbatimModuleSyntax": true, "verbatimModuleSyntax": true,
"moduleDetection": "force", "moduleDetection": "force",
"noEmit": true, "noEmit": true,
"jsx": "react-jsx", "jsx": "react-jsx",
/* Linting */ /* Linting */
"strict": true, "strict": true,
"noUnusedLocals": true, "noUnusedLocals": true,
"noUnusedParameters": true, "noUnusedParameters": true,
"erasableSyntaxOnly": true, "erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true, "noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true, "noUncheckedSideEffectImports": true,
"baseUrl": ".", "baseUrl": ".",
"paths": { "paths": {
"@/*": ["./src/*"] "@/*": ["./src/*"]
} }
}, },
"include": ["src"] "include": ["src"]
} }

View File

@@ -1,13 +1,10 @@
{ {
"files": [], "files": [],
"references": [ "references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }],
{ "path": "./tsconfig.app.json" }, "compilerOptions": {
{ "path": "./tsconfig.node.json" } "baseUrl": ".",
], "paths": {
"compilerOptions": { "@/*": ["./src/*"]
"baseUrl": ".", }
"paths": { }
"@/*": ["./src/*"]
}
}
} }

View File

@@ -1,26 +1,26 @@
{ {
"compilerOptions": { "compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023", "target": "ES2023",
"lib": ["ES2023"], "lib": ["ES2023"],
"module": "ESNext", "module": "ESNext",
"types": ["node"], "types": ["node"],
"skipLibCheck": true, "skipLibCheck": true,
/* Bundler mode */ /* Bundler mode */
"moduleResolution": "bundler", "moduleResolution": "bundler",
"allowImportingTsExtensions": true, "allowImportingTsExtensions": true,
"verbatimModuleSyntax": true, "verbatimModuleSyntax": true,
"moduleDetection": "force", "moduleDetection": "force",
"noEmit": true, "noEmit": true,
/* Linting */ /* Linting */
"strict": true, "strict": true,
"noUnusedLocals": true, "noUnusedLocals": true,
"noUnusedParameters": true, "noUnusedParameters": true,
"erasableSyntaxOnly": true, "erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true, "noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true "noUncheckedSideEffectImports": true
}, },
"include": ["vite.config.ts"] "include": ["vite.config.ts"]
} }

View File

@@ -1,18 +1,18 @@
import path from "path"
import tailwindcss from "@tailwindcss/vite" import tailwindcss from "@tailwindcss/vite"
import react from "@vitejs/plugin-react" import react from "@vitejs/plugin-react"
import path from "path"
import { defineConfig } from "vite" import { defineConfig } from "vite"
// https://vite.dev/config/ // https://vite.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [react(), tailwindcss()], plugins: [react(), tailwindcss()],
resolve: { resolve: {
alias: { alias: {
"@": path.resolve(__dirname, "./src"), "@": path.resolve(__dirname, "./src"),
}, },
}, },
server: { server: {
port: 5174, port: 5174,
allowedHosts: true, allowedHosts: true,
}, },
}) })

View File

@@ -5,7 +5,7 @@ DATABASE_URL=postgresql://user:password@localhost:5432/aris
BETTER_AUTH_SECRET= BETTER_AUTH_SECRET=
# Encryption key for source credentials at rest (32 bytes, generate with: openssl rand -base64 32) # Encryption key for source credentials at rest (32 bytes, generate with: openssl rand -base64 32)
CREDENTIALS_ENCRYPTION_KEY= CREDENTIAL_ENCRYPTION_KEY=
# Base URL of the backend # Base URL of the backend
BETTER_AUTH_URL=http://localhost:3000 BETTER_AUTH_URL=http://localhost:3000

View File

@@ -4,7 +4,7 @@
"type": "module", "type": "module",
"main": "src/server.ts", "main": "src/server.ts",
"scripts": { "scripts": {
"dev": "bun run --watch src/server.ts", "dev": "bun run --watch --inspect=0.0.0.0:6499 src/server.ts",
"start": "bun run src/server.ts", "start": "bun run src/server.ts",
"test": "bun test src/", "test": "bun test src/",
"db:generate": "bunx drizzle-kit generate", "db:generate": "bunx drizzle-kit generate",

View File

@@ -4,7 +4,7 @@ import type { EnhancementResult } from "./schema.ts"
import { enhancementResultJsonSchema, parseEnhancementResult } from "./schema.ts" import { enhancementResultJsonSchema, parseEnhancementResult } from "./schema.ts"
const DEFAULT_MODEL = "openai/gpt-4.1-mini" const DEFAULT_MODEL = "z-ai/glm-4.7-flash"
const DEFAULT_TIMEOUT_MS = 30_000 const DEFAULT_TIMEOUT_MS = 30_000
export interface LlmClientConfig { export interface LlmClientConfig {

View File

@@ -5,7 +5,11 @@ import type { FeedSourceProvider } from "../session/feed-source-provider.ts"
export class LocationSourceProvider implements FeedSourceProvider { export class LocationSourceProvider implements FeedSourceProvider {
readonly sourceId = "aelis.location" readonly sourceId = "aelis.location"
async feedSourceForUser(_userId: string, _config: unknown): Promise<LocationSource> { async feedSourceForUser(
_userId: string,
_config: unknown,
_credentials: unknown,
): Promise<LocationSource> {
return new LocationSource() return new LocationSource()
} }
} }

View File

@@ -10,6 +10,7 @@ import { createDatabase } from "./db/index.ts"
import { registerFeedHttpHandlers } from "./engine/http.ts" import { registerFeedHttpHandlers } from "./engine/http.ts"
import { createFeedEnhancer } from "./enhancement/enhance-feed.ts" import { createFeedEnhancer } from "./enhancement/enhance-feed.ts"
import { createLlmClient } from "./enhancement/llm-client.ts" import { createLlmClient } from "./enhancement/llm-client.ts"
import { CredentialEncryptor } from "./lib/crypto.ts"
import { registerLocationHttpHandlers } from "./location/http.ts" import { registerLocationHttpHandlers } from "./location/http.ts"
import { LocationSourceProvider } from "./location/provider.ts" import { LocationSourceProvider } from "./location/provider.ts"
import { UserSessionManager } from "./session/index.ts" import { UserSessionManager } from "./session/index.ts"
@@ -34,6 +35,16 @@ function main() {
console.warn("[enhancement] OPENROUTER_API_KEY not set — feed enhancement disabled") console.warn("[enhancement] OPENROUTER_API_KEY not set — feed enhancement disabled")
} }
const credentialEncryptionKey = process.env.CREDENTIAL_ENCRYPTION_KEY
const credentialEncryptor = credentialEncryptionKey
? new CredentialEncryptor(credentialEncryptionKey)
: null
if (!credentialEncryptor) {
console.warn(
"[credentials] CREDENTIAL_ENCRYPTION_KEY not set — per-user credential storage disabled",
)
}
const sessionManager = new UserSessionManager({ const sessionManager = new UserSessionManager({
db, db,
providers: [ providers: [
@@ -49,6 +60,7 @@ function main() {
new TflSourceProvider({ apiKey: process.env.TFL_API_KEY! }), new TflSourceProvider({ apiKey: process.env.TFL_API_KEY! }),
], ],
feedEnhancer, feedEnhancer,
credentialEncryptor,
}) })
const app = new Hono() const app = new Hono()

View File

@@ -8,5 +8,5 @@ export interface FeedSourceProvider {
readonly sourceId: string readonly sourceId: string
/** Arktype schema for validating user-provided config. Omit if the source has no config. */ /** Arktype schema for validating user-provided config. Omit if the source has no config. */
readonly configSchema?: ConfigSchema readonly configSchema?: ConfigSchema
feedSourceForUser(userId: string, config: unknown): Promise<FeedSource> feedSourceForUser(userId: string, config: unknown, credentials: unknown): Promise<FeedSource>
} }

View File

@@ -7,6 +7,12 @@ import { beforeEach, describe, expect, mock, spyOn, test } from "bun:test"
import type { Database } from "../db/index.ts" import type { Database } from "../db/index.ts"
import type { FeedSourceProvider } from "./feed-source-provider.ts" import type { FeedSourceProvider } from "./feed-source-provider.ts"
import { CredentialEncryptor } from "../lib/crypto.ts"
import {
CredentialStorageUnavailableError,
InvalidSourceCredentialsError,
} from "../sources/errors.ts"
import { SourceNotFoundError } from "../sources/errors.ts"
import { UserSessionManager } from "./user-session-manager.ts" import { UserSessionManager } from "./user-session-manager.ts"
/** /**
@@ -38,6 +44,13 @@ function getEnabledSourceIds(userId: string): string[] {
*/ */
let mockFindResult: unknown | undefined let mockFindResult: unknown | undefined
/**
* Spy for `updateCredentials` calls. Tests can inspect calls via
* `mockUpdateCredentialsCalls` or override behavior.
*/
const mockUpdateCredentialsCalls: Array<{ sourceId: string; credentials: Buffer }> = []
let mockUpdateCredentialsError: Error | null = null
// Mock the sources module so UserSessionManager's DB query returns controlled data. // Mock the sources module so UserSessionManager's DB query returns controlled data.
mock.module("../sources/user-sources.ts", () => ({ mock.module("../sources/user-sources.ts", () => ({
sources: (_db: Database, userId: string) => ({ sources: (_db: Database, userId: string) => ({
@@ -68,6 +81,12 @@ mock.module("../sources/user-sources.ts", () => ({
updatedAt: now, updatedAt: now,
} }
}, },
async updateCredentials(sourceId: string, credentials: Buffer) {
if (mockUpdateCredentialsError) {
throw mockUpdateCredentialsError
}
mockUpdateCredentialsCalls.push({ sourceId, credentials })
},
}), }),
})) }))
@@ -93,8 +112,11 @@ function createStubSource(id: string, items: FeedItem[] = []): FeedSource {
function createStubProvider( function createStubProvider(
sourceId: string, sourceId: string,
factory: (userId: string, config: Record<string, unknown>) => Promise<FeedSource> = async () => factory: (
createStubSource(sourceId), userId: string,
config: Record<string, unknown>,
credentials: unknown,
) => Promise<FeedSource> = async () => createStubSource(sourceId),
): FeedSourceProvider { ): FeedSourceProvider {
return { sourceId, feedSourceForUser: factory } return { sourceId, feedSourceForUser: factory }
} }
@@ -116,6 +138,8 @@ const weatherProvider: FeedSourceProvider = {
beforeEach(() => { beforeEach(() => {
enabledByUser.clear() enabledByUser.clear()
mockFindResult = undefined mockFindResult = undefined
mockUpdateCredentialsCalls.length = 0
mockUpdateCredentialsError = null
}) })
describe("UserSessionManager", () => { describe("UserSessionManager", () => {
@@ -681,3 +705,122 @@ describe("UserSessionManager.replaceProvider", () => {
expect(feedAfter.items[0]!.data.version).toBe(1) expect(feedAfter.items[0]!.data.version).toBe(1)
}) })
}) })
const TEST_ENCRYPTION_KEY = "/bv1nbzC4ozZkT/pcv5oQfl+JAMuMZDUSVDesG2dur8="
const testEncryptor = new CredentialEncryptor(TEST_ENCRYPTION_KEY)
describe("UserSessionManager.updateSourceCredentials", () => {
test("encrypts and persists credentials", async () => {
setEnabledSources(["test"])
const provider = createStubProvider("test")
const manager = new UserSessionManager({
db: fakeDb,
providers: [provider],
credentialEncryptor: testEncryptor,
})
await manager.updateSourceCredentials("user-1", "test", { token: "secret-123" })
expect(mockUpdateCredentialsCalls).toHaveLength(1)
expect(mockUpdateCredentialsCalls[0]!.sourceId).toBe("test")
// Verify the persisted buffer decrypts to the original credentials
const decrypted = JSON.parse(testEncryptor.decrypt(mockUpdateCredentialsCalls[0]!.credentials))
expect(decrypted).toEqual({ token: "secret-123" })
})
test("throws CredentialStorageUnavailableError when encryptor is not configured", async () => {
setEnabledSources(["test"])
const provider = createStubProvider("test")
const manager = new UserSessionManager({
db: fakeDb,
providers: [provider],
// no credentialEncryptor
})
await expect(
manager.updateSourceCredentials("user-1", "test", { token: "x" }),
).rejects.toBeInstanceOf(CredentialStorageUnavailableError)
})
test("throws SourceNotFoundError for unknown source", async () => {
setEnabledSources([])
const manager = new UserSessionManager({
db: fakeDb,
providers: [],
credentialEncryptor: testEncryptor,
})
await expect(
manager.updateSourceCredentials("user-1", "unknown", { token: "x" }),
).rejects.toBeInstanceOf(SourceNotFoundError)
})
test("propagates InvalidSourceCredentialsError from provider", async () => {
setEnabledSources(["test"])
let callCount = 0
const provider: FeedSourceProvider = {
sourceId: "test",
async feedSourceForUser(_userId: string, _config: unknown, _credentials: unknown) {
callCount++
// Succeed on first call (session creation), throw on refresh
if (callCount > 1) {
throw new InvalidSourceCredentialsError("test", "bad credentials")
}
return createStubSource("test")
},
}
const manager = new UserSessionManager({
db: fakeDb,
providers: [provider],
credentialEncryptor: testEncryptor,
})
// Create a session first so the refresh path is exercised
await manager.getOrCreate("user-1")
await expect(
manager.updateSourceCredentials("user-1", "test", { token: "bad" }),
).rejects.toBeInstanceOf(InvalidSourceCredentialsError)
// Credentials should still have been persisted before the provider threw
expect(mockUpdateCredentialsCalls).toHaveLength(1)
})
test("refreshes source in active session after credential update", async () => {
setEnabledSources(["test"])
let receivedCredentials: unknown = null
const provider = createStubProvider("test", async (_userId, _config, credentials) => {
receivedCredentials = credentials
return createStubSource("test")
})
const manager = new UserSessionManager({
db: fakeDb,
providers: [provider],
credentialEncryptor: testEncryptor,
})
await manager.getOrCreate("user-1")
await manager.updateSourceCredentials("user-1", "test", { token: "refreshed" })
expect(receivedCredentials).toEqual({ token: "refreshed" })
})
test("persists credentials without session refresh when no active session", async () => {
setEnabledSources(["test"])
const factory = mock(async () => createStubSource("test"))
const provider: FeedSourceProvider = { sourceId: "test", feedSourceForUser: factory }
const manager = new UserSessionManager({
db: fakeDb,
providers: [provider],
credentialEncryptor: testEncryptor,
})
// No session created — just update credentials
await manager.updateSourceCredentials("user-1", "test", { token: "stored" })
expect(mockUpdateCredentialsCalls).toHaveLength(1)
// feedSourceForUser should not have been called (no session to refresh)
expect(factory).not.toHaveBeenCalled()
})
})

View File

@@ -5,9 +5,14 @@ import merge from "lodash.merge"
import type { Database } from "../db/index.ts" import type { Database } from "../db/index.ts"
import type { FeedEnhancer } from "../enhancement/enhance-feed.ts" import type { FeedEnhancer } from "../enhancement/enhance-feed.ts"
import type { CredentialEncryptor } from "../lib/crypto.ts"
import type { FeedSourceProvider } from "./feed-source-provider.ts" import type { FeedSourceProvider } from "./feed-source-provider.ts"
import { InvalidSourceConfigError, SourceNotFoundError } from "../sources/errors.ts" import {
CredentialStorageUnavailableError,
InvalidSourceConfigError,
SourceNotFoundError,
} from "../sources/errors.ts"
import { sources } from "../sources/user-sources.ts" import { sources } from "../sources/user-sources.ts"
import { UserSession } from "./user-session.ts" import { UserSession } from "./user-session.ts"
@@ -15,6 +20,7 @@ export interface UserSessionManagerConfig {
db: Database db: Database
providers: FeedSourceProvider[] providers: FeedSourceProvider[]
feedEnhancer?: FeedEnhancer | null feedEnhancer?: FeedEnhancer | null
credentialEncryptor?: CredentialEncryptor | null
} }
export class UserSessionManager { export class UserSessionManager {
@@ -23,7 +29,7 @@ export class UserSessionManager {
private readonly db: Database private readonly db: Database
private readonly providers = new Map<string, FeedSourceProvider>() private readonly providers = new Map<string, FeedSourceProvider>()
private readonly feedEnhancer: FeedEnhancer | null private readonly feedEnhancer: FeedEnhancer | null
private readonly db: Database private readonly encryptor: CredentialEncryptor | null
constructor(config: UserSessionManagerConfig) { constructor(config: UserSessionManagerConfig) {
this.db = config.db this.db = config.db
@@ -31,7 +37,7 @@ export class UserSessionManager {
this.providers.set(provider.sourceId, provider) this.providers.set(provider.sourceId, provider)
} }
this.feedEnhancer = config.feedEnhancer ?? null this.feedEnhancer = config.feedEnhancer ?? null
this.db = config.db this.encryptor = config.credentialEncryptor ?? null
} }
getProvider(sourceId: string): FeedSourceProvider | undefined { getProvider(sourceId: string): FeedSourceProvider | undefined {
@@ -120,14 +126,15 @@ export class UserSessionManager {
return return
} }
// When config is provided, fetch existing to deep-merge before validating. // Fetch the existing row for config merging and credential access.
// NOTE: find + updateConfig is not atomic. A concurrent update could // NOTE: find + updateConfig is not atomic. A concurrent update could
// read stale config. Use SELECT FOR UPDATE or atomic jsonb merge if // read stale config. Use SELECT FOR UPDATE or atomic jsonb merge if
// this becomes a problem. // this becomes a problem.
const existingRow = await sources(this.db, userId).find(sourceId)
let mergedConfig: Record<string, unknown> | undefined let mergedConfig: Record<string, unknown> | undefined
if (update.config !== undefined && provider.configSchema) { if (update.config !== undefined && provider.configSchema) {
const existing = await sources(this.db, userId).find(sourceId) const existingConfig = (existingRow?.config ?? {}) as Record<string, unknown>
const existingConfig = (existing?.config ?? {}) as Record<string, unknown>
mergedConfig = merge({}, existingConfig, update.config) mergedConfig = merge({}, existingConfig, update.config)
const validated = provider.configSchema(mergedConfig) const validated = provider.configSchema(mergedConfig)
@@ -149,7 +156,10 @@ export class UserSessionManager {
if (update.enabled === false) { if (update.enabled === false) {
session.removeSource(sourceId) session.removeSource(sourceId)
} else { } else {
const source = await provider.feedSourceForUser(userId, mergedConfig ?? {}) const credentials = existingRow?.credentials
? this.decryptCredentials(existingRow.credentials)
: null
const source = await provider.feedSourceForUser(userId, mergedConfig ?? {}, credentials)
session.replaceSource(sourceId, source) session.replaceSource(sourceId, source)
} }
} }
@@ -182,6 +192,11 @@ export class UserSessionManager {
} }
const config = data.config ?? {} const config = data.config ?? {}
// Fetch existing row before upsert to capture credentials for session refresh.
// For new rows this will be undefined — credentials will be null.
const existingRow = await sources(this.db, userId).find(sourceId)
await sources(this.db, userId).upsertConfig(sourceId, { await sources(this.db, userId).upsertConfig(sourceId, {
enabled: data.enabled, enabled: data.enabled,
config, config,
@@ -192,7 +207,10 @@ export class UserSessionManager {
if (!data.enabled) { if (!data.enabled) {
session.removeSource(sourceId) session.removeSource(sourceId)
} else { } else {
const source = await provider.feedSourceForUser(userId, config) const credentials = existingRow?.credentials
? this.decryptCredentials(existingRow.credentials)
: null
const source = await provider.feedSourceForUser(userId, config, credentials)
if (session.hasSource(sourceId)) { if (session.hasSource(sourceId)) {
session.replaceSource(sourceId, source) session.replaceSource(sourceId, source)
} else { } else {
@@ -202,6 +220,44 @@ export class UserSessionManager {
} }
} }
/**
* Validates, encrypts, and persists per-user credentials for a source,
* then refreshes the active session.
*
* @throws {SourceNotFoundError} if the source row doesn't exist or has no registered provider
* @throws {CredentialStorageUnavailableError} if no CredentialEncryptor is configured
*/
async updateSourceCredentials(
userId: string,
sourceId: string,
credentials: unknown,
): Promise<void> {
const provider = this.providers.get(sourceId)
if (!provider) {
throw new SourceNotFoundError(sourceId, userId)
}
if (!this.encryptor) {
throw new CredentialStorageUnavailableError()
}
const encrypted = this.encryptor.encrypt(JSON.stringify(credentials))
await sources(this.db, userId).updateCredentials(sourceId, encrypted)
// Refresh the source in the active session.
// If feedSourceForUser throws (e.g. provider rejects the credentials),
// the DB already has the new credentials but the session keeps the old
// source. The next session creation will pick up the persisted credentials.
const session = this.sessions.get(userId)
if (session && session.hasSource(sourceId)) {
const row = await sources(this.db, userId).find(sourceId)
if (row?.enabled) {
const source = await provider.feedSourceForUser(userId, row.config ?? {}, credentials)
session.replaceSource(sourceId, source)
}
}
}
/** /**
* Replaces a provider and updates all active sessions. * Replaces a provider and updates all active sessions.
* The new provider must have the same sourceId as an existing one. * The new provider must have the same sourceId as an existing one.
@@ -254,7 +310,12 @@ export class UserSessionManager {
const row = await sources(this.db, session.userId).find(provider.sourceId) const row = await sources(this.db, session.userId).find(provider.sourceId)
if (!row?.enabled) return if (!row?.enabled) return
const newSource = await provider.feedSourceForUser(session.userId, row.config ?? {}) const credentials = row.credentials ? this.decryptCredentials(row.credentials) : null
const newSource = await provider.feedSourceForUser(
session.userId,
row.config ?? {},
credentials,
)
session.replaceSource(provider.sourceId, newSource) session.replaceSource(provider.sourceId, newSource)
} catch (err) { } catch (err) {
console.error( console.error(
@@ -271,7 +332,8 @@ export class UserSessionManager {
for (const row of enabledRows) { for (const row of enabledRows) {
const provider = this.providers.get(row.sourceId) const provider = this.providers.get(row.sourceId)
if (provider) { if (provider) {
promises.push(provider.feedSourceForUser(userId, row.config ?? {})) const credentials = row.credentials ? this.decryptCredentials(row.credentials) : null
promises.push(provider.feedSourceForUser(userId, row.config ?? {}, credentials))
} }
} }
@@ -302,4 +364,19 @@ export class UserSessionManager {
return new UserSession(userId, feedSources, this.feedEnhancer) return new UserSession(userId, feedSources, this.feedEnhancer)
} }
/**
* Decrypts a credentials buffer from the DB, returning parsed JSON or null.
* Returns null (with a warning) if decryption or parsing fails — e.g. due to
* key rotation, data corruption, or malformed JSON.
*/
private decryptCredentials(credentials: Buffer): unknown {
if (!this.encryptor) return null
try {
return JSON.parse(this.encryptor.decrypt(credentials))
} catch (err) {
console.warn("[UserSessionManager] Failed to decrypt credentials:", err)
return null
}
}
} }

View File

@@ -24,3 +24,26 @@ export class InvalidSourceConfigError extends Error {
this.sourceId = sourceId this.sourceId = sourceId
} }
} }
/**
* Thrown by providers when credentials fail validation.
*/
export class InvalidSourceCredentialsError extends Error {
readonly sourceId: string
constructor(sourceId: string, summary: string) {
super(summary)
this.name = "InvalidSourceCredentialsError"
this.sourceId = sourceId
}
}
/**
* Thrown when credential storage is not configured (missing encryption key).
*/
export class CredentialStorageUnavailableError extends Error {
constructor() {
super("Credential storage is not configured")
this.name = "CredentialStorageUnavailableError"
}
}

View File

@@ -7,10 +7,11 @@ import type { Database } from "../db/index.ts"
import type { ConfigSchema, FeedSourceProvider } from "../session/feed-source-provider.ts" import type { ConfigSchema, FeedSourceProvider } from "../session/feed-source-provider.ts"
import { mockAuthSessionMiddleware } from "../auth/session-middleware.ts" import { mockAuthSessionMiddleware } from "../auth/session-middleware.ts"
import { CredentialEncryptor } from "../lib/crypto.ts"
import { UserSessionManager } from "../session/user-session-manager.ts" import { UserSessionManager } from "../session/user-session-manager.ts"
import { tflConfig } from "../tfl/provider.ts" import { tflConfig } from "../tfl/provider.ts"
import { weatherConfig } from "../weather/provider.ts" import { weatherConfig } from "../weather/provider.ts"
import { SourceNotFoundError } from "./errors.ts" import { InvalidSourceCredentialsError, SourceNotFoundError } from "./errors.ts"
import { registerSourcesHttpHandlers } from "./http.ts" import { registerSourcesHttpHandlers } from "./http.ts"
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -39,7 +40,7 @@ function createStubProvider(sourceId: string, configSchema?: ConfigSchema): Feed
return { return {
sourceId, sourceId,
configSchema, configSchema,
async feedSourceForUser() { async feedSourceForUser(_userId: string, _config: unknown, _credentials: unknown) {
return createStubSource(sourceId) return createStubSource(sourceId)
}, },
} }
@@ -105,6 +106,12 @@ function createInMemoryStore() {
}) })
} }
}, },
async updateCredentials(sourceId: string, _credentials: Buffer) {
const existing = rows.get(key(userId, sourceId))
if (!existing) {
throw new SourceNotFoundError(sourceId, userId)
}
},
} }
}, },
} }
@@ -142,6 +149,30 @@ function get(app: Hono, sourceId: string) {
return app.request(`/api/sources/${sourceId}`, { method: "GET" }) return app.request(`/api/sources/${sourceId}`, { method: "GET" })
} }
const TEST_ENCRYPTION_KEY = "/bv1nbzC4ozZkT/pcv5oQfl+JAMuMZDUSVDesG2dur8="
function createAppWithEncryptor(providers: FeedSourceProvider[], userId?: string) {
const sessionManager = new UserSessionManager({
providers,
db: fakeDb,
credentialEncryptor: new CredentialEncryptor(TEST_ENCRYPTION_KEY),
})
const app = new Hono()
registerSourcesHttpHandlers(app, {
sessionManager,
authSessionMiddleware: mockAuthSessionMiddleware(userId),
})
return { app, sessionManager }
}
function putCredentials(app: Hono, sourceId: string, body: unknown) {
return app.request(`/api/sources/${sourceId}/credentials`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
})
}
function put(app: Hono, sourceId: string, body: unknown) { function put(app: Hono, sourceId: string, body: unknown) {
return app.request(`/api/sources/${sourceId}`, { return app.request(`/api/sources/${sourceId}`, {
method: "PUT", method: "PUT",
@@ -708,3 +739,86 @@ describe("PUT /api/sources/:sourceId", () => {
expect(res.status).toBe(204) expect(res.status).toBe(204)
}) })
}) })
describe("PUT /api/sources/:sourceId/credentials", () => {
test("returns 401 without auth", async () => {
activeStore = createInMemoryStore()
const { app } = createAppWithEncryptor([createStubProvider("aelis.location")])
const res = await putCredentials(app, "aelis.location", { token: "x" })
expect(res.status).toBe(401)
})
test("returns 404 for unknown source", async () => {
activeStore = createInMemoryStore()
const { app } = createAppWithEncryptor([createStubProvider("aelis.location")], MOCK_USER_ID)
const res = await putCredentials(app, "unknown.source", { token: "x" })
expect(res.status).toBe(404)
})
test("returns 400 for invalid JSON", async () => {
activeStore = createInMemoryStore()
activeStore.seed(MOCK_USER_ID, "aelis.location")
const { app } = createAppWithEncryptor([createStubProvider("aelis.location")], MOCK_USER_ID)
const res = await app.request("/api/sources/aelis.location/credentials", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: "not-json",
})
expect(res.status).toBe(400)
const body = (await res.json()) as { error: string }
expect(body.error).toBe("Invalid JSON")
})
test("returns 204 and persists credentials", async () => {
activeStore = createInMemoryStore()
activeStore.seed(MOCK_USER_ID, "aelis.location")
const { app } = createAppWithEncryptor([createStubProvider("aelis.location")], MOCK_USER_ID)
const res = await putCredentials(app, "aelis.location", { token: "secret" })
expect(res.status).toBe(204)
})
test("returns 400 when provider throws InvalidSourceCredentialsError", async () => {
activeStore = createInMemoryStore()
activeStore.seed(MOCK_USER_ID, "test.creds")
let callCount = 0
const provider: FeedSourceProvider = {
sourceId: "test.creds",
async feedSourceForUser(_userId: string, _config: unknown, _credentials: unknown) {
callCount++
if (callCount > 1) {
throw new InvalidSourceCredentialsError("test.creds", "invalid token format")
}
return createStubSource("test.creds")
},
}
const { app, sessionManager } = createAppWithEncryptor([provider], MOCK_USER_ID)
await sessionManager.getOrCreate(MOCK_USER_ID)
const res = await putCredentials(app, "test.creds", { token: "bad" })
expect(res.status).toBe(400)
const body = (await res.json()) as { error: string }
expect(body.error).toContain("invalid token format")
})
test("returns 503 when credential encryption is not configured", async () => {
activeStore = createInMemoryStore()
activeStore.seed(MOCK_USER_ID, "aelis.location")
const { app } = createApp([createStubProvider("aelis.location")], MOCK_USER_ID)
const res = await putCredentials(app, "aelis.location", { token: "x" })
expect(res.status).toBe(503)
const body = (await res.json()) as { error: string }
expect(body.error).toContain("not configured")
})
})

View File

@@ -6,7 +6,12 @@ import { createMiddleware } from "hono/factory"
import type { AuthSessionMiddleware } from "../auth/session-middleware.ts" import type { AuthSessionMiddleware } from "../auth/session-middleware.ts"
import type { UserSessionManager } from "../session/index.ts" import type { UserSessionManager } from "../session/index.ts"
import { InvalidSourceConfigError, SourceNotFoundError } from "./errors.ts" import {
CredentialStorageUnavailableError,
InvalidSourceConfigError,
InvalidSourceCredentialsError,
SourceNotFoundError,
} from "./errors.ts"
type Env = { type Env = {
Variables: { Variables: {
@@ -48,6 +53,12 @@ export function registerSourcesHttpHandlers(
app.get("/api/sources/:sourceId", inject, authSessionMiddleware, handleGetSource) app.get("/api/sources/:sourceId", inject, authSessionMiddleware, handleGetSource)
app.patch("/api/sources/:sourceId", inject, authSessionMiddleware, handleUpdateSource) app.patch("/api/sources/:sourceId", inject, authSessionMiddleware, handleUpdateSource)
app.put("/api/sources/:sourceId", inject, authSessionMiddleware, handleReplaceSource) app.put("/api/sources/:sourceId", inject, authSessionMiddleware, handleReplaceSource)
app.put(
"/api/sources/:sourceId/credentials",
inject,
authSessionMiddleware,
handleUpdateCredentials,
)
} }
async function handleGetSource(c: Context<Env>) { async function handleGetSource(c: Context<Env>) {
@@ -171,3 +182,43 @@ async function handleReplaceSource(c: Context<Env>) {
return c.body(null, 204) return c.body(null, 204)
} }
async function handleUpdateCredentials(c: Context<Env>) {
const sourceId = c.req.param("sourceId")
if (!sourceId) {
return c.body(null, 404)
}
const sessionManager = c.get("sessionManager")
const provider = sessionManager.getProvider(sourceId)
if (!provider) {
return c.json({ error: `Source "${sourceId}" not found` }, 404)
}
let body: unknown
try {
body = await c.req.json()
} catch {
return c.json({ error: "Invalid JSON" }, 400)
}
const user = c.get("user")!
try {
await sessionManager.updateSourceCredentials(user.id, sourceId, body)
} catch (err) {
if (err instanceof SourceNotFoundError) {
return c.json({ error: err.message }, 404)
}
if (err instanceof InvalidSourceCredentialsError) {
return c.json({ error: err.message }, 400)
}
if (err instanceof CredentialStorageUnavailableError) {
return c.json({ error: err.message }, 503)
}
throw err
}
return c.body(null, 204)
}

View File

@@ -23,7 +23,11 @@ export class TflSourceProvider implements FeedSourceProvider {
this.client = "client" in options ? options.client : undefined this.client = "client" in options ? options.client : undefined
} }
async feedSourceForUser(_userId: string, config: unknown): Promise<TflSource> { async feedSourceForUser(
_userId: string,
config: unknown,
_credentials: unknown,
): Promise<TflSource> {
const parsed = tflConfig(config) const parsed = tflConfig(config)
if (parsed instanceof type.errors) { if (parsed instanceof type.errors) {
throw new Error(`Invalid TFL config: ${parsed.summary}`) throw new Error(`Invalid TFL config: ${parsed.summary}`)

View File

@@ -26,7 +26,11 @@ export class WeatherSourceProvider implements FeedSourceProvider {
this.client = options.client this.client = options.client
} }
async feedSourceForUser(_userId: string, config: unknown): Promise<WeatherSource> { async feedSourceForUser(
_userId: string,
config: unknown,
_credentials: unknown,
): Promise<WeatherSource> {
const parsed = weatherConfig(config) const parsed = weatherConfig(config)
if (parsed instanceof type.errors) { if (parsed instanceof type.errors) {
throw new Error(`Invalid weather config: ${parsed.summary}`) throw new Error(`Invalid weather config: ${parsed.summary}`)

264
bun.lock
View File

@@ -37,19 +37,11 @@
"tw-animate-css": "^1.4.0", "tw-animate-css": "^1.4.0",
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.39.1",
"@types/node": "^24.10.1", "@types/node": "^24.10.1",
"@types/react": "^19.2.5", "@types/react": "^19.2.5",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1", "@vitejs/plugin-react": "^5.1.1",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"prettier": "^3.8.1",
"prettier-plugin-tailwindcss": "^0.7.2",
"typescript": "~5.9.3", "typescript": "~5.9.3",
"typescript-eslint": "^8.46.4",
"vite": "^7.2.4", "vite": "^7.2.4",
}, },
}, },
@@ -1440,25 +1432,25 @@
"@types/yargs-parser": ["@types/yargs-parser@21.0.3", "", {}, "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ=="], "@types/yargs-parser": ["@types/yargs-parser@21.0.3", "", {}, "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ=="],
"@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.57.1", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.57.1", "@typescript-eslint/type-utils": "8.57.1", "@typescript-eslint/utils": "8.57.1", "@typescript-eslint/visitor-keys": "8.57.1", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.57.1", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-Gn3aqnvNl4NGc6x3/Bqk1AOn0thyTU9bqDRhiRnUWezgvr2OnhYCWCgC8zXXRVqBsIL1pSDt7T9nJUe0oM0kDQ=="], "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.57.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.57.0", "@typescript-eslint/type-utils": "8.57.0", "@typescript-eslint/utils": "8.57.0", "@typescript-eslint/visitor-keys": "8.57.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.57.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-qeu4rTHR3/IaFORbD16gmjq9+rEs9fGKdX0kF6BKSfi+gCuG3RCKLlSBYzn/bGsY9Tj7KE/DAQStbp8AHJGHEQ=="],
"@typescript-eslint/parser": ["@typescript-eslint/parser@8.57.1", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.57.1", "@typescript-eslint/types": "8.57.1", "@typescript-eslint/typescript-estree": "8.57.1", "@typescript-eslint/visitor-keys": "8.57.1", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-k4eNDan0EIMTT/dUKc/g+rsJ6wcHYhNPdY19VoX/EOtaAG8DLtKCykhrUnuHPYvinn5jhAPgD2Qw9hXBwrahsw=="], "@typescript-eslint/parser": ["@typescript-eslint/parser@8.57.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.57.0", "@typescript-eslint/types": "8.57.0", "@typescript-eslint/typescript-estree": "8.57.0", "@typescript-eslint/visitor-keys": "8.57.0", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-XZzOmihLIr8AD1b9hL9ccNMzEMWt/dE2u7NyTY9jJG6YNiNthaD5XtUHVF2uCXZ15ng+z2hT3MVuxnUYhq6k1g=="],
"@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.57.1", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.57.1", "@typescript-eslint/types": "^8.57.1", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-vx1F37BRO1OftsYlmG9xay1TqnjNVlqALymwWVuYTdo18XuKxtBpCj1QlzNIEHlvlB27osvXFWptYiEWsVdYsg=="], "@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.57.0", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.57.0", "@typescript-eslint/types": "^8.57.0", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-pR+dK0BlxCLxtWfaKQWtYr7MhKmzqZxuii+ZjuFlZlIGRZm22HnXFqa2eY+90MUz8/i80YJmzFGDUsi8dMOV5w=="],
"@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.57.1", "", { "dependencies": { "@typescript-eslint/types": "8.57.1", "@typescript-eslint/visitor-keys": "8.57.1" } }, "sha512-hs/QcpCwlwT2L5S+3fT6gp0PabyGk4Q0Rv2doJXA0435/OpnSR3VRgvrp8Xdoc3UAYSg9cyUjTeFXZEPg/3OKg=="], "@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.57.0", "", { "dependencies": { "@typescript-eslint/types": "8.57.0", "@typescript-eslint/visitor-keys": "8.57.0" } }, "sha512-nvExQqAHF01lUM66MskSaZulpPL5pgy5hI5RfrxviLgzZVffB5yYzw27uK/ft8QnKXI2X0LBrHJFr1TaZtAibw=="],
"@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.57.1", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-0lgOZB8cl19fHO4eI46YUx2EceQqhgkPSuCGLlGi79L2jwYY1cxeYc1Nae8Aw1xjgW3PKVDLlr3YJ6Bxx8HkWg=="], "@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.57.0", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-LtXRihc5ytjJIQEH+xqjB0+YgsV4/tW35XKX3GTZHpWtcC8SPkT/d4tqdf1cKtesryHm2bgp6l555NYcT2NLvA=="],
"@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.57.1", "", { "dependencies": { "@typescript-eslint/types": "8.57.1", "@typescript-eslint/typescript-estree": "8.57.1", "@typescript-eslint/utils": "8.57.1", "debug": "^4.4.3", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-+Bwwm0ScukFdyoJsh2u6pp4S9ktegF98pYUU0hkphOOqdMB+1sNQhIz8y5E9+4pOioZijrkfNO/HUJVAFFfPKA=="], "@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.57.0", "", { "dependencies": { "@typescript-eslint/types": "8.57.0", "@typescript-eslint/typescript-estree": "8.57.0", "@typescript-eslint/utils": "8.57.0", "debug": "^4.4.3", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-yjgh7gmDcJ1+TcEg8x3uWQmn8ifvSupnPfjP21twPKrDP/pTHlEQgmKcitzF/rzPSmv7QjJ90vRpN4U+zoUjwQ=="],
"@typescript-eslint/types": ["@typescript-eslint/types@8.57.1", "", {}, "sha512-S29BOBPJSFUiblEl6RzPPjJt6w25A6XsBqRVDt53tA/tlL8q7ceQNZHTjPeONt/3S7KRI4quk+yP9jK2WjBiPQ=="], "@typescript-eslint/types": ["@typescript-eslint/types@8.57.0", "", {}, "sha512-dTLI8PEXhjUC7B9Kre+u0XznO696BhXcTlOn0/6kf1fHaQW8+VjJAVHJ3eTI14ZapTxdkOmc80HblPQLaEeJdg=="],
"@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.57.1", "", { "dependencies": { "@typescript-eslint/project-service": "8.57.1", "@typescript-eslint/tsconfig-utils": "8.57.1", "@typescript-eslint/types": "8.57.1", "@typescript-eslint/visitor-keys": "8.57.1", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-ybe2hS9G6pXpqGtPli9Gx9quNV0TWLOmh58ADlmZe9DguLq0tiAKVjirSbtM1szG6+QH6rVXyU6GTLQbWnMY+g=="], "@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.57.0", "", { "dependencies": { "@typescript-eslint/project-service": "8.57.0", "@typescript-eslint/tsconfig-utils": "8.57.0", "@typescript-eslint/types": "8.57.0", "@typescript-eslint/visitor-keys": "8.57.0", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-m7faHcyVg0BT3VdYTlX8GdJEM7COexXxS6KqGopxdtkQRvBanK377QDHr4W/vIPAR+ah9+B/RclSW5ldVniO1Q=="],
"@typescript-eslint/utils": ["@typescript-eslint/utils@8.57.1", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.57.1", "@typescript-eslint/types": "8.57.1", "@typescript-eslint/typescript-estree": "8.57.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-XUNSJ/lEVFttPMMoDVA2r2bwrl8/oPx8cURtczkSEswY5T3AeLmCy+EKWQNdL4u0MmAHOjcWrqJp2cdvgjn8dQ=="], "@typescript-eslint/utils": ["@typescript-eslint/utils@8.57.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.57.0", "@typescript-eslint/types": "8.57.0", "@typescript-eslint/typescript-estree": "8.57.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-5iIHvpD3CZe06riAsbNxxreP+MuYgVUsV0n4bwLH//VJmgtt54sQeY2GszntJ4BjYCpMzrfVh2SBnUQTtys2lQ=="],
"@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.57.1", "", { "dependencies": { "@typescript-eslint/types": "8.57.1", "eslint-visitor-keys": "^5.0.0" } }, "sha512-YWnmJkXbofiz9KbnbbwuA2rpGkFPLbAIetcCNO6mJ8gdhdZ/v7WDXsoGFAJuM6ikUFKTlSQnjWnVO4ux+UzS6A=="], "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.57.0", "", { "dependencies": { "@typescript-eslint/types": "8.57.0", "eslint-visitor-keys": "^5.0.0" } }, "sha512-zm6xx8UT/Xy2oSr2ZXD0pZo7Jx2XsCoID2IUh9YSTFRu7z+WdwYTRk6LhUftm1crwqbuoF6I8zAFeCMw0YjwDg=="],
"@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="], "@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="],
@@ -1524,7 +1516,7 @@
"agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
"ajv": ["ajv@6.14.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw=="], "ajv": ["ajv@8.11.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2", "uri-js": "^4.2.2" } }, "sha512-wGgprdCvMalC0BztXvitD2hC04YffAvtsUn93JbGXYLAtCUO4xd17mCCZQxUOItiBwZvJScWo8NIvQMQ71rdpg=="],
"ajv-formats": ["ajv-formats@2.1.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA=="], "ajv-formats": ["ajv-formats@2.1.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA=="],
@@ -1658,7 +1650,7 @@
"bplist-parser": ["bplist-parser@0.3.2", "", { "dependencies": { "big-integer": "1.6.x" } }, "sha512-apC2+fspHGI3mMKj+dGevkGo/tCqVB8jMb6i+OX+E29p0Iposz07fABkRIfVUPNd5A5VbuOz1bZbnmkKLYF+wQ=="], "bplist-parser": ["bplist-parser@0.3.2", "", { "dependencies": { "big-integer": "1.6.x" } }, "sha512-apC2+fspHGI3mMKj+dGevkGo/tCqVB8jMb6i+OX+E29p0Iposz07fABkRIfVUPNd5A5VbuOz1bZbnmkKLYF+wQ=="],
"brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="], "brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],
"braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
@@ -1992,9 +1984,7 @@
"eslint-plugin-react": ["eslint-plugin-react@7.37.5", "", { "dependencies": { "array-includes": "^3.1.8", "array.prototype.findlast": "^1.2.5", "array.prototype.flatmap": "^1.3.3", "array.prototype.tosorted": "^1.1.4", "doctrine": "^2.1.0", "es-iterator-helpers": "^1.2.1", "estraverse": "^5.3.0", "hasown": "^2.0.2", "jsx-ast-utils": "^2.4.1 || ^3.0.0", "minimatch": "^3.1.2", "object.entries": "^1.1.9", "object.fromentries": "^2.0.8", "object.values": "^1.2.1", "prop-types": "^15.8.1", "resolve": "^2.0.0-next.5", "semver": "^6.3.1", "string.prototype.matchall": "^4.0.12", "string.prototype.repeat": "^1.0.0" }, "peerDependencies": { "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7" } }, "sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA=="], "eslint-plugin-react": ["eslint-plugin-react@7.37.5", "", { "dependencies": { "array-includes": "^3.1.8", "array.prototype.findlast": "^1.2.5", "array.prototype.flatmap": "^1.3.3", "array.prototype.tosorted": "^1.1.4", "doctrine": "^2.1.0", "es-iterator-helpers": "^1.2.1", "estraverse": "^5.3.0", "hasown": "^2.0.2", "jsx-ast-utils": "^2.4.1 || ^3.0.0", "minimatch": "^3.1.2", "object.entries": "^1.1.9", "object.fromentries": "^2.0.8", "object.values": "^1.2.1", "prop-types": "^15.8.1", "resolve": "^2.0.0-next.5", "semver": "^6.3.1", "string.prototype.matchall": "^4.0.12", "string.prototype.repeat": "^1.0.0" }, "peerDependencies": { "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7" } }, "sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA=="],
"eslint-plugin-react-hooks": ["eslint-plugin-react-hooks@7.0.1", "", { "dependencies": { "@babel/core": "^7.24.4", "@babel/parser": "^7.24.4", "hermes-parser": "^0.25.1", "zod": "^3.25.0 || ^4.0.0", "zod-validation-error": "^3.5.0 || ^4.0.0" }, "peerDependencies": { "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" } }, "sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA=="], "eslint-plugin-react-hooks": ["eslint-plugin-react-hooks@5.2.0", "", { "peerDependencies": { "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" } }, "sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg=="],
"eslint-plugin-react-refresh": ["eslint-plugin-react-refresh@0.4.26", "", { "peerDependencies": { "eslint": ">=8.40" } }, "sha512-1RETEylht2O6FM/MvgnyvT+8K21wLqDNg4qD51Zj3guhjt433XbnnkVttHMyaVyAFD03QSV4LPS5iE3VQmO7XQ=="],
"eslint-scope": ["eslint-scope@8.4.0", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg=="], "eslint-scope": ["eslint-scope@8.4.0", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg=="],
@@ -2268,9 +2258,9 @@
"headers-polyfill": ["headers-polyfill@4.0.3", "", {}, "sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ=="], "headers-polyfill": ["headers-polyfill@4.0.3", "", {}, "sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ=="],
"hermes-estree": ["hermes-estree@0.25.1", "", {}, "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw=="], "hermes-estree": ["hermes-estree@0.29.1", "", {}, "sha512-jl+x31n4/w+wEqm0I2r4CMimukLbLQEYpisys5oCre611CI5fc9TxhqkBBCJ1edDG4Kza0f7CgNz8xVMLZQOmQ=="],
"hermes-parser": ["hermes-parser@0.25.1", "", { "dependencies": { "hermes-estree": "0.25.1" } }, "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA=="], "hermes-parser": ["hermes-parser@0.29.1", "", { "dependencies": { "hermes-estree": "0.29.1" } }, "sha512-xBHWmUtRC5e/UL0tI7Ivt2riA/YBq9+SiYFU7C1oBa/j2jYGlIF9043oak1F47ihuDIxQ5nbsKueYJDRY02UgA=="],
"hoist-non-react-statics": ["hoist-non-react-statics@3.3.2", "", { "dependencies": { "react-is": "^16.7.0" } }, "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw=="], "hoist-non-react-statics": ["hoist-non-react-statics@3.3.2", "", { "dependencies": { "react-is": "^16.7.0" } }, "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw=="],
@@ -2432,7 +2422,7 @@
"isbot": ["isbot@5.1.35", "", {}, "sha512-waFfC72ZNfwLLuJ2iLaoVaqcNo+CAaLR7xCpAn0Y5WfGzkNHv7ZN39Vbi1y+kb+Zs46XHOX3tZNExroFUPX+Kg=="], "isbot": ["isbot@5.1.35", "", {}, "sha512-waFfC72ZNfwLLuJ2iLaoVaqcNo+CAaLR7xCpAn0Y5WfGzkNHv7ZN39Vbi1y+kb+Zs46XHOX3tZNExroFUPX+Kg=="],
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], "isexe": ["isexe@3.1.5", "", {}, "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w=="],
"istanbul-lib-coverage": ["istanbul-lib-coverage@3.2.2", "", {}, "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg=="], "istanbul-lib-coverage": ["istanbul-lib-coverage@3.2.2", "", {}, "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg=="],
@@ -2490,7 +2480,7 @@
"json-parse-even-better-errors": ["json-parse-even-better-errors@2.3.1", "", {}, "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w=="], "json-parse-even-better-errors": ["json-parse-even-better-errors@2.3.1", "", {}, "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w=="],
"json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], "json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
"json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="], "json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="],
@@ -2736,7 +2726,7 @@
"mimic-function": ["mimic-function@5.0.1", "", {}, "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA=="], "mimic-function": ["mimic-function@5.0.1", "", {}, "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA=="],
"minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], "minimatch": ["minimatch@5.1.2", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-bNH9mmM9qsJ2X4r2Nat1B//1dJVcn3+iBLa3IgqJ7EbGaDNepL9QSHOxN4ng33s52VMMhhIfgCYDk3C4ZmlDAg=="],
"minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
@@ -2986,8 +2976,6 @@
"prettier": ["prettier@3.8.1", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg=="], "prettier": ["prettier@3.8.1", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg=="],
"prettier-plugin-tailwindcss": ["prettier-plugin-tailwindcss@0.7.2", "", { "peerDependencies": { "@ianvs/prettier-plugin-sort-imports": "*", "@prettier/plugin-hermes": "*", "@prettier/plugin-oxc": "*", "@prettier/plugin-pug": "*", "@shopify/prettier-plugin-liquid": "*", "@trivago/prettier-plugin-sort-imports": "*", "@zackad/prettier-plugin-twig": "*", "prettier": "^3.0", "prettier-plugin-astro": "*", "prettier-plugin-css-order": "*", "prettier-plugin-jsdoc": "*", "prettier-plugin-marko": "*", "prettier-plugin-multiline-arrays": "*", "prettier-plugin-organize-attributes": "*", "prettier-plugin-organize-imports": "*", "prettier-plugin-sort-imports": "*", "prettier-plugin-svelte": "*" }, "optionalPeers": ["@ianvs/prettier-plugin-sort-imports", "@prettier/plugin-hermes", "@prettier/plugin-oxc", "@prettier/plugin-pug", "@shopify/prettier-plugin-liquid", "@trivago/prettier-plugin-sort-imports", "@zackad/prettier-plugin-twig", "prettier-plugin-astro", "prettier-plugin-css-order", "prettier-plugin-jsdoc", "prettier-plugin-marko", "prettier-plugin-multiline-arrays", "prettier-plugin-organize-attributes", "prettier-plugin-organize-imports", "prettier-plugin-sort-imports", "prettier-plugin-svelte"] }, "sha512-LkphyK3Fw+q2HdMOoiEHWf93fNtYJwfamoKPl7UwtjFQdei/iIBoX11G6j706FzN3ymX9mPVi97qIY8328vdnA=="],
"pretty-bytes": ["pretty-bytes@5.6.0", "", {}, "sha512-FFw039TmrBqFK8ma/7OL3sDz/VytdtJr044/QUJtH0wK9lb9jLq9tJyIxUwtQJHwar2BqtiA4iCWSwo9JLkzFg=="], "pretty-bytes": ["pretty-bytes@5.6.0", "", {}, "sha512-FFw039TmrBqFK8ma/7OL3sDz/VytdtJr044/QUJtH0wK9lb9jLq9tJyIxUwtQJHwar2BqtiA4iCWSwo9JLkzFg=="],
"pretty-format": ["pretty-format@29.7.0", "", { "dependencies": { "@jest/schemas": "^29.6.3", "ansi-styles": "^5.0.0", "react-is": "^18.0.0" } }, "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ=="], "pretty-format": ["pretty-format@29.7.0", "", { "dependencies": { "@jest/schemas": "^29.6.3", "ansi-styles": "^5.0.0", "react-is": "^18.0.0" } }, "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ=="],
@@ -3336,7 +3324,7 @@
"sucrase": ["sucrase@3.35.0", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.2", "commander": "^4.0.0", "glob": "^10.3.10", "lines-and-columns": "^1.1.6", "mz": "^2.7.0", "pirates": "^4.0.1", "ts-interface-checker": "^0.1.9" }, "bin": { "sucrase": "bin/sucrase", "sucrase-node": "bin/sucrase-node" } }, "sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA=="], "sucrase": ["sucrase@3.35.0", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.2", "commander": "^4.0.0", "glob": "^10.3.10", "lines-and-columns": "^1.1.6", "mz": "^2.7.0", "pirates": "^4.0.1", "ts-interface-checker": "^0.1.9" }, "bin": { "sucrase": "bin/sucrase", "sucrase-node": "bin/sucrase-node" } }, "sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA=="],
"supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], "supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="],
"supports-hyperlinks": ["supports-hyperlinks@2.3.0", "", { "dependencies": { "has-flag": "^4.0.0", "supports-color": "^7.0.0" } }, "sha512-RpsAZlpWcDwOPQA22aCH4J0t7L8JmAvsCxfOSEwm7cQs3LshN36QaTkwd70DnBOXDWGssw2eUoc8CaRWT0XunA=="], "supports-hyperlinks": ["supports-hyperlinks@2.3.0", "", { "dependencies": { "has-flag": "^4.0.0", "supports-color": "^7.0.0" } }, "sha512-RpsAZlpWcDwOPQA22aCH4J0t7L8JmAvsCxfOSEwm7cQs3LshN36QaTkwd70DnBOXDWGssw2eUoc8CaRWT0XunA=="],
@@ -3442,8 +3430,6 @@
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"typescript-eslint": ["typescript-eslint@8.57.1", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.57.1", "@typescript-eslint/parser": "8.57.1", "@typescript-eslint/typescript-estree": "8.57.1", "@typescript-eslint/utils": "8.57.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-fLvZWf+cAGw3tqMCYzGIU6yR8K+Y9NT2z23RwOjlNFF2HwSB3KhdEFI5lSBv8tNmFkkBShSjsCjzx1vahZfISA=="],
"ua-parser-js": ["ua-parser-js@1.0.41", "", { "bin": { "ua-parser-js": "script/cli.js" } }, "sha512-LbBDqdIC5s8iROCUjMbW1f5dJQTEFB1+KO9ogbvlb3nm9n4YHa5p4KTvFPWvh2Hs8gZMBuiB1/8+pdfe/tDPug=="], "ua-parser-js": ["ua-parser-js@1.0.41", "", { "bin": { "ua-parser-js": "script/cli.js" } }, "sha512-LbBDqdIC5s8iROCUjMbW1f5dJQTEFB1+KO9ogbvlb3nm9n4YHa5p4KTvFPWvh2Hs8gZMBuiB1/8+pdfe/tDPug=="],
"unbox-primitive": ["unbox-primitive@1.1.0", "", { "dependencies": { "call-bound": "^1.0.3", "has-bigints": "^1.0.2", "has-symbols": "^1.1.0", "which-boxed-primitive": "^1.1.1" } }, "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw=="], "unbox-primitive": ["unbox-primitive@1.1.0", "", { "dependencies": { "call-bound": "^1.0.3", "has-bigints": "^1.0.2", "has-symbols": "^1.1.0", "which-boxed-primitive": "^1.1.1" } }, "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw=="],
@@ -3548,7 +3534,7 @@
"whatwg-url-without-unicode": ["whatwg-url-without-unicode@8.0.0-3", "", { "dependencies": { "buffer": "^5.4.3", "punycode": "^2.1.1", "webidl-conversions": "^5.0.0" } }, "sha512-HoKuzZrUlgpz35YO27XgD28uh/WJH4B0+3ttFqRo//lmq+9T/mIOJ6kqmINI9HpUpz1imRC/nR/lxKpJiv0uig=="], "whatwg-url-without-unicode": ["whatwg-url-without-unicode@8.0.0-3", "", { "dependencies": { "buffer": "^5.4.3", "punycode": "^2.1.1", "webidl-conversions": "^5.0.0" } }, "sha512-HoKuzZrUlgpz35YO27XgD28uh/WJH4B0+3ttFqRo//lmq+9T/mIOJ6kqmINI9HpUpz1imRC/nR/lxKpJiv0uig=="],
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], "which": ["which@4.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg=="],
"which-boxed-primitive": ["which-boxed-primitive@1.1.1", "", { "dependencies": { "is-bigint": "^1.1.0", "is-boolean-object": "^1.2.1", "is-number-object": "^1.1.1", "is-string": "^1.1.1", "is-symbol": "^1.1.1" } }, "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA=="], "which-boxed-primitive": ["which-boxed-primitive@1.1.1", "", { "dependencies": { "is-bigint": "^1.1.0", "is-boolean-object": "^1.2.1", "is-number-object": "^1.1.1", "is-string": "^1.1.1", "is-symbol": "^1.1.1" } }, "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA=="],
@@ -3612,8 +3598,6 @@
"zod-to-json-schema": ["zod-to-json-schema@3.25.1", "", { "peerDependencies": { "zod": "^3.25 || ^4" } }, "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA=="], "zod-to-json-schema": ["zod-to-json-schema@3.25.1", "", { "peerDependencies": { "zod": "^3.25 || ^4" } }, "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA=="],
"zod-validation-error": ["zod-validation-error@4.0.2", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ=="],
"zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="],
"@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], "@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
@@ -3648,16 +3632,20 @@
"@dotenvx/dotenvx/ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], "@dotenvx/dotenvx/ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
"@dotenvx/dotenvx/which": ["which@4.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg=="],
"@ecies/ciphers/@noble/ciphers": ["@noble/ciphers@1.3.0", "", {}, "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw=="], "@ecies/ciphers/@noble/ciphers": ["@noble/ciphers@1.3.0", "", {}, "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw=="],
"@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="], "@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="],
"@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
"@eslint/config-array/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="],
"@eslint/eslintrc/ajv": ["ajv@6.14.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw=="],
"@eslint/eslintrc/globals": ["globals@14.0.0", "", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="], "@eslint/eslintrc/globals": ["globals@14.0.0", "", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="],
"@eslint/eslintrc/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="],
"@expo/bunyan/uuid": ["uuid@8.3.2", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="], "@expo/bunyan/uuid": ["uuid@8.3.2", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="],
"@expo/cli/@expo/code-signing-certificates": ["@expo/code-signing-certificates@0.0.6", "", { "dependencies": { "node-forge": "^1.3.3" } }, "sha512-iNe0puxwBNEcuua9gmTGzq+SuMDa0iATai1FlFTMHJ/vUmKvN/V//drXoLJkVb5i5H3iE/n/qIJxyoBnXouD0w=="], "@expo/cli/@expo/code-signing-certificates": ["@expo/code-signing-certificates@0.0.6", "", { "dependencies": { "node-forge": "^1.3.3" } }, "sha512-iNe0puxwBNEcuua9gmTGzq+SuMDa0iATai1FlFTMHJ/vUmKvN/V//drXoLJkVb5i5H3iE/n/qIJxyoBnXouD0w=="],
@@ -3768,8 +3756,6 @@
"@expo/metro-config/glob": ["glob@13.0.6", "", { "dependencies": { "minimatch": "^10.2.2", "minipass": "^7.1.3", "path-scurry": "^2.0.2" } }, "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw=="], "@expo/metro-config/glob": ["glob@13.0.6", "", { "dependencies": { "minimatch": "^10.2.2", "minipass": "^7.1.3", "path-scurry": "^2.0.2" } }, "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw=="],
"@expo/metro-config/hermes-parser": ["hermes-parser@0.29.1", "", { "dependencies": { "hermes-estree": "0.29.1" } }, "sha512-xBHWmUtRC5e/UL0tI7Ivt2riA/YBq9+SiYFU7C1oBa/j2jYGlIF9043oak1F47ihuDIxQ5nbsKueYJDRY02UgA=="],
"@expo/metro-config/minimatch": ["minimatch@9.0.9", "", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="], "@expo/metro-config/minimatch": ["minimatch@9.0.9", "", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="],
"@expo/metro-config/postcss": ["postcss@8.4.49", "", { "dependencies": { "nanoid": "^3.3.7", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA=="], "@expo/metro-config/postcss": ["postcss@8.4.49", "", { "dependencies": { "nanoid": "^3.3.7", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA=="],
@@ -3854,8 +3840,6 @@
"@oclif/core/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], "@oclif/core/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
"@oclif/core/supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="],
"@prisma/dev/hono": ["hono@4.11.4", "", {}, "sha512-U7tt8JsyrxSRKspfhtLET79pU8K+tInj5QZXs1jSugO1Vq5dFj3kmZsRldo29mTBfcjDRVRXrEZ6LS63Cog9ZA=="], "@prisma/dev/hono": ["hono@4.11.4", "", {}, "sha512-U7tt8JsyrxSRKspfhtLET79pU8K+tInj5QZXs1jSugO1Vq5dFj3kmZsRldo29mTBfcjDRVRXrEZ6LS63Cog9ZA=="],
"@prisma/dev/pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], "@prisma/dev/pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
@@ -3868,8 +3852,6 @@
"@react-native/babel-preset/react-refresh": ["react-refresh@0.14.2", "", {}, "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA=="], "@react-native/babel-preset/react-refresh": ["react-refresh@0.14.2", "", {}, "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA=="],
"@react-native/codegen/hermes-parser": ["hermes-parser@0.29.1", "", { "dependencies": { "hermes-estree": "0.29.1" } }, "sha512-xBHWmUtRC5e/UL0tI7Ivt2riA/YBq9+SiYFU7C1oBa/j2jYGlIF9043oak1F47ihuDIxQ5nbsKueYJDRY02UgA=="],
"@react-native/community-cli-plugin/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], "@react-native/community-cli-plugin/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
"@react-native/dev-middleware/open": ["open@7.4.2", "", { "dependencies": { "is-docker": "^2.0.0", "is-wsl": "^2.1.1" } }, "sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q=="], "@react-native/dev-middleware/open": ["open@7.4.2", "", { "dependencies": { "is-docker": "^2.0.0", "is-wsl": "^2.1.1" } }, "sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q=="],
@@ -3884,8 +3866,6 @@
"@react-router/dev/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], "@react-router/dev/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
"@segment/ajv-human-errors/ajv": ["ajv@8.11.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2", "uri-js": "^4.2.2" } }, "sha512-wGgprdCvMalC0BztXvitD2hC04YffAvtsUn93JbGXYLAtCUO4xd17mCCZQxUOItiBwZvJScWo8NIvQMQ71rdpg=="],
"@swc/helpers/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], "@swc/helpers/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"@tailwindcss/node/jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], "@tailwindcss/node/jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
@@ -3932,8 +3912,6 @@
"aelis-client/react-dom": ["react-dom@19.1.0", "", { "dependencies": { "scheduler": "^0.26.0" }, "peerDependencies": { "react": "^19.1.0" } }, "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g=="], "aelis-client/react-dom": ["react-dom@19.1.0", "", { "dependencies": { "scheduler": "^0.26.0" }, "peerDependencies": { "react": "^19.1.0" } }, "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g=="],
"ajv-formats/ajv": ["ajv@8.11.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2", "uri-js": "^4.2.2" } }, "sha512-wGgprdCvMalC0BztXvitD2hC04YffAvtsUn93JbGXYLAtCUO4xd17mCCZQxUOItiBwZvJScWo8NIvQMQ71rdpg=="],
"ansi-escapes/type-fest": ["type-fest@0.21.3", "", {}, "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w=="], "ansi-escapes/type-fest": ["type-fest@0.21.3", "", {}, "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w=="],
"anymatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], "anymatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
@@ -3942,8 +3920,6 @@
"babel-plugin-polyfill-corejs2/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], "babel-plugin-polyfill-corejs2/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"babel-plugin-syntax-hermes-parser/hermes-parser": ["hermes-parser@0.29.1", "", { "dependencies": { "hermes-estree": "0.29.1" } }, "sha512-xBHWmUtRC5e/UL0tI7Ivt2riA/YBq9+SiYFU7C1oBa/j2jYGlIF9043oak1F47ihuDIxQ5nbsKueYJDRY02UgA=="],
"basic-auth/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="], "basic-auth/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="],
"better-call/set-cookie-parser": ["set-cookie-parser@3.0.1", "", {}, "sha512-n7Z7dXZhJbwuAHhNzkTti6Aw9QDDjZtm3JTpTGATIdNzdQz5GuFs22w90BcvF4INfnrL5xrX3oGsuqO5Dx3A1Q=="], "better-call/set-cookie-parser": ["set-cookie-parser@3.0.1", "", {}, "sha512-n7Z7dXZhJbwuAHhNzkTti6Aw9QDDjZtm3JTpTGATIdNzdQz5GuFs22w90BcvF4INfnrL5xrX3oGsuqO5Dx3A1Q=="],
@@ -3964,6 +3940,8 @@
"c12/pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], "c12/pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
"chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
"chevrotain/lodash": ["lodash@4.17.21", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="], "chevrotain/lodash": ["lodash@4.17.21", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="],
"chrome-launcher/@types/node": ["@types/node@22.19.15", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg=="], "chrome-launcher/@types/node": ["@types/node@22.19.15", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg=="],
@@ -3990,9 +3968,9 @@
"cross-fetch/node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="], "cross-fetch/node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="],
"dotenv-expand/dotenv": ["dotenv@16.4.7", "", {}, "sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ=="], "cross-spawn/which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
"eas-cli/ajv": ["ajv@8.11.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2", "uri-js": "^4.2.2" } }, "sha512-wGgprdCvMalC0BztXvitD2hC04YffAvtsUn93JbGXYLAtCUO4xd17mCCZQxUOItiBwZvJScWo8NIvQMQ71rdpg=="], "dotenv-expand/dotenv": ["dotenv@16.4.7", "", {}, "sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ=="],
"eas-cli/diff": ["diff@7.0.0", "", {}, "sha512-PJWHUb1RFevKCwaFA9RlG5tCd+FO5iRh9A8HEtkmBH2Li03iJriB6m6JIN4rGz3K3JLawI7/veA1xzRKP6ISBw=="], "eas-cli/diff": ["diff@7.0.0", "", {}, "sha512-PJWHUb1RFevKCwaFA9RlG5tCd+FO5iRh9A8HEtkmBH2Li03iJriB6m6JIN4rGz3K3JLawI7/veA1xzRKP6ISBw=="],
@@ -4002,8 +3980,6 @@
"eas-cli/https-proxy-agent": ["https-proxy-agent@5.0.1", "", { "dependencies": { "agent-base": "6", "debug": "4" } }, "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA=="], "eas-cli/https-proxy-agent": ["https-proxy-agent@5.0.1", "", { "dependencies": { "agent-base": "6", "debug": "4" } }, "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA=="],
"eas-cli/minimatch": ["minimatch@5.1.2", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-bNH9mmM9qsJ2X4r2Nat1B//1dJVcn3+iBLa3IgqJ7EbGaDNepL9QSHOxN4ng33s52VMMhhIfgCYDk3C4ZmlDAg=="],
"eas-cli/node-fetch": ["node-fetch@2.6.7", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ=="], "eas-cli/node-fetch": ["node-fetch@2.6.7", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ=="],
"eas-cli/ora": ["ora@5.1.0", "", { "dependencies": { "chalk": "^4.1.0", "cli-cursor": "^3.1.0", "cli-spinners": "^2.4.0", "is-interactive": "^1.0.0", "log-symbols": "^4.0.0", "mute-stream": "0.0.8", "strip-ansi": "^6.0.0", "wcwidth": "^1.0.1" } }, "sha512-9tXIMPvjZ7hPTbk8DFq1f7Kow/HU/pQYB60JbNq+QnGwcyhWVZaQ4hM9zQDEsPxw/muLpgiHSaumUZxCAmod/w=="], "eas-cli/ora": ["ora@5.1.0", "", { "dependencies": { "chalk": "^4.1.0", "cli-cursor": "^3.1.0", "cli-spinners": "^2.4.0", "is-interactive": "^1.0.0", "log-symbols": "^4.0.0", "mute-stream": "0.0.8", "strip-ansi": "^6.0.0", "wcwidth": "^1.0.1" } }, "sha512-9tXIMPvjZ7hPTbk8DFq1f7Kow/HU/pQYB60JbNq+QnGwcyhWVZaQ4hM9zQDEsPxw/muLpgiHSaumUZxCAmod/w=="],
@@ -4012,11 +3988,9 @@
"eciesjs/@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="], "eciesjs/@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="],
"eslint-config-expo/@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.57.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.57.0", "@typescript-eslint/type-utils": "8.57.0", "@typescript-eslint/utils": "8.57.0", "@typescript-eslint/visitor-keys": "8.57.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.57.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-qeu4rTHR3/IaFORbD16gmjq9+rEs9fGKdX0kF6BKSfi+gCuG3RCKLlSBYzn/bGsY9Tj7KE/DAQStbp8AHJGHEQ=="], "eslint/ajv": ["ajv@6.14.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw=="],
"eslint-config-expo/@typescript-eslint/parser": ["@typescript-eslint/parser@8.57.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.57.0", "@typescript-eslint/types": "8.57.0", "@typescript-eslint/typescript-estree": "8.57.0", "@typescript-eslint/visitor-keys": "8.57.0", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-XZzOmihLIr8AD1b9hL9ccNMzEMWt/dE2u7NyTY9jJG6YNiNthaD5XtUHVF2uCXZ15ng+z2hT3MVuxnUYhq6k1g=="], "eslint/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="],
"eslint-config-expo/eslint-plugin-react-hooks": ["eslint-plugin-react-hooks@5.2.0", "", { "peerDependencies": { "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" } }, "sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg=="],
"eslint-import-resolver-node/debug": ["debug@3.2.7", "", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="], "eslint-import-resolver-node/debug": ["debug@3.2.7", "", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="],
@@ -4024,16 +3998,16 @@
"eslint-module-utils/debug": ["debug@3.2.7", "", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="], "eslint-module-utils/debug": ["debug@3.2.7", "", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="],
"eslint-plugin-expo/@typescript-eslint/types": ["@typescript-eslint/types@8.57.0", "", {}, "sha512-dTLI8PEXhjUC7B9Kre+u0XznO696BhXcTlOn0/6kf1fHaQW8+VjJAVHJ3eTI14ZapTxdkOmc80HblPQLaEeJdg=="],
"eslint-plugin-expo/@typescript-eslint/utils": ["@typescript-eslint/utils@8.57.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.57.0", "@typescript-eslint/types": "8.57.0", "@typescript-eslint/typescript-estree": "8.57.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-5iIHvpD3CZe06riAsbNxxreP+MuYgVUsV0n4bwLH//VJmgtt54sQeY2GszntJ4BjYCpMzrfVh2SBnUQTtys2lQ=="],
"eslint-plugin-import/debug": ["debug@3.2.7", "", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="], "eslint-plugin-import/debug": ["debug@3.2.7", "", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="],
"eslint-plugin-import/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="],
"eslint-plugin-import/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], "eslint-plugin-import/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"eslint-plugin-import/tsconfig-paths": ["tsconfig-paths@3.15.0", "", { "dependencies": { "@types/json5": "^0.0.29", "json5": "^1.0.2", "minimist": "^1.2.6", "strip-bom": "^3.0.0" } }, "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg=="], "eslint-plugin-import/tsconfig-paths": ["tsconfig-paths@3.15.0", "", { "dependencies": { "@types/json5": "^0.0.29", "json5": "^1.0.2", "minimist": "^1.2.6", "strip-bom": "^3.0.0" } }, "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg=="],
"eslint-plugin-react/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="],
"eslint-plugin-react/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], "eslint-plugin-react/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"execa/figures": ["figures@6.1.0", "", { "dependencies": { "is-unicode-supported": "^2.0.0" } }, "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg=="], "execa/figures": ["figures@6.1.0", "", { "dependencies": { "is-unicode-supported": "^2.0.0" } }, "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg=="],
@@ -4050,8 +4024,6 @@
"expo-constants/@expo/env": ["@expo/env@2.0.11", "", { "dependencies": { "chalk": "^4.0.0", "debug": "^4.3.4", "dotenv": "~16.4.5", "dotenv-expand": "~11.0.6", "getenv": "^2.0.0" } }, "sha512-xV+ps6YCW7XIPVUwFVCRN2nox09dnRwy8uIjwHWTODu0zFw4kp4omnVkl0OOjuu2XOe7tdgAHxikrkJt9xB/7Q=="], "expo-constants/@expo/env": ["@expo/env@2.0.11", "", { "dependencies": { "chalk": "^4.0.0", "debug": "^4.3.4", "dotenv": "~16.4.5", "dotenv-expand": "~11.0.6", "getenv": "^2.0.0" } }, "sha512-xV+ps6YCW7XIPVUwFVCRN2nox09dnRwy8uIjwHWTODu0zFw4kp4omnVkl0OOjuu2XOe7tdgAHxikrkJt9xB/7Q=="],
"expo-dev-launcher/ajv": ["ajv@8.11.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2", "uri-js": "^4.2.2" } }, "sha512-wGgprdCvMalC0BztXvitD2hC04YffAvtsUn93JbGXYLAtCUO4xd17mCCZQxUOItiBwZvJScWo8NIvQMQ71rdpg=="],
"expo-manifests/@expo/config": ["@expo/config@12.0.13", "", { "dependencies": { "@babel/code-frame": "~7.10.4", "@expo/config-plugins": "~54.0.4", "@expo/config-types": "^54.0.10", "@expo/json-file": "^10.0.8", "deepmerge": "^4.3.1", "getenv": "^2.0.0", "glob": "^13.0.0", "require-from-string": "^2.0.2", "resolve-from": "^5.0.0", "resolve-workspace-root": "^2.0.0", "semver": "^7.6.0", "slugify": "^1.3.4", "sucrase": "~3.35.1" } }, "sha512-Cu52arBa4vSaupIWsF0h7F/Cg//N374nYb7HAxV0I4KceKA7x2UXpYaHOL7EEYYvp7tZdThBjvGpVmr8ScIvaQ=="], "expo-manifests/@expo/config": ["@expo/config@12.0.13", "", { "dependencies": { "@babel/code-frame": "~7.10.4", "@expo/config-plugins": "~54.0.4", "@expo/config-types": "^54.0.10", "@expo/json-file": "^10.0.8", "deepmerge": "^4.3.1", "getenv": "^2.0.0", "glob": "^13.0.0", "require-from-string": "^2.0.2", "resolve-from": "^5.0.0", "resolve-workspace-root": "^2.0.0", "semver": "^7.6.0", "slugify": "^1.3.4", "sucrase": "~3.35.1" } }, "sha512-Cu52arBa4vSaupIWsF0h7F/Cg//N374nYb7HAxV0I4KceKA7x2UXpYaHOL7EEYYvp7tZdThBjvGpVmr8ScIvaQ=="],
"expo-modules-autolinking/commander": ["commander@7.2.0", "", {}, "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw=="], "expo-modules-autolinking/commander": ["commander@7.2.0", "", {}, "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw=="],
@@ -4078,14 +4050,14 @@
"figures/escape-string-regexp": ["escape-string-regexp@1.0.5", "", {}, "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg=="], "figures/escape-string-regexp": ["escape-string-regexp@1.0.5", "", {}, "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg=="],
"filelist/minimatch": ["minimatch@5.1.2", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-bNH9mmM9qsJ2X4r2Nat1B//1dJVcn3+iBLa3IgqJ7EbGaDNepL9QSHOxN4ng33s52VMMhhIfgCYDk3C4ZmlDAg=="],
"finalhandler/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], "finalhandler/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
"framer-motion/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], "framer-motion/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"giget/pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], "giget/pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
"glob/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="],
"globby/fast-glob": ["fast-glob@3.3.2", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.4" } }, "sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow=="], "globby/fast-glob": ["fast-glob@3.3.2", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.4" } }, "sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow=="],
"globby/ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], "globby/ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
@@ -4120,8 +4092,6 @@
"jest-worker/@types/node": ["@types/node@22.19.15", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg=="], "jest-worker/@types/node": ["@types/node@22.19.15", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg=="],
"jest-worker/supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="],
"lighthouse-logger/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], "lighthouse-logger/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
"log-symbols/is-unicode-supported": ["is-unicode-supported@0.1.0", "", {}, "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw=="], "log-symbols/is-unicode-supported": ["is-unicode-supported@0.1.0", "", {}, "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw=="],
@@ -4274,12 +4244,16 @@
"sucrase/glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="], "sucrase/glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="],
"supports-hyperlinks/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
"svix/uuid": ["uuid@10.0.0", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ=="], "svix/uuid": ["uuid@10.0.0", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ=="],
"tar/minizlib": ["minizlib@3.1.0", "", { "dependencies": { "minipass": "^7.1.2" } }, "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw=="], "tar/minizlib": ["minizlib@3.1.0", "", { "dependencies": { "minipass": "^7.1.2" } }, "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw=="],
"terser/commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="], "terser/commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="],
"test-exclude/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="],
"ts-node/arg": ["arg@4.1.3", "", {}, "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA=="], "ts-node/arg": ["arg@4.1.3", "", {}, "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA=="],
"ts-node/diff": ["diff@4.0.4", "", {}, "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ=="], "ts-node/diff": ["diff@4.0.4", "", {}, "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ=="],
@@ -4334,8 +4308,6 @@
"@dotenvx/dotenvx/execa/strip-final-newline": ["strip-final-newline@2.0.0", "", {}, "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA=="], "@dotenvx/dotenvx/execa/strip-final-newline": ["strip-final-newline@2.0.0", "", {}, "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA=="],
"@dotenvx/dotenvx/which/isexe": ["isexe@3.1.5", "", {}, "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.18.20", "", { "os": "android", "cpu": "arm" }, "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw=="], "@esbuild-kit/core-utils/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.18.20", "", { "os": "android", "cpu": "arm" }, "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.18.20", "", { "os": "android", "cpu": "arm64" }, "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ=="], "@esbuild-kit/core-utils/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.18.20", "", { "os": "android", "cpu": "arm64" }, "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ=="],
@@ -4380,6 +4352,12 @@
"@esbuild-kit/core-utils/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.18.20", "", { "os": "win32", "cpu": "x64" }, "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ=="], "@esbuild-kit/core-utils/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.18.20", "", { "os": "win32", "cpu": "x64" }, "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ=="],
"@eslint/config-array/minimatch/brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="],
"@eslint/eslintrc/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
"@eslint/eslintrc/minimatch/brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="],
"@expo/cli/@expo/config/@babel/code-frame": ["@babel/code-frame@7.10.4", "", { "dependencies": { "@babel/highlight": "^7.10.4" } }, "sha512-vG6SvB6oYEhvgisZNFRmRCUkLz11c7rp+tbNTynGqc6mS1d5ATd/sGyV6W0KZZnXRKMTzZDRgQT3Ou9jhpAfUg=="], "@expo/cli/@expo/config/@babel/code-frame": ["@babel/code-frame@7.10.4", "", { "dependencies": { "@babel/highlight": "^7.10.4" } }, "sha512-vG6SvB6oYEhvgisZNFRmRCUkLz11c7rp+tbNTynGqc6mS1d5ATd/sGyV6W0KZZnXRKMTzZDRgQT3Ou9jhpAfUg=="],
"@expo/cli/@expo/config/@expo/config-types": ["@expo/config-types@54.0.10", "", {}, "sha512-/J16SC2an1LdtCZ67xhSkGXpALYUVUNyZws7v+PVsFZxClYehDSoKLqyRaGkpHlYrCc08bS0RF5E0JV6g50psA=="], "@expo/cli/@expo/config/@expo/config-types": ["@expo/config-types@54.0.10", "", {}, "sha512-/J16SC2an1LdtCZ67xhSkGXpALYUVUNyZws7v+PVsFZxClYehDSoKLqyRaGkpHlYrCc08bS0RF5E0JV6g50psA=="],
@@ -4402,8 +4380,6 @@
"@expo/cli/glob/path-scurry": ["path-scurry@2.0.2", "", { "dependencies": { "lru-cache": "^11.0.0", "minipass": "^7.1.2" } }, "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg=="], "@expo/cli/glob/path-scurry": ["path-scurry@2.0.2", "", { "dependencies": { "lru-cache": "^11.0.0", "minipass": "^7.1.2" } }, "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg=="],
"@expo/cli/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],
"@expo/cli/ora/chalk": ["chalk@2.4.2", "", { "dependencies": { "ansi-styles": "^3.2.1", "escape-string-regexp": "^1.0.5", "supports-color": "^5.3.0" } }, "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ=="], "@expo/cli/ora/chalk": ["chalk@2.4.2", "", { "dependencies": { "ansi-styles": "^3.2.1", "escape-string-regexp": "^1.0.5", "supports-color": "^5.3.0" } }, "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ=="],
"@expo/cli/ora/cli-cursor": ["cli-cursor@2.1.0", "", { "dependencies": { "restore-cursor": "^2.0.0" } }, "sha512-8lgKz8LmCRYZZQDpRyT2m5rKJ08TnU4tR9FFFW2rxpxR1FzWi4PQ/NfyODchAatHaUgnSPVcx/R5w6NuTBzFiw=="], "@expo/cli/ora/cli-cursor": ["cli-cursor@2.1.0", "", { "dependencies": { "restore-cursor": "^2.0.0" } }, "sha512-8lgKz8LmCRYZZQDpRyT2m5rKJ08TnU4tR9FFFW2rxpxR1FzWi4PQ/NfyODchAatHaUgnSPVcx/R5w6NuTBzFiw=="],
@@ -4424,8 +4400,6 @@
"@expo/fingerprint/glob/path-scurry": ["path-scurry@2.0.2", "", { "dependencies": { "lru-cache": "^11.0.0", "minipass": "^7.1.2" } }, "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg=="], "@expo/fingerprint/glob/path-scurry": ["path-scurry@2.0.2", "", { "dependencies": { "lru-cache": "^11.0.0", "minipass": "^7.1.2" } }, "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg=="],
"@expo/fingerprint/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],
"@expo/image-utils/fs-extra/universalify": ["universalify@1.0.0", "", {}, "sha512-rb6X1W158d7pRQBg5gkR8uPaSfiids68LTJQYOtEUhoJUWBdaQHsuT/EUduxXYxcrt4r5PJ4fuHW1MHT6p0qug=="], "@expo/image-utils/fs-extra/universalify": ["universalify@1.0.0", "", {}, "sha512-rb6X1W158d7pRQBg5gkR8uPaSfiids68LTJQYOtEUhoJUWBdaQHsuT/EUduxXYxcrt4r5PJ4fuHW1MHT6p0qug=="],
"@expo/metro-config/@babel/code-frame/chalk": ["chalk@2.4.2", "", { "dependencies": { "ansi-styles": "^3.2.1", "escape-string-regexp": "^1.0.5", "supports-color": "^5.3.0" } }, "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ=="], "@expo/metro-config/@babel/code-frame/chalk": ["chalk@2.4.2", "", { "dependencies": { "ansi-styles": "^3.2.1", "escape-string-regexp": "^1.0.5", "supports-color": "^5.3.0" } }, "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ=="],
@@ -4444,10 +4418,6 @@
"@expo/metro-config/glob/path-scurry": ["path-scurry@2.0.2", "", { "dependencies": { "lru-cache": "^11.0.0", "minipass": "^7.1.2" } }, "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg=="], "@expo/metro-config/glob/path-scurry": ["path-scurry@2.0.2", "", { "dependencies": { "lru-cache": "^11.0.0", "minipass": "^7.1.2" } }, "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg=="],
"@expo/metro-config/hermes-parser/hermes-estree": ["hermes-estree@0.29.1", "", {}, "sha512-jl+x31n4/w+wEqm0I2r4CMimukLbLQEYpisys5oCre611CI5fc9TxhqkBBCJ1edDG4Kza0f7CgNz8xVMLZQOmQ=="],
"@expo/metro-config/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],
"@expo/metro-config/postcss/nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], "@expo/metro-config/postcss/nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
"@expo/metro/metro-source-map/ob1": ["ob1@0.83.3", "", { "dependencies": { "flow-enums-runtime": "^0.0.6" } }, "sha512-egUxXCDwoWG06NGCS5s5AdcpnumHKJlfd3HH06P3m9TEMwwScfcY35wpQxbm9oHof+dM/lVH9Rfyu1elTVelSA=="], "@expo/metro/metro-source-map/ob1": ["ob1@0.83.3", "", { "dependencies": { "flow-enums-runtime": "^0.0.6" } }, "sha512-egUxXCDwoWG06NGCS5s5AdcpnumHKJlfd3HH06P3m9TEMwwScfcY35wpQxbm9oHof+dM/lVH9Rfyu1elTVelSA=="],
@@ -4470,16 +4440,12 @@
"@expo/plugin-help/@oclif/core/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], "@expo/plugin-help/@oclif/core/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"@expo/plugin-help/@oclif/core/supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="],
"@expo/plugin-warn-if-update-available/@oclif/core/js-yaml": ["js-yaml@3.14.2", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg=="], "@expo/plugin-warn-if-update-available/@oclif/core/js-yaml": ["js-yaml@3.14.2", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg=="],
"@expo/plugin-warn-if-update-available/@oclif/core/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], "@expo/plugin-warn-if-update-available/@oclif/core/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
"@expo/plugin-warn-if-update-available/@oclif/core/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], "@expo/plugin-warn-if-update-available/@oclif/core/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"@expo/plugin-warn-if-update-available/@oclif/core/supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="],
"@expo/prebuild-config/@expo/json-file/@babel/code-frame": ["@babel/code-frame@7.10.4", "", { "dependencies": { "@babel/highlight": "^7.10.4" } }, "sha512-vG6SvB6oYEhvgisZNFRmRCUkLz11c7rp+tbNTynGqc6mS1d5ATd/sGyV6W0KZZnXRKMTzZDRgQT3Ou9jhpAfUg=="], "@expo/prebuild-config/@expo/json-file/@babel/code-frame": ["@babel/code-frame@7.10.4", "", { "dependencies": { "@babel/highlight": "^7.10.4" } }, "sha512-vG6SvB6oYEhvgisZNFRmRCUkLz11c7rp+tbNTynGqc6mS1d5ATd/sGyV6W0KZZnXRKMTzZDRgQT3Ou9jhpAfUg=="],
"@expo/xcpretty/@babel/code-frame/chalk": ["chalk@2.4.2", "", { "dependencies": { "ansi-styles": "^3.2.1", "escape-string-regexp": "^1.0.5", "supports-color": "^5.3.0" } }, "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ=="], "@expo/xcpretty/@babel/code-frame/chalk": ["chalk@2.4.2", "", { "dependencies": { "ansi-styles": "^3.2.1", "escape-string-regexp": "^1.0.5", "supports-color": "^5.3.0" } }, "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ=="],
@@ -4504,8 +4470,6 @@
"@jest/types/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], "@jest/types/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
"@modelcontextprotocol/sdk/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
"@modelcontextprotocol/sdk/express/accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], "@modelcontextprotocol/sdk/express/accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="],
"@modelcontextprotocol/sdk/express/body-parser": ["body-parser@2.2.2", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="], "@modelcontextprotocol/sdk/express/body-parser": ["body-parser@2.2.2", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="],
@@ -4536,12 +4500,8 @@
"@oclif/core/string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], "@oclif/core/string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"@react-native/codegen/hermes-parser/hermes-estree": ["hermes-estree@0.29.1", "", {}, "sha512-jl+x31n4/w+wEqm0I2r4CMimukLbLQEYpisys5oCre611CI5fc9TxhqkBBCJ1edDG4Kza0f7CgNz8xVMLZQOmQ=="],
"@react-native/dev-middleware/open/is-docker": ["is-docker@2.2.1", "", { "bin": { "is-docker": "cli.js" } }, "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ=="], "@react-native/dev-middleware/open/is-docker": ["is-docker@2.2.1", "", { "bin": { "is-docker": "cli.js" } }, "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ=="],
"@segment/ajv-human-errors/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
"@tailwindcss/node/lightningcss/lightningcss-android-arm64": ["lightningcss-android-arm64@1.31.1", "", { "os": "android", "cpu": "arm64" }, "sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg=="], "@tailwindcss/node/lightningcss/lightningcss-android-arm64": ["lightningcss-android-arm64@1.31.1", "", { "os": "android", "cpu": "arm64" }, "sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg=="],
"@tailwindcss/node/lightningcss/lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.31.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-02uTEqf3vIfNMq3h/z2cJfcOXnQ0GRwQrkmPafhueLb2h7mqEidiCzkE4gBMEH65abHRiQvhdcQ+aP0D0g67sg=="], "@tailwindcss/node/lightningcss/lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.31.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-02uTEqf3vIfNMq3h/z2cJfcOXnQ0GRwQrkmPafhueLb2h7mqEidiCzkE4gBMEH65abHRiQvhdcQ+aP0D0g67sg=="],
@@ -4588,10 +4548,6 @@
"aelis-client/react-dom/scheduler": ["scheduler@0.26.0", "", {}, "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA=="], "aelis-client/react-dom/scheduler": ["scheduler@0.26.0", "", {}, "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA=="],
"ajv-formats/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
"babel-plugin-syntax-hermes-parser/hermes-parser/hermes-estree": ["hermes-estree@0.29.1", "", {}, "sha512-jl+x31n4/w+wEqm0I2r4CMimukLbLQEYpisys5oCre611CI5fc9TxhqkBBCJ1edDG4Kza0f7CgNz8xVMLZQOmQ=="],
"better-opn/open/define-lazy-prop": ["define-lazy-prop@2.0.0", "", {}, "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og=="], "better-opn/open/define-lazy-prop": ["define-lazy-prop@2.0.0", "", {}, "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og=="],
"better-opn/open/is-docker": ["is-docker@2.2.1", "", { "bin": { "is-docker": "cli.js" } }, "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ=="], "better-opn/open/is-docker": ["is-docker@2.2.1", "", { "bin": { "is-docker": "cli.js" } }, "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ=="],
@@ -4620,44 +4576,28 @@
"connect/finalhandler/statuses": ["statuses@1.5.0", "", {}, "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA=="], "connect/finalhandler/statuses": ["statuses@1.5.0", "", {}, "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA=="],
"eas-cli/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], "cross-spawn/which/isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
"eas-cli/fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], "eas-cli/fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
"eas-cli/https-proxy-agent/agent-base": ["agent-base@6.0.2", "", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="], "eas-cli/https-proxy-agent/agent-base": ["agent-base@6.0.2", "", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="],
"eas-cli/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],
"eas-cli/ora/cli-cursor": ["cli-cursor@3.1.0", "", { "dependencies": { "restore-cursor": "^3.1.0" } }, "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw=="], "eas-cli/ora/cli-cursor": ["cli-cursor@3.1.0", "", { "dependencies": { "restore-cursor": "^3.1.0" } }, "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw=="],
"eas-cli/ora/is-interactive": ["is-interactive@1.0.0", "", {}, "sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w=="], "eas-cli/ora/is-interactive": ["is-interactive@1.0.0", "", {}, "sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w=="],
"eas-cli/ora/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], "eas-cli/ora/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"eslint-config-expo/@typescript-eslint/eslint-plugin/@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.57.0", "", { "dependencies": { "@typescript-eslint/types": "8.57.0", "@typescript-eslint/visitor-keys": "8.57.0" } }, "sha512-nvExQqAHF01lUM66MskSaZulpPL5pgy5hI5RfrxviLgzZVffB5yYzw27uK/ft8QnKXI2X0LBrHJFr1TaZtAibw=="], "eslint-plugin-import/minimatch/brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="],
"eslint-config-expo/@typescript-eslint/eslint-plugin/@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.57.0", "", { "dependencies": { "@typescript-eslint/types": "8.57.0", "@typescript-eslint/typescript-estree": "8.57.0", "@typescript-eslint/utils": "8.57.0", "debug": "^4.4.3", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-yjgh7gmDcJ1+TcEg8x3uWQmn8ifvSupnPfjP21twPKrDP/pTHlEQgmKcitzF/rzPSmv7QjJ90vRpN4U+zoUjwQ=="],
"eslint-config-expo/@typescript-eslint/eslint-plugin/@typescript-eslint/utils": ["@typescript-eslint/utils@8.57.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.57.0", "@typescript-eslint/types": "8.57.0", "@typescript-eslint/typescript-estree": "8.57.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-5iIHvpD3CZe06riAsbNxxreP+MuYgVUsV0n4bwLH//VJmgtt54sQeY2GszntJ4BjYCpMzrfVh2SBnUQTtys2lQ=="],
"eslint-config-expo/@typescript-eslint/eslint-plugin/@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.57.0", "", { "dependencies": { "@typescript-eslint/types": "8.57.0", "eslint-visitor-keys": "^5.0.0" } }, "sha512-zm6xx8UT/Xy2oSr2ZXD0pZo7Jx2XsCoID2IUh9YSTFRu7z+WdwYTRk6LhUftm1crwqbuoF6I8zAFeCMw0YjwDg=="],
"eslint-config-expo/@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="],
"eslint-config-expo/@typescript-eslint/parser/@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.57.0", "", { "dependencies": { "@typescript-eslint/types": "8.57.0", "@typescript-eslint/visitor-keys": "8.57.0" } }, "sha512-nvExQqAHF01lUM66MskSaZulpPL5pgy5hI5RfrxviLgzZVffB5yYzw27uK/ft8QnKXI2X0LBrHJFr1TaZtAibw=="],
"eslint-config-expo/@typescript-eslint/parser/@typescript-eslint/types": ["@typescript-eslint/types@8.57.0", "", {}, "sha512-dTLI8PEXhjUC7B9Kre+u0XznO696BhXcTlOn0/6kf1fHaQW8+VjJAVHJ3eTI14ZapTxdkOmc80HblPQLaEeJdg=="],
"eslint-config-expo/@typescript-eslint/parser/@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.57.0", "", { "dependencies": { "@typescript-eslint/project-service": "8.57.0", "@typescript-eslint/tsconfig-utils": "8.57.0", "@typescript-eslint/types": "8.57.0", "@typescript-eslint/visitor-keys": "8.57.0", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-m7faHcyVg0BT3VdYTlX8GdJEM7COexXxS6KqGopxdtkQRvBanK377QDHr4W/vIPAR+ah9+B/RclSW5ldVniO1Q=="],
"eslint-config-expo/@typescript-eslint/parser/@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.57.0", "", { "dependencies": { "@typescript-eslint/types": "8.57.0", "eslint-visitor-keys": "^5.0.0" } }, "sha512-zm6xx8UT/Xy2oSr2ZXD0pZo7Jx2XsCoID2IUh9YSTFRu7z+WdwYTRk6LhUftm1crwqbuoF6I8zAFeCMw0YjwDg=="],
"eslint-plugin-expo/@typescript-eslint/utils/@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.57.0", "", { "dependencies": { "@typescript-eslint/types": "8.57.0", "@typescript-eslint/visitor-keys": "8.57.0" } }, "sha512-nvExQqAHF01lUM66MskSaZulpPL5pgy5hI5RfrxviLgzZVffB5yYzw27uK/ft8QnKXI2X0LBrHJFr1TaZtAibw=="],
"eslint-plugin-expo/@typescript-eslint/utils/@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.57.0", "", { "dependencies": { "@typescript-eslint/project-service": "8.57.0", "@typescript-eslint/tsconfig-utils": "8.57.0", "@typescript-eslint/types": "8.57.0", "@typescript-eslint/visitor-keys": "8.57.0", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-m7faHcyVg0BT3VdYTlX8GdJEM7COexXxS6KqGopxdtkQRvBanK377QDHr4W/vIPAR+ah9+B/RclSW5ldVniO1Q=="],
"eslint-plugin-import/tsconfig-paths/json5": ["json5@1.0.2", "", { "dependencies": { "minimist": "^1.2.0" }, "bin": { "json5": "lib/cli.js" } }, "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA=="], "eslint-plugin-import/tsconfig-paths/json5": ["json5@1.0.2", "", { "dependencies": { "minimist": "^1.2.0" }, "bin": { "json5": "lib/cli.js" } }, "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA=="],
"eslint-plugin-react/minimatch/brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="],
"eslint/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
"eslint/minimatch/brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="],
"expo-asset/@expo/image-utils/getenv": ["getenv@2.0.0", "", {}, "sha512-VilgtJj/ALgGY77fiLam5iD336eSWi96Q15JSAG1zi8NRBysm3LXKdGnHb4m5cuyxvOLQQKWpBZAT6ni4FI2iQ=="], "expo-asset/@expo/image-utils/getenv": ["getenv@2.0.0", "", {}, "sha512-VilgtJj/ALgGY77fiLam5iD336eSWi96Q15JSAG1zi8NRBysm3LXKdGnHb4m5cuyxvOLQQKWpBZAT6ni4FI2iQ=="],
"expo-asset/@expo/image-utils/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], "expo-asset/@expo/image-utils/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
@@ -4682,8 +4622,6 @@
"expo-constants/@expo/env/getenv": ["getenv@2.0.0", "", {}, "sha512-VilgtJj/ALgGY77fiLam5iD336eSWi96Q15JSAG1zi8NRBysm3LXKdGnHb4m5cuyxvOLQQKWpBZAT6ni4FI2iQ=="], "expo-constants/@expo/env/getenv": ["getenv@2.0.0", "", {}, "sha512-VilgtJj/ALgGY77fiLam5iD336eSWi96Q15JSAG1zi8NRBysm3LXKdGnHb4m5cuyxvOLQQKWpBZAT6ni4FI2iQ=="],
"expo-dev-launcher/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
"expo-manifests/@expo/config/@babel/code-frame": ["@babel/code-frame@7.10.4", "", { "dependencies": { "@babel/highlight": "^7.10.4" } }, "sha512-vG6SvB6oYEhvgisZNFRmRCUkLz11c7rp+tbNTynGqc6mS1d5ATd/sGyV6W0KZZnXRKMTzZDRgQT3Ou9jhpAfUg=="], "expo-manifests/@expo/config/@babel/code-frame": ["@babel/code-frame@7.10.4", "", { "dependencies": { "@babel/highlight": "^7.10.4" } }, "sha512-vG6SvB6oYEhvgisZNFRmRCUkLz11c7rp+tbNTynGqc6mS1d5ATd/sGyV6W0KZZnXRKMTzZDRgQT3Ou9jhpAfUg=="],
"expo-manifests/@expo/config/@expo/config-plugins": ["@expo/config-plugins@54.0.4", "", { "dependencies": { "@expo/config-types": "^54.0.10", "@expo/json-file": "~10.0.8", "@expo/plist": "^0.4.8", "@expo/sdk-runtime-versions": "^1.0.0", "chalk": "^4.1.2", "debug": "^4.3.5", "getenv": "^2.0.0", "glob": "^13.0.0", "resolve-from": "^5.0.0", "semver": "^7.5.4", "slash": "^3.0.0", "slugify": "^1.6.6", "xcode": "^3.0.1", "xml2js": "0.6.0" } }, "sha512-g2yXGICdoOw5i3LkQSDxl2Q5AlQCrG7oniu0pCPPO+UxGb7He4AFqSvPSy8HpRUj55io17hT62FTjYRD+d6j3Q=="], "expo-manifests/@expo/config/@expo/config-plugins": ["@expo/config-plugins@54.0.4", "", { "dependencies": { "@expo/config-types": "^54.0.10", "@expo/json-file": "~10.0.8", "@expo/plist": "^0.4.8", "@expo/sdk-runtime-versions": "^1.0.0", "chalk": "^4.1.2", "debug": "^4.3.5", "getenv": "^2.0.0", "glob": "^13.0.0", "resolve-from": "^5.0.0", "semver": "^7.5.4", "slash": "^3.0.0", "slugify": "^1.6.6", "xcode": "^3.0.1", "xml2js": "0.6.0" } }, "sha512-g2yXGICdoOw5i3LkQSDxl2Q5AlQCrG7oniu0pCPPO+UxGb7He4AFqSvPSy8HpRUj55io17hT62FTjYRD+d6j3Q=="],
@@ -4742,10 +4680,10 @@
"fbjs/cross-fetch/node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="], "fbjs/cross-fetch/node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="],
"filelist/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],
"finalhandler/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], "finalhandler/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
"glob/minimatch/brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="],
"globby/fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], "globby/fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
"jest-environment-node/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], "jest-environment-node/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
@@ -4790,6 +4728,8 @@
"sucrase/glob/minimatch": ["minimatch@9.0.9", "", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="], "sucrase/glob/minimatch": ["minimatch@9.0.9", "", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="],
"test-exclude/minimatch/brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="],
"twrnc/tailwindcss/chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="], "twrnc/tailwindcss/chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="],
"twrnc/tailwindcss/fast-glob": ["fast-glob@3.3.2", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.4" } }, "sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow=="], "twrnc/tailwindcss/fast-glob": ["fast-glob@3.3.2", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.4" } }, "sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow=="],
@@ -4890,10 +4830,6 @@
"@expo/cli/ora/strip-ansi/ansi-regex": ["ansi-regex@4.1.1", "", {}, "sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g=="], "@expo/cli/ora/strip-ansi/ansi-regex": ["ansi-regex@4.1.1", "", {}, "sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g=="],
"@expo/config-plugins/glob/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],
"@expo/config/glob/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],
"@expo/eas-json/@babel/code-frame/chalk/ansi-styles": ["ansi-styles@3.2.1", "", { "dependencies": { "color-convert": "^1.9.0" } }, "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA=="], "@expo/eas-json/@babel/code-frame/chalk/ansi-styles": ["ansi-styles@3.2.1", "", { "dependencies": { "color-convert": "^1.9.0" } }, "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA=="],
"@expo/eas-json/@babel/code-frame/chalk/escape-string-regexp": ["escape-string-regexp@1.0.5", "", {}, "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg=="], "@expo/eas-json/@babel/code-frame/chalk/escape-string-regexp": ["escape-string-regexp@1.0.5", "", {}, "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg=="],
@@ -4962,42 +4898,6 @@
"eas-cli/ora/cli-cursor/restore-cursor": ["restore-cursor@3.1.0", "", { "dependencies": { "onetime": "^5.1.0", "signal-exit": "^3.0.2" } }, "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA=="], "eas-cli/ora/cli-cursor/restore-cursor": ["restore-cursor@3.1.0", "", { "dependencies": { "onetime": "^5.1.0", "signal-exit": "^3.0.2" } }, "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA=="],
"eslint-config-expo/@typescript-eslint/eslint-plugin/@typescript-eslint/scope-manager/@typescript-eslint/types": ["@typescript-eslint/types@8.57.0", "", {}, "sha512-dTLI8PEXhjUC7B9Kre+u0XznO696BhXcTlOn0/6kf1fHaQW8+VjJAVHJ3eTI14ZapTxdkOmc80HblPQLaEeJdg=="],
"eslint-config-expo/@typescript-eslint/eslint-plugin/@typescript-eslint/type-utils/@typescript-eslint/types": ["@typescript-eslint/types@8.57.0", "", {}, "sha512-dTLI8PEXhjUC7B9Kre+u0XznO696BhXcTlOn0/6kf1fHaQW8+VjJAVHJ3eTI14ZapTxdkOmc80HblPQLaEeJdg=="],
"eslint-config-expo/@typescript-eslint/eslint-plugin/@typescript-eslint/type-utils/@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.57.0", "", { "dependencies": { "@typescript-eslint/project-service": "8.57.0", "@typescript-eslint/tsconfig-utils": "8.57.0", "@typescript-eslint/types": "8.57.0", "@typescript-eslint/visitor-keys": "8.57.0", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-m7faHcyVg0BT3VdYTlX8GdJEM7COexXxS6KqGopxdtkQRvBanK377QDHr4W/vIPAR+ah9+B/RclSW5ldVniO1Q=="],
"eslint-config-expo/@typescript-eslint/eslint-plugin/@typescript-eslint/utils/@typescript-eslint/types": ["@typescript-eslint/types@8.57.0", "", {}, "sha512-dTLI8PEXhjUC7B9Kre+u0XznO696BhXcTlOn0/6kf1fHaQW8+VjJAVHJ3eTI14ZapTxdkOmc80HblPQLaEeJdg=="],
"eslint-config-expo/@typescript-eslint/eslint-plugin/@typescript-eslint/utils/@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.57.0", "", { "dependencies": { "@typescript-eslint/project-service": "8.57.0", "@typescript-eslint/tsconfig-utils": "8.57.0", "@typescript-eslint/types": "8.57.0", "@typescript-eslint/visitor-keys": "8.57.0", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-m7faHcyVg0BT3VdYTlX8GdJEM7COexXxS6KqGopxdtkQRvBanK377QDHr4W/vIPAR+ah9+B/RclSW5ldVniO1Q=="],
"eslint-config-expo/@typescript-eslint/eslint-plugin/@typescript-eslint/visitor-keys/@typescript-eslint/types": ["@typescript-eslint/types@8.57.0", "", {}, "sha512-dTLI8PEXhjUC7B9Kre+u0XznO696BhXcTlOn0/6kf1fHaQW8+VjJAVHJ3eTI14ZapTxdkOmc80HblPQLaEeJdg=="],
"eslint-config-expo/@typescript-eslint/eslint-plugin/@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="],
"eslint-config-expo/@typescript-eslint/parser/@typescript-eslint/typescript-estree/@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.57.0", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.57.0", "@typescript-eslint/types": "^8.57.0", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-pR+dK0BlxCLxtWfaKQWtYr7MhKmzqZxuii+ZjuFlZlIGRZm22HnXFqa2eY+90MUz8/i80YJmzFGDUsi8dMOV5w=="],
"eslint-config-expo/@typescript-eslint/parser/@typescript-eslint/typescript-estree/@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.57.0", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-LtXRihc5ytjJIQEH+xqjB0+YgsV4/tW35XKX3GTZHpWtcC8SPkT/d4tqdf1cKtesryHm2bgp6l555NYcT2NLvA=="],
"eslint-config-expo/@typescript-eslint/parser/@typescript-eslint/typescript-estree/minimatch": ["minimatch@10.2.4", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg=="],
"eslint-config-expo/@typescript-eslint/parser/@typescript-eslint/typescript-estree/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
"eslint-config-expo/@typescript-eslint/parser/@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="],
"eslint-plugin-expo/@typescript-eslint/utils/@typescript-eslint/scope-manager/@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.57.0", "", { "dependencies": { "@typescript-eslint/types": "8.57.0", "eslint-visitor-keys": "^5.0.0" } }, "sha512-zm6xx8UT/Xy2oSr2ZXD0pZo7Jx2XsCoID2IUh9YSTFRu7z+WdwYTRk6LhUftm1crwqbuoF6I8zAFeCMw0YjwDg=="],
"eslint-plugin-expo/@typescript-eslint/utils/@typescript-eslint/typescript-estree/@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.57.0", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.57.0", "@typescript-eslint/types": "^8.57.0", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-pR+dK0BlxCLxtWfaKQWtYr7MhKmzqZxuii+ZjuFlZlIGRZm22HnXFqa2eY+90MUz8/i80YJmzFGDUsi8dMOV5w=="],
"eslint-plugin-expo/@typescript-eslint/utils/@typescript-eslint/typescript-estree/@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.57.0", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-LtXRihc5ytjJIQEH+xqjB0+YgsV4/tW35XKX3GTZHpWtcC8SPkT/d4tqdf1cKtesryHm2bgp6l555NYcT2NLvA=="],
"eslint-plugin-expo/@typescript-eslint/utils/@typescript-eslint/typescript-estree/@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.57.0", "", { "dependencies": { "@typescript-eslint/types": "8.57.0", "eslint-visitor-keys": "^5.0.0" } }, "sha512-zm6xx8UT/Xy2oSr2ZXD0pZo7Jx2XsCoID2IUh9YSTFRu7z+WdwYTRk6LhUftm1crwqbuoF6I8zAFeCMw0YjwDg=="],
"eslint-plugin-expo/@typescript-eslint/utils/@typescript-eslint/typescript-estree/minimatch": ["minimatch@10.2.4", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg=="],
"eslint-plugin-expo/@typescript-eslint/utils/@typescript-eslint/typescript-estree/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
"expo-constants/@expo/config/@expo/config-plugins/@expo/plist": ["@expo/plist@0.4.8", "", { "dependencies": { "@xmldom/xmldom": "^0.8.8", "base64-js": "^1.2.3", "xmlbuilder": "^15.1.1" } }, "sha512-pfNtErGGzzRwHP+5+RqswzPDKkZrx+Cli0mzjQaus1ZWFsog5ibL+nVT3NcporW51o8ggnt7x813vtRbPiyOrQ=="], "expo-constants/@expo/config/@expo/config-plugins/@expo/plist": ["@expo/plist@0.4.8", "", { "dependencies": { "@xmldom/xmldom": "^0.8.8", "base64-js": "^1.2.3", "xmlbuilder": "^15.1.1" } }, "sha512-pfNtErGGzzRwHP+5+RqswzPDKkZrx+Cli0mzjQaus1ZWFsog5ibL+nVT3NcporW51o8ggnt7x813vtRbPiyOrQ=="],
"expo-constants/@expo/config/@expo/json-file/@babel/code-frame": ["@babel/code-frame@7.23.5", "", { "dependencies": { "@babel/highlight": "^7.23.4", "chalk": "^2.4.2" } }, "sha512-CgH3s1a96LipHCmSUmYFPwY7MNx8C3avkq7i4Wl3cfa662ldtUe4VM1TPXX70pfmrlWTb6jLqTYrZyT2ZTJBgA=="], "expo-constants/@expo/config/@expo/json-file/@babel/code-frame": ["@babel/code-frame@7.23.5", "", { "dependencies": { "@babel/highlight": "^7.23.4", "chalk": "^2.4.2" } }, "sha512-CgH3s1a96LipHCmSUmYFPwY7MNx8C3avkq7i4Wl3cfa662ldtUe4VM1TPXX70pfmrlWTb6jLqTYrZyT2ZTJBgA=="],
@@ -5054,12 +4954,10 @@
"expo/@expo/config/sucrase/commander": ["commander@4.1.1", "", {}, "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA=="], "expo/@expo/config/sucrase/commander": ["commander@4.1.1", "", {}, "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA=="],
"mv/rimraf/glob/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="],
"pkg-dir/find-up/locate-path/p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="], "pkg-dir/find-up/locate-path/p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="],
"rimraf/glob/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],
"sucrase/glob/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],
"twrnc/tailwindcss/chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], "twrnc/tailwindcss/chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
"twrnc/tailwindcss/chokidar/readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="], "twrnc/tailwindcss/chokidar/readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="],
@@ -5126,30 +5024,6 @@
"eas-cli/ora/cli-cursor/restore-cursor/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], "eas-cli/ora/cli-cursor/restore-cursor/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="],
"eslint-config-expo/@typescript-eslint/eslint-plugin/@typescript-eslint/type-utils/@typescript-eslint/typescript-estree/@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.57.0", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.57.0", "@typescript-eslint/types": "^8.57.0", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-pR+dK0BlxCLxtWfaKQWtYr7MhKmzqZxuii+ZjuFlZlIGRZm22HnXFqa2eY+90MUz8/i80YJmzFGDUsi8dMOV5w=="],
"eslint-config-expo/@typescript-eslint/eslint-plugin/@typescript-eslint/type-utils/@typescript-eslint/typescript-estree/@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.57.0", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-LtXRihc5ytjJIQEH+xqjB0+YgsV4/tW35XKX3GTZHpWtcC8SPkT/d4tqdf1cKtesryHm2bgp6l555NYcT2NLvA=="],
"eslint-config-expo/@typescript-eslint/eslint-plugin/@typescript-eslint/type-utils/@typescript-eslint/typescript-estree/minimatch": ["minimatch@10.2.4", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg=="],
"eslint-config-expo/@typescript-eslint/eslint-plugin/@typescript-eslint/type-utils/@typescript-eslint/typescript-estree/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
"eslint-config-expo/@typescript-eslint/eslint-plugin/@typescript-eslint/utils/@typescript-eslint/typescript-estree/@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.57.0", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.57.0", "@typescript-eslint/types": "^8.57.0", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-pR+dK0BlxCLxtWfaKQWtYr7MhKmzqZxuii+ZjuFlZlIGRZm22HnXFqa2eY+90MUz8/i80YJmzFGDUsi8dMOV5w=="],
"eslint-config-expo/@typescript-eslint/eslint-plugin/@typescript-eslint/utils/@typescript-eslint/typescript-estree/@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.57.0", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-LtXRihc5ytjJIQEH+xqjB0+YgsV4/tW35XKX3GTZHpWtcC8SPkT/d4tqdf1cKtesryHm2bgp6l555NYcT2NLvA=="],
"eslint-config-expo/@typescript-eslint/eslint-plugin/@typescript-eslint/utils/@typescript-eslint/typescript-estree/minimatch": ["minimatch@10.2.4", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg=="],
"eslint-config-expo/@typescript-eslint/eslint-plugin/@typescript-eslint/utils/@typescript-eslint/typescript-estree/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
"eslint-config-expo/@typescript-eslint/parser/@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@5.0.4", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg=="],
"eslint-plugin-expo/@typescript-eslint/utils/@typescript-eslint/scope-manager/@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="],
"eslint-plugin-expo/@typescript-eslint/utils/@typescript-eslint/typescript-estree/@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="],
"eslint-plugin-expo/@typescript-eslint/utils/@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@5.0.4", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg=="],
"expo-constants/@expo/config/@expo/config-plugins/@expo/plist/@xmldom/xmldom": ["@xmldom/xmldom@0.8.11", "", {}, "sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw=="], "expo-constants/@expo/config/@expo/config-plugins/@expo/plist/@xmldom/xmldom": ["@xmldom/xmldom@0.8.11", "", {}, "sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw=="],
"expo-constants/@expo/config/@expo/config-plugins/@expo/plist/xmlbuilder": ["xmlbuilder@15.1.1", "", {}, "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg=="], "expo-constants/@expo/config/@expo/config-plugins/@expo/plist/xmlbuilder": ["xmlbuilder@15.1.1", "", {}, "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg=="],
@@ -5198,6 +5072,8 @@
"expo/@expo/config/glob/path-scurry/lru-cache": ["lru-cache@11.2.6", "", {}, "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ=="], "expo/@expo/config/glob/path-scurry/lru-cache": ["lru-cache@11.2.6", "", {}, "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ=="],
"mv/rimraf/glob/minimatch/brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="],
"pkg-dir/find-up/locate-path/p-locate/p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="], "pkg-dir/find-up/locate-path/p-locate/p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="],
"twrnc/tailwindcss/chokidar/readdirp/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], "twrnc/tailwindcss/chokidar/readdirp/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
@@ -5224,14 +5100,6 @@
"@expo/xcpretty/@babel/code-frame/chalk/ansi-styles/color-convert/color-name": ["color-name@1.1.3", "", {}, "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw=="], "@expo/xcpretty/@babel/code-frame/chalk/ansi-styles/color-convert/color-name": ["color-name@1.1.3", "", {}, "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw=="],
"eslint-config-expo/@typescript-eslint/eslint-plugin/@typescript-eslint/type-utils/@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@5.0.4", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg=="],
"eslint-config-expo/@typescript-eslint/eslint-plugin/@typescript-eslint/utils/@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@5.0.4", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg=="],
"eslint-config-expo/@typescript-eslint/parser/@typescript-eslint/typescript-estree/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
"eslint-plugin-expo/@typescript-eslint/utils/@typescript-eslint/typescript-estree/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
"expo-constants/@expo/config/@expo/json-file/@babel/code-frame/chalk/ansi-styles": ["ansi-styles@3.2.1", "", { "dependencies": { "color-convert": "^1.9.0" } }, "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA=="], "expo-constants/@expo/config/@expo/json-file/@babel/code-frame/chalk/ansi-styles": ["ansi-styles@3.2.1", "", { "dependencies": { "color-convert": "^1.9.0" } }, "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA=="],
"expo-constants/@expo/config/@expo/json-file/@babel/code-frame/chalk/escape-string-regexp": ["escape-string-regexp@1.0.5", "", {}, "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg=="], "expo-constants/@expo/config/@expo/json-file/@babel/code-frame/chalk/escape-string-regexp": ["escape-string-regexp@1.0.5", "", {}, "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg=="],
@@ -5282,10 +5150,6 @@
"@expo/package-manager/@expo/json-file/@babel/code-frame/chalk/ansi-styles/color-convert/color-name": ["color-name@1.1.3", "", {}, "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw=="], "@expo/package-manager/@expo/json-file/@babel/code-frame/chalk/ansi-styles/color-convert/color-name": ["color-name@1.1.3", "", {}, "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw=="],
"eslint-config-expo/@typescript-eslint/eslint-plugin/@typescript-eslint/type-utils/@typescript-eslint/typescript-estree/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
"eslint-config-expo/@typescript-eslint/eslint-plugin/@typescript-eslint/utils/@typescript-eslint/typescript-estree/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
"expo-constants/@expo/config/@expo/json-file/@babel/code-frame/chalk/ansi-styles/color-convert": ["color-convert@1.9.3", "", { "dependencies": { "color-name": "1.1.3" } }, "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg=="], "expo-constants/@expo/config/@expo/json-file/@babel/code-frame/chalk/ansi-styles/color-convert": ["color-convert@1.9.3", "", { "dependencies": { "color-name": "1.1.3" } }, "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg=="],
"expo-constants/@expo/config/@expo/json-file/@babel/code-frame/chalk/supports-color/has-flag": ["has-flag@3.0.0", "", {}, "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw=="], "expo-constants/@expo/config/@expo/json-file/@babel/code-frame/chalk/supports-color/has-flag": ["has-flag@3.0.0", "", {}, "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw=="],

View File

@@ -10,5 +10,7 @@ export {
type TflAlertSeverity, type TflAlertSeverity,
type TflLineStatus, type TflLineStatus,
type TflSourceOptions, type TflSourceOptions,
type TflStatusData,
type TflStatusFeedItem,
} from "./types.ts" } from "./types.ts"
export { renderTflAlert } from "./renderer.tsx" export { renderTflStatus } from "./renderer.tsx"

View File

@@ -2,102 +2,140 @@
import { render } from "@nym.sh/jrx" import { render } from "@nym.sh/jrx"
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import type { TflAlertFeedItem } from "./types.ts" import type { TflAlertData, TflStatusFeedItem } from "./types.ts"
import { renderTflAlert } from "./renderer.tsx" import { renderTflStatus } from "./renderer.tsx"
function makeItem(overrides: Partial<TflAlertFeedItem["data"]> = {}): TflAlertFeedItem { function makeAlert(overrides: Partial<TflAlertData> = {}): TflAlertData {
return { return {
id: "tfl-alert-northern-minor-delays", line: "northern",
type: "tfl-alert", lineName: "Northern",
timestamp: new Date("2026-01-15T12:00:00Z"), severity: "minor-delays",
data: { description: "Minor delays due to signal failure",
line: "northern", closestStationDistance: null,
lineName: "Northern", ...overrides,
severity: "minor-delays",
description: "Minor delays due to signal failure",
closestStationDistance: null,
...overrides,
},
} }
} }
describe("renderTflAlert", () => { function makeItem(alerts: TflAlertData[]): TflStatusFeedItem {
test("renders a FeedCard with title and description", () => { return {
const node = renderTflAlert(makeItem()) id: "tfl-status",
sourceId: "aelis.tfl",
type: "tfl-status",
timestamp: new Date("2026-01-15T12:00:00Z"),
data: { alerts },
}
}
/** Collect all SansSerifText elements from a rendered spec, filtering out Fragments. */
function collectTextElements(spec: ReturnType<typeof render>) {
return Object.values(spec.elements).filter((el) => el.type === "SansSerifText")
}
describe("renderTflStatus", () => {
test("renders a single FeedCard", () => {
const node = renderTflStatus(makeItem([makeAlert()]))
const spec = render(node) const spec = render(node)
const root = spec.elements[spec.root]! const root = spec.elements[spec.root]!
expect(root.type).toBe("FeedCard") expect(root.type).toBe("FeedCard")
expect(root.children!.length).toBeGreaterThanOrEqual(2) })
const title = spec.elements[root.children![0]!]! test("renders one alert with title and description", () => {
expect(title.type).toBe("SansSerifText") const node = renderTflStatus(makeItem([makeAlert()]))
expect(title.props.content).toBe("Northern · Minor delays") const spec = render(node)
const body = spec.elements[root.children![1]!]! const texts = collectTextElements(spec)
expect(body.type).toBe("SansSerifText") const titleText = texts.find((el) => el.props.content === "Northern · Minor delays")
expect(body.props.content).toBe("Minor delays due to signal failure") const bodyText = texts.find((el) => el.props.content === "Minor delays due to signal failure")
expect(titleText).toBeDefined()
expect(bodyText).toBeDefined()
})
test("renders multiple alerts stacked in one card", () => {
const alerts = [
makeAlert({ line: "northern", lineName: "Northern", severity: "minor-delays" }),
makeAlert({
line: "central",
lineName: "Central",
severity: "closure",
description: "Closed due to strike",
}),
]
const node = renderTflStatus(makeItem(alerts))
const spec = render(node)
const root = spec.elements[spec.root]!
expect(root.type).toBe("FeedCard")
const texts = collectTextElements(spec)
const northernTitle = texts.find((el) => el.props.content === "Northern · Minor delays")
const centralTitle = texts.find((el) => el.props.content === "Central · Closed")
const centralBody = texts.find((el) => el.props.content === "Closed due to strike")
expect(northernTitle).toBeDefined()
expect(centralTitle).toBeDefined()
expect(centralBody).toBeDefined()
}) })
test("shows nearest station distance when available", () => { test("shows nearest station distance when available", () => {
const node = renderTflAlert(makeItem({ closestStationDistance: 0.35 })) const node = renderTflStatus(makeItem([makeAlert({ closestStationDistance: 0.35 })]))
const spec = render(node) const spec = render(node)
const root = spec.elements[spec.root]! const texts = collectTextElements(spec)
expect(root.children).toHaveLength(3) const caption = texts.find((el) => el.props.content === "Nearest station: 350m away")
expect(caption).toBeDefined()
const caption = spec.elements[root.children![2]!]!
expect(caption.type).toBe("SansSerifText")
expect(caption.props.content).toBe("Nearest station: 350m away")
}) })
test("formats distance in km when >= 1km", () => { test("formats distance in km when >= 1km", () => {
const node = renderTflAlert(makeItem({ closestStationDistance: 2.456 })) const node = renderTflStatus(makeItem([makeAlert({ closestStationDistance: 2.456 })]))
const spec = render(node) const spec = render(node)
const root = spec.elements[spec.root]! const texts = collectTextElements(spec)
const caption = spec.elements[root.children![2]!]! const caption = texts.find((el) => el.props.content === "Nearest station: 2.5km away")
expect(caption.props.content).toBe("Nearest station: 2.5km away") expect(caption).toBeDefined()
}) })
test("formats near-1km boundary as km not meters", () => { test("formats near-1km boundary as km not meters", () => {
const node = renderTflAlert(makeItem({ closestStationDistance: 0.9999 })) const node = renderTflStatus(makeItem([makeAlert({ closestStationDistance: 0.9999 })]))
const spec = render(node) const spec = render(node)
const root = spec.elements[spec.root]! const texts = collectTextElements(spec)
const caption = spec.elements[root.children![2]!]! const caption = texts.find((el) => el.props.content === "Nearest station: 1.0km away")
expect(caption.props.content).toBe("Nearest station: 1.0km away") expect(caption).toBeDefined()
}) })
test("omits station distance when null", () => { test("omits station distance when null", () => {
const node = renderTflAlert(makeItem({ closestStationDistance: null })) const node = renderTflStatus(makeItem([makeAlert({ closestStationDistance: null })]))
const spec = render(node) const spec = render(node)
const root = spec.elements[spec.root]! const texts = collectTextElements(spec)
// Title + body only, no caption (empty fragment doesn't produce a child) const distanceTexts = texts.filter((el) =>
const children = root.children!.filter((key) => { (el.props.content as string).startsWith("Nearest station:"),
const el = spec.elements[key] )
return el && el.type !== "Fragment" expect(distanceTexts).toHaveLength(0)
})
expect(children).toHaveLength(2)
}) })
test("renders closure severity label", () => { test("renders closure severity label", () => {
const node = renderTflAlert(makeItem({ severity: "closure", lineName: "Central" })) const node = renderTflStatus(
makeItem([makeAlert({ severity: "closure", lineName: "Central" })]),
)
const spec = render(node) const spec = render(node)
const root = spec.elements[spec.root]! const texts = collectTextElements(spec)
const title = spec.elements[root.children![0]!]! const title = texts.find((el) => el.props.content === "Central · Closed")
expect(title.props.content).toBe("Central · Closed") expect(title).toBeDefined()
}) })
test("renders major delays severity label", () => { test("renders major delays severity label", () => {
const node = renderTflAlert(makeItem({ severity: "major-delays", lineName: "Jubilee" })) const node = renderTflStatus(
makeItem([makeAlert({ severity: "major-delays", lineName: "Jubilee" })]),
)
const spec = render(node) const spec = render(node)
const root = spec.elements[spec.root]! const texts = collectTextElements(spec)
const title = spec.elements[root.children![0]!]! const title = texts.find((el) => el.props.content === "Jubilee · Major delays")
expect(title.props.content).toBe("Jubilee · Major delays") expect(title).toBeDefined()
}) })
}) })

View File

@@ -3,7 +3,7 @@ import type { FeedItemRenderer } from "@aelis/core"
import { FeedCard, SansSerifText } from "@aelis/components" import { FeedCard, SansSerifText } from "@aelis/components"
import type { TflAlertData } from "./types.ts" import type { TflAlertData, TflStatusData } from "./types.ts"
import { TflAlertSeverity } from "./types.ts" import { TflAlertSeverity } from "./types.ts"
@@ -21,20 +21,26 @@ function formatDistance(km: number): string {
return `${(meters / 1000).toFixed(1)}km away` return `${(meters / 1000).toFixed(1)}km away`
} }
export const renderTflAlert: FeedItemRenderer<"tfl-alert", TflAlertData> = (item) => { function renderAlertRow(alert: TflAlertData) {
const { lineName, severity, description, closestStationDistance } = item.data const severityLabel = SEVERITY_LABEL[alert.severity]
const severityLabel = SEVERITY_LABEL[severity]
return ( return (
<FeedCard> <>
<SansSerifText content={`${lineName} · ${severityLabel}`} style="text-base font-semibold" /> <SansSerifText
<SansSerifText content={description} style="text-sm" /> content={`${alert.lineName} · ${severityLabel}`}
{closestStationDistance !== null ? ( style="text-base font-semibold"
/>
<SansSerifText content={alert.description} style="text-sm" />
{alert.closestStationDistance !== null ? (
<SansSerifText <SansSerifText
content={`Nearest station: ${formatDistance(closestStationDistance)}`} content={`Nearest station: ${formatDistance(alert.closestStationDistance)}`}
style="text-xs text-stone-500" style="text-xs text-stone-500"
/> />
) : null} ) : null}
</FeedCard> </>
) )
} }
export const renderTflStatus: FeedItemRenderer<"tfl-status", TflStatusData> = (item) => {
return <FeedCard>{item.data.alerts.map((alert) => renderAlertRow(alert))}</FeedCard>
}

View File

@@ -69,7 +69,7 @@ export class TflApi {
} }
async fetchLineStatuses(lines?: TflLineId[]): Promise<TflLineStatus[]> { async fetchLineStatuses(lines?: TflLineId[]): Promise<TflLineStatus[]> {
const lineIds = lines ?? ALL_LINE_IDS const lineIds = lines?.length ? lines : ALL_LINE_IDS
const data = await this.fetch<unknown>(`/Line/${lineIds.join(",")}/Status`) const data = await this.fetch<unknown>(`/Line/${lineIds.join(",")}/Status`)
const parsed = lineResponseArray(data) const parsed = lineResponseArray(data)
@@ -101,8 +101,8 @@ export class TflApi {
return this.stationsCache return this.stationsCache
} }
// Fetch stations for all lines in parallel // Fetch stations for all lines in parallel, tolerating individual failures
const responses = await Promise.all( const results = await Promise.allSettled(
ALL_LINE_IDS.map(async (id) => { ALL_LINE_IDS.map(async (id) => {
const data = await this.fetch<unknown>(`/Line/${id}/StopPoints`) const data = await this.fetch<unknown>(`/Line/${id}/StopPoints`)
const parsed = lineStopPointsArray(data) const parsed = lineStopPointsArray(data)
@@ -116,7 +116,12 @@ export class TflApi {
// Merge stations, combining lines for shared stations // Merge stations, combining lines for shared stations
const stationMap = new Map<string, StationLocation>() const stationMap = new Map<string, StationLocation>()
for (const { lineId: currentLineId, stops } of responses) { for (const result of results) {
if (result.status === "rejected") {
continue
}
const { lineId: currentLineId, stops } = result.value
for (const stop of stops) { for (const stop of stops) {
const existing = stationMap.get(stop.naptanId) const existing = stationMap.get(stop.naptanId)
if (existing) { if (existing) {
@@ -135,8 +140,15 @@ export class TflApi {
} }
} }
this.stationsCache = Array.from(stationMap.values()) // Only cache if all requests succeeded — partial results shouldn't persist
return this.stationsCache const allSucceeded = results.every((r) => r.status === "fulfilled")
const stations = Array.from(stationMap.values())
if (allSucceeded) {
this.stationsCache = stations
}
return stations
} }
} }

View File

@@ -138,13 +138,15 @@ describe("TflSource", () => {
test("changes which lines are fetched", async () => { test("changes which lines are fetched", async () => {
const source = new TflSource({ client: lineFilteringApi }) const source = new TflSource({ client: lineFilteringApi })
const before = await source.fetchItems(createContext()) const before = await source.fetchItems(createContext())
expect(before.length).toBe(2) expect(before).toHaveLength(1)
expect(before[0]!.data.alerts).toHaveLength(2)
source.setLinesOfInterest(["northern"]) source.setLinesOfInterest(["northern"])
const after = await source.fetchItems(createContext()) const after = await source.fetchItems(createContext())
expect(after.length).toBe(1) expect(after).toHaveLength(1)
expect(after[0]!.data.line).toBe("northern") expect(after[0]!.data.alerts).toHaveLength(1)
expect(after[0]!.data.alerts[0]!.line).toBe("northern")
}) })
test("DEFAULT_LINES_OF_INTEREST restores all lines", async () => { test("DEFAULT_LINES_OF_INTEREST restores all lines", async () => {
@@ -153,23 +155,52 @@ describe("TflSource", () => {
lines: ["northern"], lines: ["northern"],
}) })
const filtered = await source.fetchItems(createContext()) const filtered = await source.fetchItems(createContext())
expect(filtered.length).toBe(1) expect(filtered[0]!.data.alerts).toHaveLength(1)
source.setLinesOfInterest([...TflSource.DEFAULT_LINES_OF_INTEREST]) source.setLinesOfInterest([...TflSource.DEFAULT_LINES_OF_INTEREST])
const all = await source.fetchItems(createContext()) const all = await source.fetchItems(createContext())
expect(all.length).toBe(2) expect(all[0]!.data.alerts).toHaveLength(2)
}) })
}) })
describe("fetchItems", () => { describe("fetchItems", () => {
test("returns feed items array", async () => { test("returns at most one feed item", async () => {
const source = new TflSource({ client: api }) const source = new TflSource({ client: api })
const items = await source.fetchItems(createContext()) const items = await source.fetchItems(createContext())
expect(Array.isArray(items)).toBe(true) expect(items).toHaveLength(1)
}) })
test("feed items have correct base structure", async () => { test("returns empty array when no disruptions", async () => {
const emptyApi: ITflApi = {
async fetchLineStatuses(): Promise<TflLineStatus[]> {
return []
},
async fetchStations(): Promise<StationLocation[]> {
return []
},
}
const source = new TflSource({ client: emptyApi })
const items = await source.fetchItems(createContext())
expect(items).toHaveLength(0)
})
test("combined item has correct base structure", async () => {
const source = new TflSource({ client: api })
const items = await source.fetchItems(createContext())
const item = items[0]!
expect(item.id).toBe("tfl-status")
expect(item.type).toBe("tfl-status")
expect(item.sourceId).toBe("aelis.tfl")
expect(item.signals).toBeDefined()
expect(typeof item.signals!.urgency).toBe("number")
expect(item.timestamp).toBeInstanceOf(Date)
expect(Array.isArray(item.data.alerts)).toBe(true)
expect(item.data.alerts.length).toBeGreaterThan(0)
})
test("alerts have correct data structure", async () => {
const source = new TflSource({ client: api }) const source = new TflSource({ client: api })
const location: Location = { const location: Location = {
lat: 51.5074, lat: 51.5074,
@@ -178,72 +209,140 @@ describe("TflSource", () => {
timestamp: new Date(), timestamp: new Date(),
} }
const items = await source.fetchItems(createContext(location)) const items = await source.fetchItems(createContext(location))
const alerts = items[0]!.data.alerts
for (const item of items) { for (const alert of alerts) {
expect(typeof item.id).toBe("string") expect(typeof alert.line).toBe("string")
expect(item.id).toMatch(/^tfl-alert-/) expect(typeof alert.lineName).toBe("string")
expect(item.type).toBe("tfl-alert") expect(["minor-delays", "major-delays", "closure"]).toContain(alert.severity)
expect(item.signals).toBeDefined() expect(typeof alert.description).toBe("string")
expect(typeof item.signals!.urgency).toBe("number")
expect(item.timestamp).toBeInstanceOf(Date)
}
})
test("feed items have correct data structure", async () => {
const source = new TflSource({ client: api })
const location: Location = {
lat: 51.5074,
lng: -0.1278,
accuracy: 10,
timestamp: new Date(),
}
const items = await source.fetchItems(createContext(location))
for (const item of items) {
expect(typeof item.data.line).toBe("string")
expect(typeof item.data.lineName).toBe("string")
expect(["minor-delays", "major-delays", "closure"]).toContain(item.data.severity)
expect(typeof item.data.description).toBe("string")
expect( expect(
item.data.closestStationDistance === null || alert.closestStationDistance === null || typeof alert.closestStationDistance === "number",
typeof item.data.closestStationDistance === "number",
).toBe(true) ).toBe(true)
} }
}) })
test("feed item ids are unique", async () => { test("signals use highest severity urgency", async () => {
const source = new TflSource({ client: api }) const mixedApi: ITflApi = {
async fetchLineStatuses(): Promise<TflLineStatus[]> {
return [
{
lineId: "northern",
lineName: "Northern",
severity: "minor-delays",
description: "Minor delays",
},
{
lineId: "central",
lineName: "Central",
severity: "closure",
description: "Closed",
},
{
lineId: "jubilee",
lineName: "Jubilee",
severity: "major-delays",
description: "Major delays",
},
]
},
async fetchStations(): Promise<StationLocation[]> {
return []
},
}
const source = new TflSource({ client: mixedApi })
const items = await source.fetchItems(createContext()) const items = await source.fetchItems(createContext())
const ids = items.map((item) => item.id) expect(items[0]!.signals!.urgency).toBe(1.0) // closure urgency
const uniqueIds = new Set(ids) expect(items[0]!.signals!.timeRelevance).toBe("imminent") // closure time relevance
expect(uniqueIds.size).toBe(ids.length)
}) })
test("feed items are sorted by urgency descending", async () => { test("signals use single alert severity when only one disruption", async () => {
const source = new TflSource({ client: api }) const singleApi: ITflApi = {
async fetchLineStatuses(): Promise<TflLineStatus[]> {
return [
{
lineId: "northern",
lineName: "Northern",
severity: "minor-delays",
description: "Minor delays",
},
]
},
async fetchStations(): Promise<StationLocation[]> {
return []
},
}
const source = new TflSource({ client: singleApi })
const items = await source.fetchItems(createContext()) const items = await source.fetchItems(createContext())
for (let i = 1; i < items.length; i++) { expect(items[0]!.signals!.urgency).toBe(0.6) // minor-delays urgency
const prev = items[i - 1]! expect(items[0]!.signals!.timeRelevance).toBe("upcoming")
const curr = items[i]!
expect(prev.signals!.urgency).toBeGreaterThanOrEqual(curr.signals!.urgency!)
}
}) })
test("urgency values match severity levels", async () => { test("alerts sorted by closestStationDistance ascending, nulls last", async () => {
const source = new TflSource({ client: api }) const distanceApi: ITflApi = {
const items = await source.fetchItems(createContext()) async fetchLineStatuses(): Promise<TflLineStatus[]> {
return [
{
lineId: "northern",
lineName: "Northern",
severity: "minor-delays",
description: "Delays",
},
{
lineId: "central",
lineName: "Central",
severity: "minor-delays",
description: "Delays",
},
{
lineId: "jubilee",
lineName: "Jubilee",
severity: "minor-delays",
description: "Delays",
},
]
},
async fetchStations(): Promise<StationLocation[]> {
return [
{ id: "s1", name: "Station A", lat: 51.51, lng: -0.13, lines: ["central"] },
{ id: "s2", name: "Station B", lat: 51.52, lng: -0.14, lines: ["northern"] },
// No stations for jubilee — its distance will be null
]
},
}
const source = new TflSource({ client: distanceApi })
const location: Location = {
lat: 51.5074,
lng: -0.1278,
accuracy: 10,
timestamp: new Date(),
}
const items = await source.fetchItems(createContext(location))
const alerts = items[0]!.data.alerts
const severityUrgency: Record<string, number> = { // Alerts with distances should come before nulls
closure: 1.0, const withDistance = alerts.filter((a) => a.closestStationDistance !== null)
"major-delays": 0.8, const withoutDistance = alerts.filter((a) => a.closestStationDistance === null)
"minor-delays": 0.6,
// All distance alerts come first
const firstNullIndex = alerts.findIndex((a) => a.closestStationDistance === null)
if (firstNullIndex !== -1) {
for (let i = 0; i < firstNullIndex; i++) {
expect(alerts[i]!.closestStationDistance).not.toBeNull()
}
} }
for (const item of items) { // Distance alerts are in ascending order
expect(item.signals!.urgency).toBe(severityUrgency[item.data.severity]!) for (let i = 1; i < withDistance.length; i++) {
expect(withDistance[i]!.closestStationDistance!).toBeGreaterThanOrEqual(
withDistance[i - 1]!.closestStationDistance!,
)
} }
expect(withoutDistance.length).toBe(1)
expect(withoutDistance[0]!.line).toBe("jubilee")
}) })
test("closestStationDistance is number when location provided", async () => { test("closestStationDistance is number when location provided", async () => {
@@ -256,9 +355,9 @@ describe("TflSource", () => {
} }
const items = await source.fetchItems(createContext(location)) const items = await source.fetchItems(createContext(location))
for (const item of items) { for (const alert of items[0]!.data.alerts) {
expect(typeof item.data.closestStationDistance).toBe("number") expect(typeof alert.closestStationDistance).toBe("number")
expect(item.data.closestStationDistance!).toBeGreaterThan(0) expect(alert.closestStationDistance!).toBeGreaterThan(0)
} }
}) })
@@ -266,8 +365,8 @@ describe("TflSource", () => {
const source = new TflSource({ client: api }) const source = new TflSource({ client: api })
const items = await source.fetchItems(createContext()) const items = await source.fetchItems(createContext())
for (const item of items) { for (const alert of items[0]!.data.alerts) {
expect(item.data.closestStationDistance).toBeNull() expect(alert.closestStationDistance).toBeNull()
} }
}) })
}) })
@@ -309,8 +408,9 @@ describe("TflSource", () => {
await source.executeAction("set-lines-of-interest", ["northern"]) await source.executeAction("set-lines-of-interest", ["northern"])
const items = await source.fetchItems(createContext()) const items = await source.fetchItems(createContext())
expect(items.length).toBe(1) expect(items).toHaveLength(1)
expect(items[0]!.data.line).toBe("northern") expect(items[0]!.data.alerts).toHaveLength(1)
expect(items[0]!.data.alerts[0]!.line).toBe("northern")
}) })
test("executeAction throws on invalid input", async () => { test("executeAction throws on invalid input", async () => {

View File

@@ -8,10 +8,10 @@ import type {
ITflApi, ITflApi,
StationLocation, StationLocation,
TflAlertData, TflAlertData,
TflAlertFeedItem,
TflAlertSeverity, TflAlertSeverity,
TflLineId, TflLineId,
TflSourceOptions, TflSourceOptions,
TflStatusFeedItem,
} from "./types.ts" } from "./types.ts"
import { TflApi, lineId } from "./tfl-api.ts" import { TflApi, lineId } from "./tfl-api.ts"
@@ -51,7 +51,7 @@ const SEVERITY_TIME_RELEVANCE: Record<TflAlertSeverity, TimeRelevance> = {
* const { items } = await engine.refresh() * const { items } = await engine.refresh()
* ``` * ```
*/ */
export class TflSource implements FeedSource<TflAlertFeedItem> { export class TflSource implements FeedSource<TflStatusFeedItem> {
static readonly DEFAULT_LINES_OF_INTEREST: readonly TflLineId[] = [ static readonly DEFAULT_LINES_OF_INTEREST: readonly TflLineId[] = [
"bakerloo", "bakerloo",
"central", "central",
@@ -84,7 +84,7 @@ export class TflSource implements FeedSource<TflAlertFeedItem> {
throw new Error("Either client or apiKey must be provided") throw new Error("Either client or apiKey must be provided")
} }
this.client = options.client ?? new TflApi(options.apiKey!) this.client = options.client ?? new TflApi(options.apiKey!)
this.lines = options.lines ?? [...TflSource.DEFAULT_LINES_OF_INTEREST] this.lines = options.lines?.length ? options.lines : [...TflSource.DEFAULT_LINES_OF_INTEREST]
} }
async listActions(): Promise<Record<string, ActionDefinition>> { async listActions(): Promise<Record<string, ActionDefinition>> {
@@ -123,56 +123,58 @@ export class TflSource implements FeedSource<TflAlertFeedItem> {
this.lines = lines this.lines = lines
} }
async fetchItems(context: Context): Promise<TflAlertFeedItem[]> { async fetchItems(context: Context): Promise<TflStatusFeedItem[]> {
const [statuses, stations] = await Promise.all([ const [statuses, stations] = await Promise.all([
this.client.fetchLineStatuses(this.lines), this.client.fetchLineStatuses(this.lines),
this.client.fetchStations(), this.client.fetchStations(),
]) ])
if (statuses.length === 0) {
return []
}
const location = context.get(LocationKey) const location = context.get(LocationKey)
const items: TflAlertFeedItem[] = statuses.map((status) => { const alerts: TflAlertData[] = statuses.map((status) => ({
const closestStationDistance = location line: status.lineId,
lineName: status.lineName,
severity: status.severity,
description: status.description,
closestStationDistance: location
? findClosestStationDistance(status.lineId, stations, location.lat, location.lng) ? findClosestStationDistance(status.lineId, stations, location.lat, location.lng)
: null : null,
}))
const data: TflAlertData = { // Sort by closest station distance ascending, nulls last
line: status.lineId, alerts.sort((a, b) => {
lineName: status.lineName, if (a.closestStationDistance === null && b.closestStationDistance === null) return 0
severity: status.severity, if (a.closestStationDistance === null) return 1
description: status.description, if (b.closestStationDistance === null) return -1
closestStationDistance, return a.closestStationDistance - b.closestStationDistance
} })
const signals: FeedItemSignals = { // Signals from the highest-severity alert
urgency: SEVERITY_URGENCY[status.severity], const highestSeverity = alerts.reduce<TflAlertSeverity>(
timeRelevance: SEVERITY_TIME_RELEVANCE[status.severity], (worst, alert) =>
} SEVERITY_URGENCY[alert.severity] > SEVERITY_URGENCY[worst] ? alert.severity : worst,
alerts[0]!.severity,
)
return { const signals: FeedItemSignals = {
id: `tfl-alert-${status.lineId}-${status.severity}`, urgency: SEVERITY_URGENCY[highestSeverity],
timeRelevance: SEVERITY_TIME_RELEVANCE[highestSeverity],
}
return [
{
id: "tfl-status",
sourceId: this.id, sourceId: this.id,
type: TflFeedItemType.Alert, type: TflFeedItemType.Status,
timestamp: context.time, timestamp: context.time,
data, data: { alerts },
signals, signals,
} },
}) ]
// Sort by urgency (desc), then by proximity (asc) if location available
items.sort((a, b) => {
const aUrgency = a.signals?.urgency ?? 0
const bUrgency = b.signals?.urgency ?? 0
if (bUrgency !== aUrgency) {
return bUrgency - aUrgency
}
if (a.data.closestStationDistance !== null && b.data.closestStationDistance !== null) {
return a.data.closestStationDistance - b.data.closestStationDistance
}
return 0
})
return items
} }
} }

View File

@@ -22,12 +22,19 @@ export interface TflAlertData extends Record<string, unknown> {
export const TflFeedItemType = { export const TflFeedItemType = {
Alert: "tfl-alert", Alert: "tfl-alert",
Status: "tfl-status",
} as const } as const
export type TflFeedItemType = (typeof TflFeedItemType)[keyof typeof TflFeedItemType] export type TflFeedItemType = (typeof TflFeedItemType)[keyof typeof TflFeedItemType]
export type TflAlertFeedItem = FeedItem<typeof TflFeedItemType.Alert, TflAlertData> export type TflAlertFeedItem = FeedItem<typeof TflFeedItemType.Alert, TflAlertData>
export interface TflStatusData extends Record<string, unknown> {
alerts: TflAlertData[]
}
export type TflStatusFeedItem = FeedItem<typeof TflFeedItemType.Status, TflStatusData>
export interface TflSourceOptions { export interface TflSourceOptions {
apiKey?: string apiKey?: string
client?: ITflApi client?: ITflApi

View File

@@ -57,7 +57,7 @@ export interface HourlyWeatherFeedItem extends FeedItem<
HourlyWeatherData HourlyWeatherData
> {} > {}
export type DailyWeatherData = { export type DailyWeatherEntry = {
forecastDate: Date forecastDate: Date
conditionCode: ConditionCode conditionCode: ConditionCode
maxUvIndex: number maxUvIndex: number
@@ -71,6 +71,10 @@ export type DailyWeatherData = {
temperatureMin: number temperatureMin: number
} }
export type DailyWeatherData = {
days: DailyWeatherEntry[]
}
export interface DailyWeatherFeedItem extends FeedItem< export interface DailyWeatherFeedItem extends FeedItem<
typeof WeatherFeedItemType.Daily, typeof WeatherFeedItemType.Daily,
DailyWeatherData DailyWeatherData

View File

@@ -11,6 +11,7 @@ export {
type HourlyWeatherEntry, type HourlyWeatherEntry,
type DailyWeatherFeedItem, type DailyWeatherFeedItem,
type DailyWeatherData, type DailyWeatherData,
type DailyWeatherEntry,
type WeatherAlertFeedItem, type WeatherAlertFeedItem,
type WeatherAlertData, type WeatherAlertData,
} from "./feed-items" } from "./feed-items"

View File

@@ -4,10 +4,10 @@ import { Context } from "@aelis/core"
import { LocationKey } from "@aelis/source-location" import { LocationKey } from "@aelis/source-location"
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import type { WeatherKitClient, WeatherKitResponse, HourlyForecast } from "./weatherkit" import type { WeatherKitClient, WeatherKitResponse, HourlyForecast, DailyForecast } from "./weatherkit"
import fixture from "../fixtures/san-francisco.json" import fixture from "../fixtures/san-francisco.json"
import { WeatherFeedItemType, type HourlyWeatherData } from "./feed-items" import { WeatherFeedItemType, type DailyWeatherData, type HourlyWeatherData } from "./feed-items"
import { WeatherKey, type Weather } from "./weather-context" import { WeatherKey, type Weather } from "./weather-context"
import { WeatherSource, Units } from "./weather-source" import { WeatherSource, Units } from "./weather-source"
@@ -133,7 +133,8 @@ describe("WeatherSource", () => {
expect(hourlyItems.length).toBe(1) expect(hourlyItems.length).toBe(1)
expect((hourlyItems[0]!.data as HourlyWeatherData).hours.length).toBe(3) expect((hourlyItems[0]!.data as HourlyWeatherData).hours.length).toBe(3)
expect(dailyItems.length).toBe(2) expect(dailyItems.length).toBe(1)
expect((dailyItems[0]!.data as DailyWeatherData).days.length).toBe(2)
}) })
test("produces a single hourly item with hours array", async () => { test("produces a single hourly item with hours array", async () => {
@@ -192,6 +193,65 @@ describe("WeatherSource", () => {
expect(hourlyItem!.signals!.timeRelevance).toBe("imminent") expect(hourlyItem!.signals!.timeRelevance).toBe("imminent")
}) })
test("produces a single daily item with days array", async () => {
const source = new WeatherSource({ client: mockClient })
const context = createMockContext({ lat: 37.7749, lng: -122.4194 })
const items = await source.fetchItems(context)
const dailyItems = items.filter((i) => i.type === WeatherFeedItemType.Daily)
expect(dailyItems.length).toBe(1)
const dailyData = dailyItems[0]!.data as DailyWeatherData
expect(Array.isArray(dailyData.days)).toBe(true)
expect(dailyData.days.length).toBeGreaterThan(0)
expect(dailyData.days.length).toBeLessThanOrEqual(7)
})
test("averages urgency across days with mixed conditions", async () => {
const mildDay: DailyForecast = {
forecastStart: "2026-01-17T00:00:00Z",
forecastEnd: "2026-01-18T00:00:00Z",
conditionCode: "Clear",
maxUvIndex: 3,
moonPhase: "firstQuarter",
precipitationAmount: 0,
precipitationChance: 0,
precipitationType: "clear",
snowfallAmount: 0,
sunrise: "2026-01-17T07:00:00Z",
sunriseCivil: "2026-01-17T06:30:00Z",
sunriseNautical: "2026-01-17T06:00:00Z",
sunriseAstronomical: "2026-01-17T05:30:00Z",
sunset: "2026-01-17T17:00:00Z",
sunsetCivil: "2026-01-17T17:30:00Z",
sunsetNautical: "2026-01-17T18:00:00Z",
sunsetAstronomical: "2026-01-17T18:30:00Z",
temperatureMax: 15,
temperatureMin: 5,
}
const severeDay: DailyForecast = {
...mildDay,
forecastStart: "2026-01-18T00:00:00Z",
forecastEnd: "2026-01-19T00:00:00Z",
conditionCode: "SevereThunderstorm",
}
const mixedResponse: WeatherKitResponse = {
forecastDaily: { days: [mildDay, severeDay] },
}
const source = new WeatherSource({ client: createMockClient(mixedResponse) })
const context = createMockContext({ lat: 37.7749, lng: -122.4194 })
const items = await source.fetchItems(context)
const dailyItem = items.find((i) => i.type === WeatherFeedItemType.Daily)
expect(dailyItem).toBeDefined()
// Mild urgency = 0.2, severe urgency = 0.5, average = 0.35
expect(dailyItem!.signals!.urgency).toBeCloseTo(0.35, 5)
// Worst-case: SevereThunderstorm → Imminent
expect(dailyItem!.signals!.timeRelevance).toBe("imminent")
})
test("sets timestamp from context.time", async () => { test("sets timestamp from context.time", async () => {
const source = new WeatherSource({ client: mockClient }) const source = new WeatherSource({ client: mockClient })
const queryTime = new Date("2026-01-17T12:00:00Z") const queryTime = new Date("2026-01-17T12:00:00Z")

View File

@@ -3,7 +3,7 @@ import type { ActionDefinition, ContextEntry, FeedItemSignals, FeedSource } from
import { Context, TimeRelevance, UnknownActionError } from "@aelis/core" import { Context, TimeRelevance, UnknownActionError } from "@aelis/core"
import { LocationKey } from "@aelis/source-location" import { LocationKey } from "@aelis/source-location"
import { WeatherFeedItemType, type HourlyWeatherEntry, type WeatherFeedItem } from "./feed-items" import { WeatherFeedItemType, type DailyWeatherEntry, type HourlyWeatherEntry, type WeatherFeedItem } from "./feed-items"
import currentWeatherInsightPrompt from "./prompts/current-weather-insight.txt" import currentWeatherInsightPrompt from "./prompts/current-weather-insight.txt"
import { WeatherKey, type Weather } from "./weather-context" import { WeatherKey, type Weather } from "./weather-context"
import { import {
@@ -181,11 +181,8 @@ export class WeatherSource implements FeedSource<WeatherFeedItem> {
if (response.forecastDaily?.days) { if (response.forecastDaily?.days) {
const days = response.forecastDaily.days.slice(0, this.dailyLimit) const days = response.forecastDaily.days.slice(0, this.dailyLimit)
for (let i = 0; i < days.length; i++) { if (days.length > 0) {
const day = days[i] items.push(createDailyForecastFeedItem(days, timestamp, this.units, this.id))
if (day) {
items.push(createDailyWeatherFeedItem(day, i, timestamp, this.units, this.id))
}
} }
} }
@@ -370,24 +367,18 @@ function createHourlyForecastFeedItem(
} }
} }
function createDailyWeatherFeedItem( function createDailyForecastFeedItem(
daily: DailyForecast, dailyForecasts: DailyForecast[],
index: number,
timestamp: Date, timestamp: Date,
units: Units, units: Units,
sourceId: string, sourceId: string,
): WeatherFeedItem { ): WeatherFeedItem {
const signals: FeedItemSignals = { const days: DailyWeatherEntry[] = []
urgency: adjustUrgencyForCondition(BASE_URGENCY.daily, daily.conditionCode), let totalUrgency = 0
timeRelevance: timeRelevanceForCondition(daily.conditionCode), let worstTimeRelevance: TimeRelevance = TimeRelevance.Ambient
}
return { for (const daily of dailyForecasts) {
id: `weather-daily-${timestamp.getTime()}-${index}`, days.push({
sourceId,
type: WeatherFeedItemType.Daily,
timestamp,
data: {
forecastDate: new Date(daily.forecastStart), forecastDate: new Date(daily.forecastStart),
conditionCode: daily.conditionCode, conditionCode: daily.conditionCode,
maxUvIndex: daily.maxUvIndex, maxUvIndex: daily.maxUvIndex,
@@ -399,7 +390,27 @@ function createDailyWeatherFeedItem(
sunset: new Date(daily.sunset), sunset: new Date(daily.sunset),
temperatureMax: convertTemperature(daily.temperatureMax, units), temperatureMax: convertTemperature(daily.temperatureMax, units),
temperatureMin: convertTemperature(daily.temperatureMin, units), temperatureMin: convertTemperature(daily.temperatureMin, units),
}, })
totalUrgency += adjustUrgencyForCondition(BASE_URGENCY.daily, daily.conditionCode)
const rel = timeRelevanceForCondition(daily.conditionCode)
if (rel === TimeRelevance.Imminent) {
worstTimeRelevance = TimeRelevance.Imminent
} else if (rel === TimeRelevance.Upcoming && worstTimeRelevance !== TimeRelevance.Imminent) {
worstTimeRelevance = TimeRelevance.Upcoming
}
}
const signals: FeedItemSignals = {
urgency: totalUrgency / days.length,
timeRelevance: worstTimeRelevance,
}
return {
id: `weather-daily-${timestamp.getTime()}`,
sourceId,
type: WeatherFeedItemType.Daily,
timestamp,
data: { days },
signals, signals,
} }
} }