Compare commits

..

39 Commits

Author SHA1 Message Date
de91dfc2a2 chore: rename aelis to freya 2026-06-12 17:32:20 +01:00
7e77870c13 chore: move services to package scripts (#121) 2026-06-12 16:31:28 +01:00
c95c730533 fix: add .ona and drizzle to oxfmt ignore (#119)
oxfmt was reformatting generated drizzle migration snapshots and
crashing on .ona/review/comments.json. Also runs the formatter
across the full codebase.

Co-authored-by: Ona <no-reply@ona.com>
2026-04-12 18:33:46 +01:00
62c8dfe0b1 feat: wrap multi-step DB writes in transactions (#118)
- saveSourceConfig: upsert + credential update run atomically
- updateSourceConfig: SELECT FOR UPDATE prevents lost updates
- Widen Database type to accept transaction handles

Co-authored-by: Ona <no-reply@ona.com>
2026-04-12 15:46:30 +01:00
e54c5d5462 fix: accept credentials in source config upsert (#117)
* fix: unified source config + credentials

Accept optional credentials in PUT /api/sources/:sourceId so the
dashboard can send config and credentials in a single request,
eliminating the race condition between parallel config/credential
updates that left sources uninitialized until server restart.

The existing /credentials endpoint is preserved for independent
credential updates.

Co-authored-by: Ona <no-reply@ona.com>

* refactor: rename upsertSourceConfig to saveSourceConfig

Co-authored-by: Ona <no-reply@ona.com>

---------

Co-authored-by: Ona <no-reply@ona.com>
2026-04-12 15:17:29 +01:00
b5236e0e52 feat: migrate to TypeScript 6 and add tsgo (#114)
* feat: migrate to TypeScript 6 and add tsgo

- Upgrade typescript from ^5 to ^6 across all packages
- Address TS6 breaking changes in tsconfig files:
  - Add explicit types array (new default is [])
  - Remove deprecated baseUrl (paths work without it)
  - Remove redundant esModuleInterop: true
  - Merge DOM.Iterable into DOM lib
- Install @typescript/native-preview for tsgo CLI
- Enable tsgo in VS Code settings

Co-authored-by: Ona <no-reply@ona.com>

* chore: remove redundant tsconfig comments

Co-authored-by: Ona <no-reply@ona.com>

---------

Co-authored-by: Ona <no-reply@ona.com>
2026-04-12 12:34:02 +01:00
0a8243c55b feat: add CalDAV source config to admin dashboard (#112)
Add source definition for aelis.caldav with server URL, username,
password, look-ahead days, and timezone fields.

Route per-user credentials through /api/sources/:id/credentials
instead of the admin provider config endpoint, controlled by a
perUserCredentials flag on the source definition.

Co-authored-by: Ona <no-reply@ona.com>
2026-04-12 12:02:15 +01:00
400055ab8c feat: add CalDAV source provider (#111)
Wire CalDavSourceProvider into the backend to support CalDAV
calendar sources (e.g. iCloud) with basic auth. Config accepts
serverUrl, username, lookAheadDays, and timeZone. Credentials
(app-specific password) are stored encrypted via the existing
credential storage infrastructure.

Co-authored-by: Ona <no-reply@ona.com>
2026-04-11 16:34:11 +01:00
98ce546eff feat: surface per-user credentials to feed source providers (#110)
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-11 15:18:24 +01: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
f126afc3ca chore: remove aelis-data-source-weatherkit (#101)
Co-authored-by: Ona <no-reply@ona.com>
2026-03-29 15:29:43 +01:00
53dbf1ca34 feat: combine hourly weather into single feed item (#100)
* feat: combine hourly weather into single feed item

Co-authored-by: Ona <no-reply@ona.com>

* fix: use worst-case timeRelevance, improve tests

- Use most urgent timeRelevance across hours instead of
  hardcoded Ambient
- Use HourlyWeatherData type in test casts
- Add test for averaged urgency with mixed conditions

Co-authored-by: Ona <no-reply@ona.com>

---------

Co-authored-by: Ona <no-reply@ona.com>
2026-03-29 14:54:14 +01:00
e09c606649 fix: disable strict mode for enhancement JSON schema (#99)
strict: true requires all property names to be known upfront,
which is incompatible with the dynamic-key maps in slotFills.
Also replace type array with anyOf for nullable slot values.
2026-03-28 15:58:57 +00:00
21b7d299a6 fix: move tailscale setup to postStartCommand (#98)
Co-authored-by: Ona <no-reply@ona.com>
2026-03-24 22:59:03 +00:00
1596f2bedf fix: use http protocol for service ports (#97)
Co-authored-by: Ona <no-reply@ona.com>
2026-03-24 22:44:56 +00:00
b85109e2e2 feat: auto-login to tailscale in devcontainer (#96) 2026-03-24 22:20:34 +00:00
eb5149a500 fix: add --host to admin dashboard dev server (#95)
Bind Vite to all interfaces so port forwarding works in
Ona environments.

Co-authored-by: Ona <no-reply@ona.com>
2026-03-24 22:12:21 +00:00
02f519c29c dev: add service definitions for backend and dashboard (#94)
Co-authored-by: Ona <no-reply@ona.com>
2026-03-24 21:24:21 +00:00
59d14ee37b fix(admin-dashboard): redirect to login on session fetch failure (#93)
Wrap the session check in beforeLoad with a try/catch so
network errors, 404s, and other failures redirect to the
login page instead of showing an error boundary.

Co-authored-by: Ona <no-reply@ona.com>
2026-03-24 21:11:38 +00:00
9b0ac1cd4e feat: add admin dashboard app (#91)
* feat: add admin dashboard app

- React + Vite + TanStack Router + TanStack Query
- Auth with better-auth (login, session, admin guard)
- Source config management (WeatherKit credentials, user config)
- Feed query panel
- Location push card
- General settings with health check
- CORS middleware for cross-origin auth
- Disable CSRF check in dev mode
- Sonner toasts for mutation feedback

Co-authored-by: Ona <no-reply@ona.com>

* fix: use useQuery instead of getQueryData

Co-authored-by: Ona <no-reply@ona.com>

* refactor: remove backend changes from dashboard PR

Backend CORS/CSRF changes moved to #92.
Source registry removed (sources hardcoded in frontend).

Co-authored-by: Ona <no-reply@ona.com>

---------

Co-authored-by: Ona <no-reply@ona.com>
2026-03-23 00:31:34 +00:00
35c6371d48 fix(backend): add CORS and disable CSRF in dev (#92)
* fix(backend): add CORS middleware and disable CSRF in dev

- Add CORS middleware for /api/auth/* and global routes
- Disable better-auth CSRF origin check when NODE_ENV != production

Co-authored-by: Ona <no-reply@ona.com>

* fix: gate permissive CORS to dev only

In production, only origins listed in CORS_ORIGINS env
var are allowed. In dev, any origin is reflected back.

Co-authored-by: Ona <no-reply@ona.com>

---------

Co-authored-by: Ona <no-reply@ona.com>
2026-03-23 00:31:23 +00:00
7909211c1b fix(backend): disable reasoning and fallback to reasoning field (#90)
Set reasoning effort to none in the LLM client to reduce latency
and token usage. Fall back to the reasoning field when content is
absent in the response.

Co-authored-by: Ona <no-reply@ona.com>
2026-03-22 22:47:01 +00:00
99c097e503 fix(backend): reject unknown fields in source config (#88)
Add "+": "reject" to all arktype schemas so undeclared
keys return 400. Sources without a configSchema now
reject the config field entirely at the HTTP layer.

Co-authored-by: Ona <no-reply@ona.com>
2026-03-22 22:45:17 +00:00
a52addebd8 feat(backend): add GET /api/sources/:sourceId (#89)
Return { enabled, config } for a user's source. Defaults to
{ enabled: false, config: {} } when no row exists.

Co-authored-by: Ona <no-reply@ona.com>
2026-03-22 21:45:17 +00:00
4cef7f2ea1 feat(backend): add PUT /api/sources/:sourceId (#87)
Add a PUT endpoint that inserts or fully replaces a user's source
config. Unlike PATCH (which deep-merges and requires an existing row),
PUT requires both `enabled` and `config`, performs an upsert via
INSERT ... ON CONFLICT DO UPDATE, and replaces config entirely.

- Add `upsertConfig` to user-sources data layer
- Add `upsertSourceConfig` to UserSessionManager
- Add `addSource` to UserSession for new source registration
- 12 new tests covering insert, replace, validation, and session refresh

Co-authored-by: Ona <no-reply@ona.com>
2026-03-22 18:37:40 +00:00
dd2b37938f feat(backend): add PATCH /api/sources/:sourceId (#86)
Add endpoint for users to update their source config
and enabled state. Config is deep-merged with existing
values via lodash.merge and validated against the
provider's schema before persisting.

Co-authored-by: Ona <no-reply@ona.com>
2026-03-22 17:57:54 +00:00
a6be7b31e7 feat(session): query enabled sources before providers (#85)
UserSessionManager now queries the user_sources table for enabled
sources before calling any provider. Providers receive the per-user
JSON config directly instead of querying the DB themselves, removing
their db dependency and eliminating redundant round-trips.

Co-authored-by: Ona <no-reply@ona.com>
2026-03-22 16:28:19 +00:00
b24d879d31 feat(session): add per-user source refresh (#84)
* feat(session): add per-user source refresh

Add refreshSource(provider) to UserSession so per-user
config changes can re-resolve a source without replacing
the global provider.

- UserSession now carries userId
- Simplify UserSessionManager sessions map
- replaceProvider delegates to session.refreshSource
- Remove updateSessionSource from manager

Co-authored-by: Ona <no-reply@ona.com>

* docs: fix stale jsdoc on provider failure behavior

Co-authored-by: Ona <no-reply@ona.com>

---------

Co-authored-by: Ona <no-reply@ona.com>
2026-03-22 00:13:22 +00:00
7862a6d367 feat(backend): add admin API with provider config endpoint (#83)
* feat(backend): add admin API with provider config endpoint

Add /api/admin/* route group with admin role middleware and a
PUT /api/admin/:sourceId/config endpoint for updating feed source
provider config at runtime. Currently supports aelis.weather.

Co-authored-by: Ona <no-reply@ona.com>

* test: remove weak active session test

Co-authored-by: Ona <no-reply@ona.com>

---------

Co-authored-by: Ona <no-reply@ona.com>
2026-03-21 19:01:43 +00:00
0095d9cd72 feat: runtime provider hotswap (#82)
Add ability to replace a FeedSourceProvider at runtime and propagate
the new source to all active (and pending) user sessions, invalidating
their feed caches.

Co-authored-by: Ona <no-reply@ona.com>
2026-03-19 23:32:29 +00:00
ca2664b617 feat: add Drizzle Studio service to automations (#81)
Co-authored-by: Ona <no-reply@ona.com>
2026-03-16 23:10:31 +00:00
21750582b1 feat(backend): add admin plugin and create-admin script (#80)
* feat(backend): add admin plugin and create-admin script

Add Better Auth admin plugin for role-based user management.
Includes a CLI script to create admin accounts.

Co-authored-by: Ona <no-reply@ona.com>

* fix(backend): guard against missing BETTER_AUTH_SECRET

Co-authored-by: Ona <no-reply@ona.com>

---------

Co-authored-by: Ona <no-reply@ona.com>
2026-03-16 22:39:40 +00:00
323 changed files with 11168 additions and 9540 deletions

View File

@@ -11,7 +11,7 @@
"dockerfile": "Dockerfile"
},
"postCreateCommand": "bun install",
"postStartCommand": "./scripts/setup-git.sh && ./scripts/setup-nvim.sh",
"postStartCommand": "./scripts/setup-git.sh && ./scripts/setup-nvim.sh && ./scripts/setup-tailscale.sh",
// Features add additional features to your environment. See https://containers.dev/features
// Beware: features are not supported on all platforms and may have unintended side-effects.
"features": {

View File

@@ -11,7 +11,7 @@ on:
env:
REGISTRY: cr.nym.sh
IMAGE_NAME: aelis-waitlist-website
IMAGE_NAME: freya-waitlist-website
jobs:
build:

View File

@@ -1,8 +0,0 @@
services:
expo:
name: Expo Dev Server
description: Expo development server for aelis-client
triggeredBy:
- postDevcontainerStart
commands:
start: cd apps/aelis-client && ./scripts/run-dev-server.sh

View File

@@ -8,5 +8,5 @@
"ignoreCase": true,
"newlinesBetween": true
},
"ignorePatterns": [".claude", "fixtures"]
"ignorePatterns": [".claude", ".ona", "drizzle", "fixtures"]
}

3
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,3 @@
{
"js/ts.experimental.useTsgo": true
}

View File

@@ -2,7 +2,7 @@
## Project
AELIS is an AI-powered personal assistant that aggregates data from various sources into a contextual feed. Monorepo with `packages/` (shared libraries) and `apps/` (applications).
FREYA is an AI-powered personal assistant that aggregates data from various sources into a contextual feed. Monorepo with `packages/` (shared libraries) and `apps/` (applications).
## Commands

View File

@@ -1,4 +1,4 @@
# aelis
# freya
To install dependencies:
@@ -8,14 +8,14 @@ bun install
## Packages
### @aelis/source-tfl
### @freya/source-tfl
TfL (Transport for London) feed source for tube, overground, and Elizabeth line alerts.
#### Testing
```bash
cd packages/aelis-source-tfl
cd packages/freya-source-tfl
bun run test
```

24
apps/admin-dashboard/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

View File

@@ -0,0 +1,21 @@
# React + TypeScript + Vite + shadcn/ui
This is a template for a new Vite project with React, TypeScript, and shadcn/ui.
## Adding components
To add components to your app, run the following command:
```bash
npx shadcn@latest add button
```
This will place the ui components in the `src/components` directory.
## Using components
To use the components in your app, import them as follows:
```tsx
import { Button } from "@/components/ui/button"
```

View File

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

View File

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

View File

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

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

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

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,84 @@
"use client"
import { ChevronDownIcon, ChevronUpIcon } from "lucide-react"
import { Accordion as AccordionPrimitive } from "radix-ui"
import * as React from "react"
import { cn } from "@/lib/utils"
function Accordion({ className, ...props }: React.ComponentProps<typeof AccordionPrimitive.Root>) {
return (
<AccordionPrimitive.Root
data-slot="accordion"
className={cn("flex w-full flex-col overflow-hidden rounded-md border", className)}
{...props}
/>
)
}
function AccordionItem({
className,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Item>) {
return (
<AccordionPrimitive.Item
data-slot="accordion-item"
className={cn("not-last:border-b data-open:bg-muted/50", className)}
{...props}
/>
)
}
function AccordionTrigger({
className,
children,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
return (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
data-slot="accordion-trigger"
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",
className,
)}
{...props}
>
{children}
<ChevronDownIcon
data-slot="accordion-trigger-icon"
className="pointer-events-none shrink-0 group-aria-expanded/accordion-trigger:hidden"
/>
<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({
className,
children,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Content>) {
return (
<AccordionPrimitive.Content
data-slot="accordion-content"
className="overflow-hidden px-2 text-xs/relaxed data-open:animate-accordion-down data-closed:animate-accordion-up"
{...props}
>
<div
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",
className,
)}
>
{children}
</div>
</AccordionPrimitive.Content>
)
}
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }

View File

@@ -0,0 +1,73 @@
import { cva, type VariantProps } from "class-variance-authority"
import * as React from "react"
import { cn } from "@/lib/utils"
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",
{
variants: {
variant: {
default: "bg-card text-card-foreground",
destructive:
"bg-card text-destructive *:data-[slot=alert-description]:text-destructive/90 *:[svg]:text-current",
},
},
defaultVariants: {
variant: "default",
},
},
)
function Alert({
className,
variant,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
return (
<div
data-slot="alert"
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
)
}
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-title"
className={cn(
"font-medium group-has-[>svg]/alert:col-start-2 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground",
className,
)}
{...props}
/>
)
}
function AlertDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-description"
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",
className,
)}
{...props}
/>
)
}
function AlertAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-action"
className={cn("absolute top-1.5 right-2", className)}
{...props}
/>
)
}
export { Alert, AlertTitle, AlertDescription, AlertAction }

View File

@@ -0,0 +1,46 @@
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import * as React from "react"
import { cn } from "@/lib/utils"
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!",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
secondary: "bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
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",
outline:
"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",
link: "text-primary underline-offset-4 hover:underline",
},
},
defaultVariants: {
variant: "default",
},
},
)
function Badge({
className,
variant = "default",
asChild = false,
...props
}: React.ComponentProps<"span"> & VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot.Root : "span"
return (
<Comp
data-slot="badge"
data-variant={variant}
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
}
export { Badge, badgeVariants }

View File

@@ -0,0 +1,65 @@
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import * as React from "react"
import { cn } from "@/lib/utils"
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",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/80",
outline:
"border-border hover:bg-input/50 hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:bg-input/30",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
ghost:
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
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",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
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",
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",
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-xs": "size-5 rounded-sm [&_svg:not([class*='size-'])]:size-2.5",
"icon-sm": "size-6 [&_svg:not([class*='size-'])]:size-3",
"icon-lg": "size-8 [&_svg:not([class*='size-'])]:size-4",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
)
function Button({
className,
variant = "default",
size = "default",
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot.Root : "button"
return (
<Comp
data-slot="button"
data-variant={variant}
data-size={size}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }

View File

@@ -0,0 +1,83 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({
className,
size = "default",
...props
}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
return (
<div
data-slot="card"
data-size={size}
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",
className,
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
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",
className,
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return <div data-slot="card-title" className={cn("text-sm font-medium", className)} {...props} />
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-xs/relaxed text-muted-foreground", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn("col-start-2 row-span-2 row-start-1 self-start justify-self-end", className)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-4 group-data-[size=sm]/card:px-3", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
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",
className,
)}
{...props}
/>
)
}
export { Card, CardHeader, CardFooter, CardTitle, CardAction, CardDescription, CardContent }

View File

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

View File

@@ -0,0 +1,19 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
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",
className,
)}
{...props}
/>
)
}
export { Input }

View File

@@ -0,0 +1,19 @@
import { Label as LabelPrimitive } from "radix-ui"
import * as React from "react"
import { cn } from "@/lib/utils"
function Label({ className, ...props }: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
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",
className,
)}
{...props}
/>
)
}
export { Label }

View File

@@ -0,0 +1,183 @@
import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react"
import { Select as SelectPrimitive } from "radix-ui"
import * as React from "react"
import { cn } from "@/lib/utils"
function Select({ ...props }: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />
}
function SelectGroup({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.Group>) {
return (
<SelectPrimitive.Group
data-slot="select-group"
className={cn("scroll-my-1 p-1", className)}
{...props}
/>
)
}
function SelectValue({ ...props }: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default"
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
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",
className,
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="pointer-events-none size-3.5 text-muted-foreground" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
position = "item-aligned",
align = "center",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
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,
)}
position={position}
align={align}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
data-position={position}
className={cn(
"data-[position=popper]:h-(--radix-select-trigger-height) data-[position=popper]:w-full data-[position=popper]:min-w-(--radix-select-trigger-width)",
position === "popper" && "",
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
)
}
function SelectLabel({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("px-2 py-1.5 text-xs text-muted-foreground", className)}
{...props}
/>
)
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
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",
className,
)}
{...props}
>
<span className="pointer-events-none absolute right-2 flex items-center justify-center">
<SelectPrimitive.ItemIndicator>
<CheckIcon className="pointer-events-none" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
)
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border/50", className)}
{...props}
/>
)
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
"z-10 flex cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-3.5",
className,
)}
{...props}
>
<ChevronUpIcon />
</SelectPrimitive.ScrollUpButton>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
"z-10 flex cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-3.5",
className,
)}
{...props}
>
<ChevronDownIcon />
</SelectPrimitive.ScrollDownButton>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}

View File

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

View File

@@ -0,0 +1,128 @@
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>) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />
}
function SheetTrigger({ ...props }: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
}
function SheetClose({ ...props }: React.ComponentProps<typeof SheetPrimitive.Close>) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
}
function SheetPortal({ ...props }: React.ComponentProps<typeof SheetPrimitive.Portal>) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
}
function SheetOverlay({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
return (
<SheetPrimitive.Overlay
data-slot="sheet-overlay"
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",
className,
)}
{...props}
/>
)
}
function SheetContent({
className,
children,
side = "right",
showCloseButton = true,
...props
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
side?: "top" | "right" | "bottom" | "left"
showCloseButton?: boolean
}) {
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
data-slot="sheet-content"
data-side={side}
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",
className,
)}
{...props}
>
{children}
{showCloseButton && (
<SheetPrimitive.Close data-slot="sheet-close" asChild>
<Button variant="ghost" className="absolute top-4 right-4" size="icon-sm">
<XIcon />
<span className="sr-only">Close</span>
</Button>
</SheetPrimitive.Close>
)}
</SheetPrimitive.Content>
</SheetPortal>
)
}
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-header"
className={cn("flex flex-col gap-1.5 p-6", className)}
{...props}
/>
)
}
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-footer"
className={cn("mt-auto flex flex-col gap-2 p-6", className)}
{...props}
/>
)
}
function SheetTitle({ className, ...props }: React.ComponentProps<typeof SheetPrimitive.Title>) {
return (
<SheetPrimitive.Title
data-slot="sheet-title"
className={cn("text-sm font-medium text-foreground", className)}
{...props}
/>
)
}
function SheetDescription({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
return (
<SheetPrimitive.Description
data-slot="sheet-description"
className={cn("text-xs/relaxed text-muted-foreground", className)}
{...props}
/>
)
}
export {
Sheet,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
}

View File

@@ -0,0 +1,671 @@
"use client"
import { cva, type VariantProps } from "class-variance-authority"
import { PanelLeftIcon } from "lucide-react"
import { Slot } from "radix-ui"
import * as React from "react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Separator } from "@/components/ui/separator"
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet"
import { Skeleton } from "@/components/ui/skeleton"
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
import { useIsMobile } from "@/hooks/use-mobile"
import { cn } from "@/lib/utils"
const SIDEBAR_COOKIE_NAME = "sidebar_state"
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
const SIDEBAR_WIDTH = "16rem"
const SIDEBAR_WIDTH_MOBILE = "18rem"
const SIDEBAR_WIDTH_ICON = "3rem"
const SIDEBAR_KEYBOARD_SHORTCUT = "b"
type SidebarContextProps = {
state: "expanded" | "collapsed"
open: boolean
setOpen: (open: boolean) => void
openMobile: boolean
setOpenMobile: (open: boolean) => void
isMobile: boolean
toggleSidebar: () => void
}
const SidebarContext = React.createContext<SidebarContextProps | null>(null)
function useSidebar() {
const context = React.useContext(SidebarContext)
if (!context) {
throw new Error("useSidebar must be used within a SidebarProvider.")
}
return context
}
function SidebarProvider({
defaultOpen = true,
open: openProp,
onOpenChange: setOpenProp,
className,
style,
children,
...props
}: React.ComponentProps<"div"> & {
defaultOpen?: boolean
open?: boolean
onOpenChange?: (open: boolean) => void
}) {
const isMobile = useIsMobile()
const [openMobile, setOpenMobile] = React.useState(false)
// This is the internal state of the sidebar.
// We use openProp and setOpenProp for control from outside the component.
const [_open, _setOpen] = React.useState(defaultOpen)
const open = openProp ?? _open
const setOpen = React.useCallback(
(value: boolean | ((value: boolean) => boolean)) => {
const openState = typeof value === "function" ? value(open) : value
if (setOpenProp) {
setOpenProp(openState)
} else {
_setOpen(openState)
}
// This sets the cookie to keep the sidebar state.
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`
},
[setOpenProp, open],
)
// Helper to toggle the sidebar.
const toggleSidebar = React.useCallback(() => {
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open)
}, [isMobile, setOpen, setOpenMobile])
// Adds a keyboard shortcut to toggle the sidebar.
React.useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === SIDEBAR_KEYBOARD_SHORTCUT && (event.metaKey || event.ctrlKey)) {
event.preventDefault()
toggleSidebar()
}
}
window.addEventListener("keydown", handleKeyDown)
return () => window.removeEventListener("keydown", handleKeyDown)
}, [toggleSidebar])
// We add a state so that we can do data-state="expanded" or "collapsed".
// This makes it easier to style the sidebar with Tailwind classes.
const state = open ? "expanded" : "collapsed"
const contextValue = React.useMemo<SidebarContextProps>(
() => ({
state,
open,
setOpen,
isMobile,
openMobile,
setOpenMobile,
toggleSidebar,
}),
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar],
)
return (
<SidebarContext.Provider value={contextValue}>
<div
data-slot="sidebar-wrapper"
style={
{
"--sidebar-width": SIDEBAR_WIDTH,
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
...style,
} as React.CSSProperties
}
className={cn(
"group/sidebar-wrapper flex min-h-svh w-full has-data-[variant=inset]:bg-sidebar",
className,
)}
{...props}
>
{children}
</div>
</SidebarContext.Provider>
)
}
function Sidebar({
side = "left",
variant = "sidebar",
collapsible = "offcanvas",
className,
children,
dir,
...props
}: React.ComponentProps<"div"> & {
side?: "left" | "right"
variant?: "sidebar" | "floating" | "inset"
collapsible?: "offcanvas" | "icon" | "none"
}) {
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
if (collapsible === "none") {
return (
<div
data-slot="sidebar"
className={cn(
"flex h-full w-(--sidebar-width) flex-col bg-sidebar text-sidebar-foreground",
className,
)}
{...props}
>
{children}
</div>
)
}
if (isMobile) {
return (
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
<SheetContent
dir={dir}
data-sidebar="sidebar"
data-slot="sidebar"
data-mobile="true"
className="w-(--sidebar-width) bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden"
style={
{
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
} as React.CSSProperties
}
side={side}
>
<SheetHeader className="sr-only">
<SheetTitle>Sidebar</SheetTitle>
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
</SheetHeader>
<div className="flex h-full w-full flex-col">{children}</div>
</SheetContent>
</Sheet>
)
}
return (
<div
className="group peer hidden text-sidebar-foreground md:block"
data-state={state}
data-collapsible={state === "collapsed" ? collapsible : ""}
data-variant={variant}
data-side={side}
data-slot="sidebar"
>
{/* This is what handles the sidebar gap on desktop */}
<div
data-slot="sidebar-gap"
className={cn(
"relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear",
"group-data-[collapsible=offcanvas]:w-0",
"group-data-[side=right]:rotate-180",
variant === "floating" || variant === "inset"
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)",
)}
/>
<div
data-slot="sidebar-container"
data-side={side}
className={cn(
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear data-[side=left]:left-0 data-[side=left]:group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)] data-[side=right]:right-0 data-[side=right]:group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)] md:flex",
// Adjust the padding for floating and inset variants.
variant === "floating" || variant === "inset"
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l",
className,
)}
{...props}
>
<div
data-sidebar="sidebar"
data-slot="sidebar-inner"
className="flex size-full flex-col bg-sidebar group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:shadow-sm group-data-[variant=floating]:ring-1 group-data-[variant=floating]:ring-sidebar-border"
>
{children}
</div>
</div>
</div>
)
}
function SidebarTrigger({ className, onClick, ...props }: React.ComponentProps<typeof Button>) {
const { toggleSidebar } = useSidebar()
return (
<Button
data-sidebar="trigger"
data-slot="sidebar-trigger"
variant="ghost"
size="icon-sm"
className={cn(className)}
onClick={(event) => {
onClick?.(event)
toggleSidebar()
}}
{...props}
>
<PanelLeftIcon />
<span className="sr-only">Toggle Sidebar</span>
</Button>
)
}
function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
const { toggleSidebar } = useSidebar()
return (
<button
data-sidebar="rail"
data-slot="sidebar-rail"
aria-label="Toggle Sidebar"
tabIndex={-1}
onClick={toggleSidebar}
title="Toggle Sidebar"
className={cn(
"absolute inset-y-0 z-20 hidden w-4 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:start-1/2 after:w-[2px] hover:after:bg-sidebar-border sm:flex ltr:-translate-x-1/2 rtl:-translate-x-1/2",
"in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
"group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full hover:group-data-[collapsible=offcanvas]:bg-sidebar",
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
className,
)}
{...props}
/>
)
}
function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
return (
<main
data-slot="sidebar-inset"
className={cn(
"relative flex w-full flex-1 flex-col bg-background md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2",
className,
)}
{...props}
/>
)
}
function SidebarInput({ className, ...props }: React.ComponentProps<typeof Input>) {
return (
<Input
data-slot="sidebar-input"
data-sidebar="input"
className={cn("h-8 w-full border-input bg-muted/20 dark:bg-muted/30", className)}
{...props}
/>
)
}
function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-header"
data-sidebar="header"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
)
}
function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-footer"
data-sidebar="footer"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
)
}
function SidebarSeparator({ className, ...props }: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot="sidebar-separator"
data-sidebar="separator"
className={cn("mx-2 w-auto bg-sidebar-border", className)}
{...props}
/>
)
}
function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-content"
data-sidebar="content"
className={cn(
"no-scrollbar flex min-h-0 flex-1 flex-col gap-0 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
className,
)}
{...props}
/>
)
}
function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-group"
data-sidebar="group"
className={cn("relative flex w-full min-w-0 flex-col px-2 py-1", className)}
{...props}
/>
)
}
function SidebarGroupLabel({
className,
asChild = false,
...props
}: React.ComponentProps<"div"> & { asChild?: boolean }) {
const Comp = asChild ? Slot.Root : "div"
return (
<Comp
data-slot="sidebar-group-label"
data-sidebar="group-label"
className={cn(
"flex h-8 shrink-0 items-center rounded-md px-2 text-xs text-sidebar-foreground/70 ring-sidebar-ring outline-hidden transition-[margin,opacity] duration-200 ease-linear group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0 focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
className,
)}
{...props}
/>
)
}
function SidebarGroupAction({
className,
asChild = false,
...props
}: React.ComponentProps<"button"> & { asChild?: boolean }) {
const Comp = asChild ? Slot.Root : "button"
return (
<Comp
data-slot="sidebar-group-action"
data-sidebar="group-action"
className={cn(
"absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground ring-sidebar-ring outline-hidden transition-transform group-data-[collapsible=icon]:hidden after:absolute after:-inset-2 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0",
className,
)}
{...props}
/>
)
}
function SidebarGroupContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-group-content"
data-sidebar="group-content"
className={cn("w-full text-xs", className)}
{...props}
/>
)
}
function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
return (
<ul
data-slot="sidebar-menu"
data-sidebar="menu"
className={cn("flex w-full min-w-0 flex-col gap-px", className)}
{...props}
/>
)
}
function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
return (
<li
data-slot="sidebar-menu-item"
data-sidebar="menu-item"
className={cn("group/menu-item relative", className)}
{...props}
/>
)
}
const sidebarMenuButtonVariants = cva(
"peer/menu-button group/menu-button flex w-full items-center gap-2 overflow-hidden rounded-[calc(var(--radius-sm)+2px)] p-2 text-left text-xs ring-sidebar-ring outline-hidden transition-[width,height,padding] group-has-data-[sidebar=menu-action]/menu-item:pr-8 group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-open:hover:bg-sidebar-accent data-open:hover:text-sidebar-accent-foreground data-active:bg-sidebar-accent data-active:font-medium data-active:text-sidebar-accent-foreground [&_svg]:size-4 [&_svg]:shrink-0 [&>span:last-child]:truncate",
{
variants: {
variant: {
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
outline:
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
},
size: {
default: "h-8 text-xs",
sm: "h-7 text-xs",
lg: "h-12 text-xs group-data-[collapsible=icon]:p-0!",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
)
function SidebarMenuButton({
asChild = false,
isActive = false,
variant = "default",
size = "default",
tooltip,
className,
...props
}: React.ComponentProps<"button"> & {
asChild?: boolean
isActive?: boolean
tooltip?: string | React.ComponentProps<typeof TooltipContent>
} & VariantProps<typeof sidebarMenuButtonVariants>) {
const Comp = asChild ? Slot.Root : "button"
const { isMobile, state } = useSidebar()
const button = (
<Comp
data-slot="sidebar-menu-button"
data-sidebar="menu-button"
data-size={size}
data-active={isActive}
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
{...props}
/>
)
if (!tooltip) {
return button
}
if (typeof tooltip === "string") {
tooltip = {
children: tooltip,
}
}
return (
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent
side="right"
align="center"
hidden={state !== "collapsed" || isMobile}
{...tooltip}
/>
</Tooltip>
)
}
function SidebarMenuAction({
className,
asChild = false,
showOnHover = false,
...props
}: React.ComponentProps<"button"> & {
asChild?: boolean
showOnHover?: boolean
}) {
const Comp = asChild ? Slot.Root : "button"
return (
<Comp
data-slot="sidebar-menu-action"
data-sidebar="menu-action"
className={cn(
"absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-[calc(var(--radius-sm)-2px)] p-0 text-sidebar-foreground ring-sidebar-ring outline-hidden transition-transform group-data-[collapsible=icon]:hidden peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[size=default]/menu-button:top-1.5 peer-data-[size=lg]/menu-button:top-2.5 peer-data-[size=sm]/menu-button:top-1 after:absolute after:-inset-2 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0",
showOnHover &&
"group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 peer-data-active/menu-button:text-sidebar-accent-foreground aria-expanded:opacity-100 md:opacity-0",
className,
)}
{...props}
/>
)
}
function SidebarMenuBadge({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-menu-badge"
data-sidebar="menu-badge"
className={cn(
"pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-[calc(var(--radius-sm)-2px)] px-1 text-xs font-medium text-sidebar-foreground tabular-nums select-none group-data-[collapsible=icon]:hidden peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[size=default]/menu-button:top-1.5 peer-data-[size=lg]/menu-button:top-2.5 peer-data-[size=sm]/menu-button:top-1 peer-data-active/menu-button:text-sidebar-accent-foreground",
className,
)}
{...props}
/>
)
}
function SidebarMenuSkeleton({
className,
showIcon = false,
...props
}: React.ComponentProps<"div"> & {
showIcon?: boolean
}) {
// Random width between 50 to 90%.
const [width] = React.useState(() => {
return `${Math.floor(Math.random() * 40) + 50}%`
})
return (
<div
data-slot="sidebar-menu-skeleton"
data-sidebar="menu-skeleton"
className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)}
{...props}
>
{showIcon && <Skeleton className="size-4 rounded-md" data-sidebar="menu-skeleton-icon" />}
<Skeleton
className="h-4 max-w-(--skeleton-width) flex-1"
data-sidebar="menu-skeleton-text"
style={
{
"--skeleton-width": width,
} as React.CSSProperties
}
/>
</div>
)
}
function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
return (
<ul
data-slot="sidebar-menu-sub"
data-sidebar="menu-sub"
className={cn(
"mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l border-sidebar-border px-2.5 py-0.5 group-data-[collapsible=icon]:hidden",
className,
)}
{...props}
/>
)
}
function SidebarMenuSubItem({ className, ...props }: React.ComponentProps<"li">) {
return (
<li
data-slot="sidebar-menu-sub-item"
data-sidebar="menu-sub-item"
className={cn("group/menu-sub-item relative", className)}
{...props}
/>
)
}
function SidebarMenuSubButton({
asChild = false,
size = "md",
isActive = false,
className,
...props
}: React.ComponentProps<"a"> & {
asChild?: boolean
size?: "sm" | "md"
isActive?: boolean
}) {
const Comp = asChild ? Slot.Root : "a"
return (
<Comp
data-slot="sidebar-menu-sub-button"
data-sidebar="menu-sub-button"
data-size={size}
data-active={isActive}
className={cn(
"flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground ring-sidebar-ring outline-hidden group-data-[collapsible=icon]:hidden hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[size=md]:text-xs data-[size=sm]:text-xs data-active:bg-sidebar-accent data-active:text-sidebar-accent-foreground [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground",
className,
)}
{...props}
/>
)
}
export {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupAction,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarInput,
SidebarInset,
SidebarMenu,
SidebarMenuAction,
SidebarMenuBadge,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSkeleton,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
SidebarProvider,
SidebarRail,
SidebarSeparator,
SidebarTrigger,
useSidebar,
}

View File

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

View File

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

View File

@@ -0,0 +1,33 @@
"use client"
import { Switch as SwitchPrimitive } from "radix-ui"
import * as React from "react"
import { cn } from "@/lib/utils"
function Switch({
className,
size = "default",
...props
}: React.ComponentProps<typeof SwitchPrimitive.Root> & {
size?: "sm" | "default"
}) {
return (
<SwitchPrimitive.Root
data-slot="switch"
data-size={size}
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",
className,
)}
{...props}
>
<SwitchPrimitive.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"
/>
</SwitchPrimitive.Root>
)
}
export { Switch }

View File

@@ -0,0 +1,53 @@
"use client"
import { Tooltip as TooltipPrimitive } from "radix-ui"
import * as React from "react"
import { cn } from "@/lib/utils"
function TooltipProvider({
delayDuration = 0,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delayDuration={delayDuration}
{...props}
/>
)
}
function Tooltip({ ...props }: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
}
function TooltipTrigger({ ...props }: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
}
function TooltipContent({
className,
sideOffset = 0,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
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",
className,
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
)
}
export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger }

View File

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

View File

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

View File

@@ -0,0 +1,273 @@
import { getServerUrl } from "./server-url"
function apiBase() {
return `${getServerUrl()}/api/admin`
}
function serverBase() {
return `${getServerUrl()}/api`
}
export interface ConfigFieldDef {
type: "string" | "number" | "select" | "multiselect"
label: string
required?: boolean
description?: string
secret?: boolean
defaultValue?: string | number | string[]
options?: { label: string; value: string }[]
}
export interface SourceDefinition {
id: string
name: string
description: string
alwaysEnabled?: boolean
/** When true, secret fields are stored as per-user credentials via /api/sources/:id/credentials. */
perUserCredentials?: boolean
fields: Record<string, ConfigFieldDef>
}
export interface SourceConfig {
sourceId: string
enabled: boolean
config: Record<string, unknown>
}
const sourceDefinitions: SourceDefinition[] = [
{
id: "freya.location",
name: "Location",
description: "Device location provider. Always enabled as a dependency for other sources.",
alwaysEnabled: true,
fields: {},
},
{
id: "freya.weather",
name: "WeatherKit",
description: "Apple WeatherKit weather data. Requires Apple Developer credentials.",
fields: {
privateKey: {
type: "string",
label: "Private Key",
required: true,
secret: true,
description: "Apple WeatherKit private key (PEM format)",
},
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: "freya.caldav",
name: "CalDAV",
description: "Calendar events from any CalDAV server (Nextcloud, Radicale, Baikal, etc.).",
perUserCredentials: true,
fields: {
serverUrl: {
type: "string",
label: "Server URL",
required: true,
secret: false,
description: "CalDAV server URL (e.g. https://nextcloud.example.com/remote.php/dav)",
},
username: {
type: "string",
label: "Username",
required: true,
secret: false,
},
password: {
type: "string",
label: "Password",
required: true,
secret: true,
},
lookAheadDays: {
type: "number",
label: "Look-ahead Days",
defaultValue: 0,
description: "Number of additional days beyond today to fetch events for",
},
timeZone: {
type: "string",
label: "Timezone",
description: 'IANA timezone for determining "today" (e.g. Europe/London). Defaults to UTC.',
},
},
},
{
id: "freya.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[]> {
return Promise.resolve(sourceDefinitions)
}
export async function fetchSourceConfig(sourceId: string): Promise<SourceConfig | null> {
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}`)
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[]> {
const results = await Promise.all(sourceDefinitions.map((s) => fetchSourceConfig(s.id)))
return results.filter((c): c is SourceConfig => c !== null)
}
export async function replaceSource(
sourceId: string,
body: { enabled: boolean; config: unknown; credentials?: Record<string, unknown> },
): Promise<void> {
const res = await fetch(`${serverBase()}/sources/${sourceId}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify(body),
})
if (!res.ok) {
const data = (await res.json()) as { error?: string }
throw new Error(data.error ?? `Failed to replace source config: ${res.status}`)
}
}
export async function updateProviderConfig(
sourceId: string,
body: Record<string, unknown>,
): Promise<void> {
const res = await fetch(`${apiBase()}/${sourceId}/config`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify(body),
})
if (!res.ok) {
const data = (await res.json()) as { error?: string }
throw new Error(data.error ?? `Failed to update provider config: ${res.status}`)
}
}
export async function updateSourceCredentials(
sourceId: string,
credentials: Record<string, unknown>,
): Promise<void> {
const res = await fetch(`${serverBase()}/sources/${sourceId}/credentials`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify(credentials),
})
if (!res.ok) {
const data = (await res.json()) as { error?: string }
throw new Error(data.error ?? `Failed to update credentials: ${res.status}`)
}
}
export interface LocationInput {
lat: number
lng: number
accuracy: number
}
export async function pushLocation(location: LocationInput): Promise<void> {
const res = await fetch(`${serverBase()}/location`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({
...location,
timestamp: new Date().toISOString(),
}),
})
if (!res.ok) {
const data = (await res.json()) as { error?: string }
throw new Error(data.error ?? `Failed to push location: ${res.status}`)
}
}
export interface FeedItemSlot {
description: string
content: string | null
}
export interface FeedItem {
id: string
sourceId: string
type: string
timestamp: string
data: Record<string, unknown>
signals?: {
urgency?: number
timeRelevance?: string
}
slots?: Record<string, FeedItemSlot>
ui?: unknown
}
export interface FeedResponse {
items: FeedItem[]
errors: { sourceId: string; error: string }[]
}
export async function fetchFeed(): Promise<FeedResponse> {
const res = await fetch(`${serverBase()}/feed`, { credentials: "include" })
if (!res.ok) throw new Error(`Failed to fetch feed: ${res.status}`)
return res.json() as Promise<FeedResponse>
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,11 @@
import { Route as rootRoute } from "./routes/__root"
import { Route as dashboardRoute } from "./routes/_dashboard"
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 loginRoute } from "./routes/login"
export const routeTree = rootRoute.addChildren([
loginRoute,
dashboardRoute.addChildren([dashboardIndexRoute, dashboardFeedRoute, dashboardSourceRoute]),
])

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,25 +0,0 @@
import { LocationSource } from "@aelis/source-location"
import type { Database } from "../db/index.ts"
import type { FeedSourceProvider } from "../session/feed-source-provider.ts"
import { SourceDisabledError } from "../sources/errors.ts"
import { sources } from "../sources/user-sources.ts"
export class LocationSourceProvider implements FeedSourceProvider {
private readonly db: Database
constructor(db: Database) {
this.db = db
}
async feedSourceForUser(userId: string): Promise<LocationSource> {
const row = await sources(this.db, userId).find("aelis.location")
if (!row || !row.enabled) {
throw new SourceDisabledError("aelis.location", userId)
}
return new LocationSource()
}
}

View File

@@ -1,9 +0,0 @@
import type { FeedSource } from "@aelis/core"
export interface FeedSourceProvider {
feedSourceForUser(userId: string): Promise<FeedSource>
}
export type FeedSourceProviderFn = (userId: string) => Promise<FeedSource>
export type FeedSourceProviderInput = FeedSourceProvider | FeedSourceProviderFn

View File

@@ -1,7 +0,0 @@
export type {
FeedSourceProvider,
FeedSourceProviderFn,
FeedSourceProviderInput,
} from "./feed-source-provider.ts"
export { UserSession } from "./user-session.ts"
export { UserSessionManager } from "./user-session-manager.ts"

View File

@@ -1,254 +0,0 @@
import { LocationSource } from "@aelis/source-location"
import { WeatherSource } from "@aelis/source-weatherkit"
import { describe, expect, mock, spyOn, test } from "bun:test"
import { UserSessionManager } from "./user-session-manager.ts"
const mockWeatherProvider = async () =>
new WeatherSource({ client: { fetch: async () => ({}) as never } })
describe("UserSessionManager", () => {
test("getOrCreate creates session on first call", async () => {
const manager = new UserSessionManager({ providers: [async () => new LocationSource()] })
const session = await manager.getOrCreate("user-1")
expect(session).toBeDefined()
expect(session.engine).toBeDefined()
})
test("getOrCreate returns same session for same user", async () => {
const manager = new UserSessionManager({ providers: [async () => new LocationSource()] })
const session1 = await manager.getOrCreate("user-1")
const session2 = await manager.getOrCreate("user-1")
expect(session1).toBe(session2)
})
test("getOrCreate returns different sessions for different users", async () => {
const manager = new UserSessionManager({ providers: [async () => new LocationSource()] })
const session1 = await manager.getOrCreate("user-1")
const session2 = await manager.getOrCreate("user-2")
expect(session1).not.toBe(session2)
})
test("each user gets independent source instances", async () => {
const manager = new UserSessionManager({ providers: [async () => new LocationSource()] })
const session1 = await manager.getOrCreate("user-1")
const session2 = await manager.getOrCreate("user-2")
const source1 = session1.getSource<LocationSource>("aelis.location")
const source2 = session2.getSource<LocationSource>("aelis.location")
expect(source1).not.toBe(source2)
})
test("remove destroys session and allows re-creation", async () => {
const manager = new UserSessionManager({ providers: [async () => new LocationSource()] })
const session1 = await manager.getOrCreate("user-1")
manager.remove("user-1")
const session2 = await manager.getOrCreate("user-1")
expect(session1).not.toBe(session2)
})
test("remove is no-op for unknown user", () => {
const manager = new UserSessionManager({ providers: [async () => new LocationSource()] })
expect(() => manager.remove("unknown")).not.toThrow()
})
test("accepts function providers", async () => {
const manager = new UserSessionManager({ providers: [async () => new LocationSource()] })
const session = await manager.getOrCreate("user-1")
const result = await session.engine.refresh()
expect(result.errors).toHaveLength(0)
})
test("accepts object providers", async () => {
const manager = new UserSessionManager({
providers: [async () => new LocationSource(), mockWeatherProvider],
})
const session = await manager.getOrCreate("user-1")
expect(session.getSource("aelis.weather")).toBeDefined()
})
test("accepts mixed providers", async () => {
const manager = new UserSessionManager({
providers: [async () => new LocationSource(), mockWeatherProvider],
})
const session = await manager.getOrCreate("user-1")
expect(session.getSource("aelis.location")).toBeDefined()
expect(session.getSource("aelis.weather")).toBeDefined()
})
test("refresh returns feed result through session", async () => {
const manager = new UserSessionManager({ providers: [async () => new LocationSource()] })
const session = await manager.getOrCreate("user-1")
const result = await session.engine.refresh()
expect(result).toHaveProperty("context")
expect(result).toHaveProperty("items")
expect(result).toHaveProperty("errors")
expect(result.context.time).toBeInstanceOf(Date)
})
test("location update via executeAction works", async () => {
const manager = new UserSessionManager({ providers: [async () => new LocationSource()] })
const session = await manager.getOrCreate("user-1")
await session.engine.executeAction("aelis.location", "update-location", {
lat: 51.5074,
lng: -0.1278,
accuracy: 10,
timestamp: new Date(),
})
const source = session.getSource<LocationSource>("aelis.location")
expect(source?.lastLocation?.lat).toBe(51.5074)
})
test("subscribe receives updates after location push", async () => {
const manager = new UserSessionManager({ providers: [async () => new LocationSource()] })
const callback = mock()
const session = await manager.getOrCreate("user-1")
session.engine.subscribe(callback)
await session.engine.executeAction("aelis.location", "update-location", {
lat: 51.5074,
lng: -0.1278,
accuracy: 10,
timestamp: new Date(),
})
// Wait for async update propagation
await new Promise((resolve) => setTimeout(resolve, 10))
expect(callback).toHaveBeenCalled()
})
test("remove stops reactive updates", async () => {
const manager = new UserSessionManager({ providers: [async () => new LocationSource()] })
const callback = mock()
const session = await manager.getOrCreate("user-1")
session.engine.subscribe(callback)
manager.remove("user-1")
// Create new session and push location — old callback should not fire
const session2 = await manager.getOrCreate("user-1")
await session2.engine.executeAction("aelis.location", "update-location", {
lat: 51.5074,
lng: -0.1278,
accuracy: 10,
timestamp: new Date(),
})
await new Promise((resolve) => setTimeout(resolve, 10))
expect(callback).not.toHaveBeenCalled()
})
test("creates session with successful providers when some fail", async () => {
const manager = new UserSessionManager({
providers: [
async () => new LocationSource(),
async () => {
throw new Error("provider failed")
},
],
})
const spy = spyOn(console, "error").mockImplementation(() => {})
const session = await manager.getOrCreate("user-1")
expect(session).toBeDefined()
expect(session.getSource("aelis.location")).toBeDefined()
expect(spy).toHaveBeenCalled()
spy.mockRestore()
})
test("throws AggregateError when all providers fail", async () => {
const manager = new UserSessionManager({
providers: [
async () => {
throw new Error("first failed")
},
async () => {
throw new Error("second failed")
},
],
})
await expect(manager.getOrCreate("user-1")).rejects.toBeInstanceOf(AggregateError)
})
test("concurrent getOrCreate for same user returns same session", async () => {
let callCount = 0
const manager = new UserSessionManager({
providers: [
async () => {
callCount++
// Simulate async work to widen the race window
await new Promise((resolve) => setTimeout(resolve, 10))
return new LocationSource()
},
],
})
const [session1, session2] = await Promise.all([
manager.getOrCreate("user-1"),
manager.getOrCreate("user-1"),
])
expect(session1).toBe(session2)
expect(callCount).toBe(1)
})
test("remove during in-flight getOrCreate prevents session from being stored", async () => {
let resolveProvider: () => void
const providerGate = new Promise<void>((r) => {
resolveProvider = r
})
const manager = new UserSessionManager({
providers: [
async () => {
await providerGate
return new LocationSource()
},
],
})
const sessionPromise = manager.getOrCreate("user-1")
// remove() while provider is still resolving
manager.remove("user-1")
// Let the provider finish
resolveProvider!()
await expect(sessionPromise).rejects.toThrow("removed during creation")
// A fresh getOrCreate should produce a new session, not the cancelled one
const freshSession = await manager.getOrCreate("user-1")
expect(freshSession).toBeDefined()
expect(freshSession.engine).toBeDefined()
})
})

View File

@@ -1,86 +0,0 @@
import type { FeedSource } from "@aelis/core"
import type { FeedEnhancer } from "../enhancement/enhance-feed.ts"
import type { FeedSourceProviderInput } from "./feed-source-provider.ts"
import { UserSession } from "./user-session.ts"
export interface UserSessionManagerConfig {
providers: FeedSourceProviderInput[]
feedEnhancer?: FeedEnhancer | null
}
export class UserSessionManager {
private sessions = new Map<string, UserSession>()
private pending = new Map<string, Promise<UserSession>>()
private readonly providers: FeedSourceProviderInput[]
private readonly feedEnhancer: FeedEnhancer | null
constructor(config: UserSessionManagerConfig) {
this.providers = config.providers
this.feedEnhancer = config.feedEnhancer ?? null
}
async getOrCreate(userId: string): Promise<UserSession> {
const existing = this.sessions.get(userId)
if (existing) return existing
const inflight = this.pending.get(userId)
if (inflight) return inflight
const promise = this.createSession(userId)
this.pending.set(userId, promise)
try {
const session = await promise
// If remove() was called while we were awaiting, it clears the
// pending entry. Detect that and destroy the session immediately.
if (!this.pending.has(userId)) {
session.destroy()
throw new Error(`Session for user ${userId} was removed during creation`)
}
this.sessions.set(userId, session)
return session
} finally {
this.pending.delete(userId)
}
}
remove(userId: string): void {
const session = this.sessions.get(userId)
if (session) {
session.destroy()
this.sessions.delete(userId)
}
// Cancel any in-flight creation so getOrCreate won't store the session
this.pending.delete(userId)
}
private async createSession(userId: string): Promise<UserSession> {
const results = await Promise.allSettled(
this.providers.map((p) =>
typeof p === "function" ? p(userId) : p.feedSourceForUser(userId),
),
)
const sources: FeedSource[] = []
const errors: unknown[] = []
for (const result of results) {
if (result.status === "fulfilled") {
sources.push(result.value)
} else {
errors.push(result.reason)
}
}
if (sources.length === 0 && errors.length > 0) {
throw new AggregateError(errors, "All feed source providers failed")
}
for (const error of errors) {
console.error("[UserSessionManager] Feed source provider failed:", error)
}
return new UserSession(sources, this.feedEnhancer)
}
}

View File

@@ -1,216 +0,0 @@
import type { ActionDefinition, ContextEntry, FeedItem, FeedSource } from "@aelis/core"
import { LocationSource } from "@aelis/source-location"
import { describe, expect, test } from "bun:test"
import { UserSession } from "./user-session.ts"
function createStubSource(id: string, items: FeedItem[] = []): FeedSource {
return {
id,
async listActions(): Promise<Record<string, ActionDefinition>> {
return {}
},
async executeAction(): Promise<unknown> {
return undefined
},
async fetchContext(): Promise<readonly ContextEntry[] | null> {
return null
},
async fetchItems() {
return items
},
}
}
describe("UserSession", () => {
test("registers sources and starts engine", async () => {
const session = new UserSession([createStubSource("test-a"), createStubSource("test-b")])
const result = await session.engine.refresh()
expect(result.errors).toHaveLength(0)
})
test("getSource returns registered source", () => {
const location = new LocationSource()
const session = new UserSession([location])
const result = session.getSource<LocationSource>("aelis.location")
expect(result).toBe(location)
})
test("getSource returns undefined for unknown source", () => {
const session = new UserSession([createStubSource("test")])
expect(session.getSource("unknown")).toBeUndefined()
})
test("destroy stops engine and clears sources", () => {
const session = new UserSession([createStubSource("test")])
session.destroy()
expect(session.getSource("test")).toBeUndefined()
})
test("engine.executeAction routes to correct source", async () => {
const location = new LocationSource()
const session = new UserSession([location])
await session.engine.executeAction("aelis.location", "update-location", {
lat: 51.5,
lng: -0.1,
accuracy: 10,
timestamp: new Date(),
})
expect(location.lastLocation).toBeDefined()
expect(location.lastLocation!.lat).toBe(51.5)
})
})
describe("UserSession.feed", () => {
test("returns feed items without enhancer", async () => {
const items: FeedItem[] = [
{
id: "item-1",
sourceId: "test",
type: "test",
timestamp: new Date("2025-01-01T00:00:00.000Z"),
data: { value: 42 },
},
]
const session = new UserSession([createStubSource("test", items)])
const result = await session.feed()
expect(result.items).toHaveLength(1)
expect(result.items[0]!.id).toBe("item-1")
})
test("returns enhanced items when enhancer is provided", async () => {
const items: FeedItem[] = [
{
id: "item-1",
sourceId: "test",
type: "test",
timestamp: new Date("2025-01-01T00:00:00.000Z"),
data: { value: 42 },
},
]
const enhancer = async (feedItems: FeedItem[]) =>
feedItems.map((item) => ({ ...item, data: { ...item.data, enhanced: true } }))
const session = new UserSession([createStubSource("test", items)], enhancer)
const result = await session.feed()
expect(result.items).toHaveLength(1)
expect(result.items[0]!.data.enhanced).toBe(true)
})
test("caches enhanced items on subsequent calls", async () => {
const items: FeedItem[] = [
{
id: "item-1",
sourceId: "test",
type: "test",
timestamp: new Date("2025-01-01T00:00:00.000Z"),
data: { value: 42 },
},
]
let enhancerCallCount = 0
const enhancer = async (feedItems: FeedItem[]) => {
enhancerCallCount++
return feedItems.map((item) => ({ ...item, data: { ...item.data, enhanced: true } }))
}
const session = new UserSession([createStubSource("test", items)], enhancer)
const result1 = await session.feed()
expect(result1.items[0]!.data.enhanced).toBe(true)
expect(enhancerCallCount).toBe(1)
const result2 = await session.feed()
expect(result2.items[0]!.data.enhanced).toBe(true)
expect(enhancerCallCount).toBe(1)
})
test("re-enhances after engine refresh with new data", async () => {
let currentItems: FeedItem[] = [
{
id: "item-1",
sourceId: "test",
type: "test",
timestamp: new Date("2025-01-01T00:00:00.000Z"),
data: { version: 1 },
},
]
const source = createStubSource("test", currentItems)
// Make fetchItems dynamic so refresh returns new data
source.fetchItems = async () => currentItems
const enhancedVersions: number[] = []
const enhancer = async (feedItems: FeedItem[]) => {
const version = feedItems[0]!.data.version as number
enhancedVersions.push(version)
return feedItems.map((item) => ({
...item,
data: { ...item.data, enhanced: true },
}))
}
const session = new UserSession([source], enhancer)
// First feed triggers refresh + enhancement
const result1 = await session.feed()
expect(result1.items[0]!.data.version).toBe(1)
expect(result1.items[0]!.data.enhanced).toBe(true)
// Update source data and trigger engine refresh
currentItems = [
{
id: "item-1",
sourceId: "test",
type: "test",
timestamp: new Date("2025-01-02T00:00:00.000Z"),
data: { version: 2 },
},
]
await session.engine.refresh()
// Wait for subscriber-triggered background enhancement
await new Promise((resolve) => setTimeout(resolve, 10))
// feed() should now serve re-enhanced items with version 2
const result2 = await session.feed()
expect(result2.items[0]!.data.version).toBe(2)
expect(result2.items[0]!.data.enhanced).toBe(true)
expect(enhancedVersions).toEqual([1, 2])
})
test("falls back to unenhanced items when enhancer throws", async () => {
const items: FeedItem[] = [
{
id: "item-1",
sourceId: "test",
type: "test",
timestamp: new Date("2025-01-01T00:00:00.000Z"),
data: { value: 42 },
},
]
const enhancer = async () => {
throw new Error("enhancement exploded")
}
const session = new UserSession([createStubSource("test", items)], enhancer)
const result = await session.feed()
expect(result.items).toHaveLength(1)
expect(result.items[0]!.id).toBe("item-1")
expect(result.items[0]!.data.value).toBe(42)
})
})

View File

@@ -1,32 +0,0 @@
/**
* Thrown by a FeedSourceProvider when the source is not enabled for a user.
*
* UserSessionManager's Promise.allSettled handles this gracefully —
* the source is excluded from the session without crashing.
*/
export class SourceDisabledError extends Error {
readonly sourceId: string
readonly userId: string
constructor(sourceId: string, userId: string) {
super(`Source "${sourceId}" is not enabled for user "${userId}"`)
this.name = "SourceDisabledError"
this.sourceId = sourceId
this.userId = userId
}
}
/**
* Thrown when an operation targets a user source that doesn't exist.
*/
export class SourceNotFoundError extends Error {
readonly sourceId: string
readonly userId: string
constructor(sourceId: string, userId: string) {
super(`Source "${sourceId}" not found for user "${userId}"`)
this.name = "SourceNotFoundError"
this.sourceId = sourceId
this.userId = userId
}
}

View File

@@ -1,47 +0,0 @@
import { TflSource, type ITflApi, type TflLineId } from "@aelis/source-tfl"
import { type } from "arktype"
import type { Database } from "../db/index.ts"
import type { FeedSourceProvider } from "../session/feed-source-provider.ts"
import { SourceDisabledError } from "../sources/errors.ts"
import { sources } from "../sources/user-sources.ts"
export type TflSourceProviderOptions =
| { db: Database; apiKey: string; client?: never }
| { db: Database; apiKey?: never; client: ITflApi }
const tflConfig = type({
"lines?": "string[]",
})
export class TflSourceProvider implements FeedSourceProvider {
private readonly db: Database
private readonly apiKey: string | undefined
private readonly client: ITflApi | undefined
constructor(options: TflSourceProviderOptions) {
this.db = options.db
this.apiKey = "apiKey" in options ? options.apiKey : undefined
this.client = "client" in options ? options.client : undefined
}
async feedSourceForUser(userId: string): Promise<TflSource> {
const row = await sources(this.db, userId).find("aelis.tfl")
if (!row || !row.enabled) {
throw new SourceDisabledError("aelis.tfl", userId)
}
const parsed = tflConfig(row.config ?? {})
if (parsed instanceof type.errors) {
throw new Error(`Invalid TFL config for user ${userId}: ${parsed.summary}`)
}
return new TflSource({
apiKey: this.apiKey,
client: this.client,
lines: parsed.lines as TflLineId[] | undefined,
})
}
}

View File

@@ -5,7 +5,7 @@ DATABASE_URL=postgresql://user:password@localhost:5432/aris
BETTER_AUTH_SECRET=
# 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
BETTER_AUTH_URL=http://localhost:3000

View File

@@ -1,10 +1,10 @@
{
"name": "@aelis/backend",
"name": "@freya/backend",
"version": "0.0.0",
"type": "module",
"main": "src/server.ts",
"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",
"test": "bun test src/",
"db:generate": "bunx drizzle-kit generate",
@@ -15,19 +15,21 @@
"create-admin": "bun run src/scripts/create-admin.ts"
},
"dependencies": {
"@aelis/core": "workspace:*",
"@aelis/source-caldav": "workspace:*",
"@aelis/source-google-calendar": "workspace:*",
"@aelis/source-location": "workspace:*",
"@aelis/source-tfl": "workspace:*",
"@aelis/source-weatherkit": "workspace:*",
"@freya/core": "workspace:*",
"@freya/source-caldav": "workspace:*",
"@freya/source-google-calendar": "workspace:*",
"@freya/source-location": "workspace:*",
"@freya/source-tfl": "workspace:*",
"@freya/source-weatherkit": "workspace:*",
"@openrouter/sdk": "^0.9.11",
"arktype": "^2.1.29",
"better-auth": "^1",
"drizzle-orm": "^0.45.1",
"hono": "^4"
"hono": "^4",
"lodash.merge": "^4.6.2"
},
"devDependencies": {
"@types/lodash.merge": "^4.6.9",
"drizzle-kit": "^0.31.9"
}
}

View File

@@ -0,0 +1,195 @@
import type { ActionDefinition, ContextEntry, FeedItem, FeedSource } from "@freya/core"
import { describe, expect, mock, test } from "bun:test"
import { Hono } from "hono"
import type { AdminMiddleware } from "../auth/admin-middleware.ts"
import type { AuthSession, AuthUser } from "../auth/session.ts"
import type { Database } from "../db/index.ts"
import type { FeedSourceProvider } from "../session/feed-source-provider.ts"
import { UserSessionManager } from "../session/user-session-manager.ts"
import { registerAdminHttpHandlers } from "./http.ts"
let mockEnabledSourceIds: string[] = []
mock.module("../sources/user-sources.ts", () => ({
sources: (_db: Database, _userId: string) => ({
async enabled() {
const now = new Date()
return mockEnabledSourceIds.map((sourceId) => ({
id: crypto.randomUUID(),
userId: _userId,
sourceId,
enabled: true,
config: {},
credentials: null,
createdAt: now,
updatedAt: now,
}))
},
async find(sourceId: string) {
const now = new Date()
return {
id: crypto.randomUUID(),
userId: _userId,
sourceId,
enabled: true,
config: {},
credentials: null,
createdAt: now,
updatedAt: now,
}
},
}),
}))
function createStubSource(id: string): FeedSource {
return {
id,
async listActions(): Promise<Record<string, ActionDefinition>> {
return {}
},
async executeAction(): Promise<unknown> {
return undefined
},
async fetchContext(): Promise<readonly ContextEntry[] | null> {
return null
},
async fetchItems(): Promise<FeedItem[]> {
return []
},
}
}
function createStubProvider(sourceId: string): FeedSourceProvider {
return {
sourceId,
async feedSourceForUser() {
return createStubSource(sourceId)
},
}
}
/** Passthrough admin middleware for testing (assumes admin). */
function passthroughAdminMiddleware(): AdminMiddleware {
const now = new Date()
return async (c, next) => {
c.set("user", {
id: "admin-1",
name: "Admin",
email: "admin@test.com",
emailVerified: true,
image: null,
createdAt: now,
updatedAt: now,
role: "admin",
banned: false,
banReason: null,
banExpires: null,
} as AuthUser)
c.set("session", { id: "sess-1" } as AuthSession)
await next()
}
}
const fakeDb = {} as Database
function createApp(providers: FeedSourceProvider[]) {
mockEnabledSourceIds = providers.map((p) => p.sourceId)
const sessionManager = new UserSessionManager({ db: fakeDb, providers })
const app = new Hono()
registerAdminHttpHandlers(app, {
sessionManager,
adminMiddleware: passthroughAdminMiddleware(),
db: fakeDb,
})
return { app, sessionManager }
}
const validWeatherConfig = {
credentials: {
privateKey: "pk-123",
keyId: "key-456",
teamId: "team-789",
serviceId: "svc-abc",
},
}
describe("PUT /api/admin/:sourceId/config", () => {
test("returns 404 for unknown provider", async () => {
const { app } = createApp([createStubProvider("freya.location")])
const res = await app.request("/api/admin/freya.nonexistent/config", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ key: "value" }),
})
expect(res.status).toBe(404)
const body = (await res.json()) as { error: string }
expect(body.error).toContain("not found")
})
test("returns 404 for provider without runtime config support", async () => {
const { app } = createApp([createStubProvider("freya.location")])
const res = await app.request("/api/admin/freya.location/config", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ key: "value" }),
})
expect(res.status).toBe(404)
const body = (await res.json()) as { error: string }
expect(body.error).toContain("not found")
})
test("returns 400 for invalid JSON body", async () => {
const { app } = createApp([createStubProvider("freya.weather")])
const res = await app.request("/api/admin/freya.weather/config", {
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).toContain("Invalid JSON")
})
test("returns 400 when weather config fails validation", async () => {
const { app } = createApp([createStubProvider("freya.weather")])
const res = await app.request("/api/admin/freya.weather/config", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ credentials: { privateKey: 123 } }),
})
expect(res.status).toBe(400)
const body = (await res.json()) as { error: string }
expect(body.error).toBeDefined()
})
test("returns 204 and applies valid weather config", async () => {
const { app, sessionManager } = createApp([createStubProvider("freya.weather")])
const originalProvider = sessionManager.getProvider("freya.weather")
const res = await app.request("/api/admin/freya.weather/config", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(validWeatherConfig),
})
expect(res.status).toBe(204)
// Provider was replaced with a new instance
const provider = sessionManager.getProvider("freya.weather")
expect(provider).toBeDefined()
expect(provider!.sourceId).toBe("freya.weather")
expect(provider).not.toBe(originalProvider)
})
})

View File

@@ -0,0 +1,86 @@
import type { Context, Hono } from "hono"
import { type } from "arktype"
import { createMiddleware } from "hono/factory"
import type { AdminMiddleware } from "../auth/admin-middleware.ts"
import type { Database } from "../db/index.ts"
import type { UserSessionManager } from "../session/index.ts"
import { WeatherSourceProvider } from "../weather/provider.ts"
type Env = {
Variables: {
sessionManager: UserSessionManager
db: Database
}
}
interface AdminHttpHandlersDeps {
sessionManager: UserSessionManager
adminMiddleware: AdminMiddleware
db: Database
}
export function registerAdminHttpHandlers(
app: Hono,
{ sessionManager, adminMiddleware, db }: AdminHttpHandlersDeps,
) {
const inject = createMiddleware<Env>(async (c, next) => {
c.set("sessionManager", sessionManager)
c.set("db", db)
await next()
})
app.put("/api/admin/:sourceId/config", inject, adminMiddleware, handleUpdateProviderConfig)
}
const WeatherKitSourceProviderConfig = type({
credentials: {
privateKey: "string",
keyId: "string",
teamId: "string",
serviceId: "string",
},
})
async function handleUpdateProviderConfig(c: Context<Env>) {
const sourceId = c.req.param("sourceId")
if (!sourceId) {
return c.body(null, 404)
}
const sessionManager = c.get("sessionManager")
let body: unknown
try {
body = await c.req.json()
} catch {
return c.json({ error: "Invalid JSON" }, 400)
}
switch (sourceId) {
case "freya.weather": {
const parsed = WeatherKitSourceProviderConfig(body)
if (parsed instanceof type.errors) {
return c.json({ error: parsed.summary }, 400)
}
const updated = new WeatherSourceProvider({
credentials: parsed.credentials,
})
try {
await sessionManager.replaceProvider(updated)
} catch (err) {
console.error(`[admin] replaceProvider("${sourceId}") failed:`, err)
return c.json({ error: "Failed to apply config" }, 500)
}
return c.body(null, 204)
}
default:
return c.json({ error: `Provider "${sourceId}" not found` }, 404)
}
}

View File

@@ -0,0 +1,95 @@
import { describe, expect, test } from "bun:test"
import { Hono } from "hono"
import type { Auth } from "./index.ts"
import type { AuthSession, AuthUser } from "./session.ts"
import { createRequireAdmin } from "./admin-middleware.ts"
function makeUser(role: string | null): AuthUser {
const now = new Date()
return {
id: "user-1",
name: "Test User",
email: "test@example.com",
emailVerified: true,
image: null,
createdAt: now,
updatedAt: now,
role,
banned: false,
banReason: null,
banExpires: null,
}
}
function makeSession(): AuthSession {
const now = new Date()
return {
id: "sess-1",
userId: "user-1",
token: "tok-1",
expiresAt: new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000),
ipAddress: "127.0.0.1",
userAgent: "test",
createdAt: now,
updatedAt: now,
}
}
function mockAuth(sessionResult: { user: AuthUser; session: AuthSession } | null): Auth {
return {
api: {
getSession: async () => sessionResult,
},
} as unknown as Auth
}
function createApp(auth: Auth) {
const app = new Hono()
const middleware = createRequireAdmin(auth)
app.get("/api/admin/test", middleware, (c) => c.json({ ok: true }))
return app
}
describe("createRequireAdmin", () => {
test("returns 401 when no session", async () => {
const app = createApp(mockAuth(null))
const res = await app.request("/api/admin/test")
expect(res.status).toBe(401)
const body = (await res.json()) as { error: string }
expect(body.error).toBe("Unauthorized")
})
test("returns 403 when user is not admin", async () => {
const app = createApp(mockAuth({ user: makeUser("user"), session: makeSession() }))
const res = await app.request("/api/admin/test")
expect(res.status).toBe(403)
const body = (await res.json()) as { error: string }
expect(body.error).toBe("Forbidden")
})
test("returns 403 when role is null", async () => {
const app = createApp(mockAuth({ user: makeUser(null), session: makeSession() }))
const res = await app.request("/api/admin/test")
expect(res.status).toBe(403)
})
test("allows admin users through and sets context", async () => {
const user = makeUser("admin")
const session = makeSession()
const app = createApp(mockAuth({ user, session }))
const res = await app.request("/api/admin/test")
expect(res.status).toBe(200)
const body = (await res.json()) as { ok: boolean }
expect(body.ok).toBe(true)
})
})

View File

@@ -0,0 +1,28 @@
import type { Context, MiddlewareHandler, Next } from "hono"
import type { Auth } from "./index.ts"
import type { AuthSessionEnv } from "./session-middleware.ts"
export type AdminMiddleware = MiddlewareHandler<AuthSessionEnv>
/**
* Creates a middleware that requires a valid session with admin role.
* Returns 401 if not authenticated, 403 if not admin.
*/
export function createRequireAdmin(auth: Auth): AdminMiddleware {
return async (c: Context, next: Next): Promise<Response | void> => {
const session = await auth.api.getSession({ headers: c.req.raw.headers })
if (!session) {
return c.json({ error: "Unauthorized" }, 401)
}
if (session.user.role !== "admin") {
return c.json({ error: "Forbidden" }, 403)
}
c.set("user", session.user)
c.set("session", session.session)
await next()
}
}

View File

@@ -16,6 +16,9 @@ export function createAuth(db: Database) {
provider: "pg",
schema,
}),
advanced: {
disableCSRFCheck: process.env.NODE_ENV !== "production",
},
emailAndPassword: {
enabled: true,
},

View File

@@ -79,7 +79,7 @@ export function mockAuthSessionMiddleware(userId?: string): AuthSessionMiddlewar
const user: AuthUser = {
id: "k7Gx2mPqRvNwYs9TdLfA4bHcJeUo1iZn",
name: "Dev User",
email: "dev@aelis.local",
email: "dev@freya.local",
emailVerified: true,
image: null,
createdAt: now,
@@ -96,7 +96,7 @@ export function mockAuthSessionMiddleware(userId?: string): AuthSessionMiddlewar
token: "Vb9CxNfRm2KwQs7TjPeA5dLhYg0UoZi4",
expiresAt,
ipAddress: "127.0.0.1",
userAgent: "aelis-dev",
userAgent: "freya-dev",
createdAt: now,
updatedAt: now,
}

View File

@@ -0,0 +1,85 @@
import { describe, expect, test } from "bun:test"
import { CalDavSourceProvider } from "./provider.ts"
describe("CalDavSourceProvider", () => {
const provider = new CalDavSourceProvider()
test("sourceId is freya.caldav", () => {
expect(provider.sourceId).toBe("freya.caldav")
})
test("throws when credentials are null", async () => {
const config = { serverUrl: "https://caldav.icloud.com", username: "user@icloud.com" }
await expect(provider.feedSourceForUser("user-1", config, null)).rejects.toThrow(
"No CalDAV credentials configured",
)
})
test("throws when credentials are missing password", async () => {
const config = { serverUrl: "https://caldav.icloud.com", username: "user@icloud.com" }
await expect(provider.feedSourceForUser("user-1", config, {})).rejects.toThrow(
"password must be a string",
)
})
test("throws when config is missing serverUrl", async () => {
const credentials = { password: "app-specific-password" }
await expect(
provider.feedSourceForUser("user-1", { username: "user@icloud.com" }, credentials),
).rejects.toThrow("Invalid CalDAV config")
})
test("throws when config is missing username", async () => {
const credentials = { password: "app-specific-password" }
await expect(
provider.feedSourceForUser("user-1", { serverUrl: "https://caldav.icloud.com" }, credentials),
).rejects.toThrow("Invalid CalDAV config")
})
test("throws when config has extra keys", async () => {
const config = {
serverUrl: "https://caldav.icloud.com",
username: "user@icloud.com",
extra: true,
}
const credentials = { password: "app-specific-password" }
await expect(provider.feedSourceForUser("user-1", config, credentials)).rejects.toThrow(
"Invalid CalDAV config",
)
})
test("throws when credentials have extra keys", async () => {
const config = { serverUrl: "https://caldav.icloud.com", username: "user@icloud.com" }
const credentials = { password: "app-specific-password", extra: true }
await expect(provider.feedSourceForUser("user-1", config, credentials)).rejects.toThrow(
"extra must be removed",
)
})
test("returns CalDavSource with valid config and credentials", async () => {
const config = {
serverUrl: "https://caldav.icloud.com",
username: "user@icloud.com",
lookAheadDays: 3,
timeZone: "Europe/London",
}
const credentials = { password: "app-specific-password" }
const source = await provider.feedSourceForUser("user-1", config, credentials)
expect(source).toBeDefined()
expect(source.id).toBe("freya.caldav")
})
test("returns CalDavSource with minimal config", async () => {
const config = {
serverUrl: "https://caldav.icloud.com",
username: "user@icloud.com",
}
const credentials = { password: "app-specific-password" }
const source = await provider.feedSourceForUser("user-1", config, credentials)
expect(source).toBeDefined()
expect(source.id).toBe("freya.caldav")
})
})

View File

@@ -0,0 +1,53 @@
import { CalDavSource } from "@freya/source-caldav"
import { type } from "arktype"
import type { FeedSourceProvider } from "../session/feed-source-provider.ts"
import { InvalidSourceCredentialsError } from "../sources/errors.ts"
const caldavConfig = type({
"+": "reject",
serverUrl: "string",
username: "string",
"lookAheadDays?": "number",
"timeZone?": "string",
})
const caldavCredentials = type({
"+": "reject",
password: "string",
})
export class CalDavSourceProvider implements FeedSourceProvider {
readonly sourceId = "freya.caldav"
readonly configSchema = caldavConfig
async feedSourceForUser(
_userId: string,
config: unknown,
credentials: unknown,
): Promise<CalDavSource> {
const parsed = caldavConfig(config)
if (parsed instanceof type.errors) {
throw new Error(`Invalid CalDAV config: ${parsed.summary}`)
}
if (!credentials) {
throw new InvalidSourceCredentialsError("freya.caldav", "No CalDAV credentials configured")
}
const creds = caldavCredentials(credentials)
if (creds instanceof type.errors) {
throw new InvalidSourceCredentialsError("freya.caldav", creds.summary)
}
return new CalDavSource({
serverUrl: parsed.serverUrl,
authMethod: "basic",
username: parsed.username,
password: creds.password,
lookAheadDays: parsed.lookAheadDays,
timeZone: parsed.timeZone,
})
}
}

View File

@@ -1,9 +1,12 @@
import type { PgDatabase } from "drizzle-orm/pg-core"
import { SQL } from "bun"
import { drizzle, type BunSQLDatabase } from "drizzle-orm/bun-sql"
import { drizzle, type BunSQLQueryResultHKT } from "drizzle-orm/bun-sql"
import * as schema from "./schema.ts"
export type Database = BunSQLDatabase<typeof schema>
/** Covers both the top-level drizzle instance and transaction handles. */
export type Database = PgDatabase<BunSQLQueryResultHKT, typeof schema>
export interface DatabaseConnection {
db: Database

View File

@@ -29,7 +29,7 @@ export {
import { user } from "./auth-schema.ts"
// ---------------------------------------------------------------------------
// AELIS — per-user source configuration
// FREYA — per-user source configuration
// ---------------------------------------------------------------------------
const bytea = customType<{ data: Buffer }>({

View File

@@ -1,9 +1,11 @@
import type { ActionDefinition, ContextEntry, FeedItem, FeedSource } from "@aelis/core"
import type { ActionDefinition, ContextEntry, FeedItem, FeedSource } from "@freya/core"
import { contextKey } from "@aelis/core"
import { describe, expect, spyOn, test } from "bun:test"
import { contextKey } from "@freya/core"
import { describe, expect, mock, spyOn, test } from "bun:test"
import { Hono } from "hono"
import type { Database } from "../db/index.ts"
import { mockAuthSessionMiddleware } from "../auth/session-middleware.ts"
import { UserSessionManager } from "../session/index.ts"
import { registerFeedHttpHandlers } from "./http.ts"
@@ -50,9 +52,45 @@ function buildTestApp(sessionManager: UserSessionManager, userId?: string) {
return app
}
let mockEnabledSourceIds: string[] = []
mock.module("../sources/user-sources.ts", () => ({
sources: (_db: Database, _userId: string) => ({
async enabled() {
const now = new Date()
return mockEnabledSourceIds.map((sourceId) => ({
id: crypto.randomUUID(),
userId: _userId,
sourceId,
enabled: true,
config: {},
credentials: null,
createdAt: now,
updatedAt: now,
}))
},
async find(sourceId: string) {
const now = new Date()
return {
id: crypto.randomUUID(),
userId: _userId,
sourceId,
enabled: true,
config: {},
credentials: null,
createdAt: now,
updatedAt: now,
}
},
}),
}))
const fakeDb = {} as Database
describe("GET /api/feed", () => {
test("returns 401 without auth", async () => {
const manager = new UserSessionManager({ providers: [] })
mockEnabledSourceIds = []
const manager = new UserSessionManager({ db: fakeDb, providers: [] })
const app = buildTestApp(manager)
const res = await app.request("/api/feed")
@@ -71,8 +109,17 @@ describe("GET /api/feed", () => {
data: { value: 42 },
},
]
mockEnabledSourceIds = ["test"]
const manager = new UserSessionManager({
providers: [async () => createStubSource("test", items)],
db: fakeDb,
providers: [
{
sourceId: "test",
async feedSourceForUser() {
return createStubSource("test", items)
},
},
],
})
const app = buildTestApp(manager, "user-1")
@@ -104,8 +151,17 @@ describe("GET /api/feed", () => {
data: { fresh: true },
},
]
mockEnabledSourceIds = ["test"]
const manager = new UserSessionManager({
providers: [async () => createStubSource("test", items)],
db: fakeDb,
providers: [
{
sourceId: "test",
async feedSourceForUser() {
return createStubSource("test", items)
},
},
],
})
const app = buildTestApp(manager, "user-1")
@@ -136,7 +192,18 @@ describe("GET /api/feed", () => {
throw new Error("connection timeout")
},
}
const manager = new UserSessionManager({ providers: [async () => failingSource] })
mockEnabledSourceIds = ["failing"]
const manager = new UserSessionManager({
db: fakeDb,
providers: [
{
sourceId: "failing",
async feedSourceForUser() {
return failingSource
},
},
],
})
const app = buildTestApp(manager, "user-1")
const res = await app.request("/api/feed")
@@ -150,10 +217,15 @@ describe("GET /api/feed", () => {
})
test("returns 503 when all providers fail", async () => {
mockEnabledSourceIds = ["test"]
const manager = new UserSessionManager({
db: fakeDb,
providers: [
async () => {
throw new Error("provider down")
{
sourceId: "test",
async feedSourceForUser() {
throw new Error("provider down")
},
},
],
})
@@ -172,7 +244,7 @@ describe("GET /api/feed", () => {
})
describe("GET /api/context", () => {
const weatherKey = contextKey("aelis.weather", "weather")
const weatherKey = contextKey("freya.weather", "weather")
const weatherData = { temperature: 20, condition: "Clear" }
const contextEntries: readonly ContextEntry[] = [[weatherKey, weatherData]]
@@ -180,8 +252,17 @@ describe("GET /api/context", () => {
const mockUserId = "k7Gx2mPqRvNwYs9TdLfA4bHcJeUo1iZn"
async function buildContextApp(userId?: string) {
mockEnabledSourceIds = ["weather"]
const manager = new UserSessionManager({
providers: [async () => createStubSource("weather", [], contextEntries)],
db: fakeDb,
providers: [
{
sourceId: "weather",
async feedSourceForUser() {
return createStubSource("weather", [], contextEntries)
},
},
],
})
const app = buildTestApp(manager, userId)
const session = await manager.getOrCreate(mockUserId)
@@ -189,10 +270,11 @@ describe("GET /api/context", () => {
}
test("returns 401 without auth", async () => {
const manager = new UserSessionManager({ providers: [] })
mockEnabledSourceIds = []
const manager = new UserSessionManager({ db: fakeDb, providers: [] })
const app = buildTestApp(manager)
const res = await app.request('/api/context?key=["aelis.weather","weather"]')
const res = await app.request('/api/context?key=["freya.weather","weather"]')
expect(res.status).toBe(401)
})
@@ -250,7 +332,7 @@ describe("GET /api/context", () => {
test("returns 400 when match param is invalid", async () => {
const { app } = await buildContextApp("user-1")
const res = await app.request('/api/context?key=["aelis.weather"]&match=invalid')
const res = await app.request('/api/context?key=["freya.weather"]&match=invalid')
expect(res.status).toBe(400)
const body = (await res.json()) as { error: string }
@@ -261,7 +343,7 @@ describe("GET /api/context", () => {
const { app, session } = await buildContextApp("user-1")
await session.engine.refresh()
const res = await app.request('/api/context?key=["aelis.weather","weather"]&match=exact')
const res = await app.request('/api/context?key=["freya.weather","weather"]&match=exact')
expect(res.status).toBe(200)
const body = (await res.json()) as { match: string; value: unknown }
@@ -273,7 +355,7 @@ describe("GET /api/context", () => {
const { app, session } = await buildContextApp("user-1")
await session.engine.refresh()
const res = await app.request('/api/context?key=["aelis.weather"]&match=exact')
const res = await app.request('/api/context?key=["freya.weather"]&match=exact')
expect(res.status).toBe(404)
})
@@ -282,7 +364,7 @@ describe("GET /api/context", () => {
const { app, session } = await buildContextApp("user-1")
await session.engine.refresh()
const res = await app.request('/api/context?key=["aelis.weather"]&match=prefix')
const res = await app.request('/api/context?key=["freya.weather"]&match=prefix')
expect(res.status).toBe(200)
const body = (await res.json()) as {
@@ -291,7 +373,7 @@ describe("GET /api/context", () => {
}
expect(body.match).toBe("prefix")
expect(body.entries).toHaveLength(1)
expect(body.entries[0]!.key).toEqual(["aelis.weather", "weather"])
expect(body.entries[0]!.key).toEqual(["freya.weather", "weather"])
expect(body.entries[0]!.value).toEqual(weatherData)
})
@@ -299,7 +381,7 @@ describe("GET /api/context", () => {
const { app, session } = await buildContextApp("user-1")
await session.engine.refresh()
const res = await app.request('/api/context?key=["aelis.weather","weather"]')
const res = await app.request('/api/context?key=["freya.weather","weather"]')
expect(res.status).toBe(200)
const body = (await res.json()) as { match: string; value: unknown }
@@ -311,7 +393,7 @@ describe("GET /api/context", () => {
const { app, session } = await buildContextApp("user-1")
await session.engine.refresh()
const res = await app.request('/api/context?key=["aelis.weather"]')
const res = await app.request('/api/context?key=["freya.weather"]')
expect(res.status).toBe(200)
const body = (await res.json()) as {

View File

@@ -1,6 +1,6 @@
import type { Context, Hono } from "hono"
import { contextKey } from "@aelis/core"
import { contextKey } from "@freya/core"
import { createMiddleware } from "hono/factory"
import type { AuthSessionMiddleware } from "../auth/session-middleware.ts"

View File

@@ -1,4 +1,4 @@
import type { FeedItem } from "@aelis/core"
import type { FeedItem } from "@freya/core"
import type { LlmClient } from "./llm-client.ts"
@@ -47,5 +47,3 @@ export function createFeedEnhancer(config: FeedEnhancerConfig): FeedEnhancer {
return mergeEnhancement(items, result, currentTime)
}
}

View File

@@ -4,7 +4,7 @@ import type { EnhancementResult } 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
export interface LlmClientConfig {
@@ -46,15 +46,17 @@ export function createLlmClient(config: LlmClientConfig): LlmClient {
type: "json_schema" as const,
jsonSchema: {
name: "enhancement_result",
strict: true,
strict: false,
schema: enhancementResultJsonSchema,
},
},
reasoning: { effort: "none" },
stream: false,
},
})
const content = response.choices?.[0]?.message?.content
const message = response.choices?.[0]?.message
const content = message?.content ?? message?.reasoning
if (typeof content !== "string") {
console.warn("[enhancement] LLM returned no content in response")
return null

View File

@@ -1,4 +1,4 @@
import type { FeedItem } from "@aelis/core"
import type { FeedItem } from "@freya/core"
import { describe, expect, test } from "bun:test"

View File

@@ -1,8 +1,8 @@
import type { FeedItem } from "@aelis/core"
import type { FeedItem } from "@freya/core"
import type { EnhancementResult } from "./schema.ts"
const ENHANCEMENT_SOURCE_ID = "aelis.enhancement"
const ENHANCEMENT_SOURCE_ID = "freya.enhancement"
/**
* Merges an EnhancementResult into feed items.

View File

@@ -1,4 +1,4 @@
import type { FeedItem } from "@aelis/core"
import type { FeedItem } from "@freya/core"
import { describe, expect, test } from "bun:test"

View File

@@ -1,7 +1,7 @@
import type { FeedItem } from "@aelis/core"
import type { FeedItem } from "@freya/core"
import { CalDavFeedItemType } from "@aelis/source-caldav"
import { CalendarFeedItemType } from "@aelis/source-google-calendar"
import { CalDavFeedItemType } from "@freya/source-caldav"
import { CalendarFeedItemType } from "@freya/source-google-calendar"
import systemPromptBase from "./prompts/system.txt"
@@ -36,8 +36,7 @@ export function buildPrompt(
for (const item of items) {
const hasUnfilledSlots =
item.slots &&
Object.values(item.slots).some((slot) => slot.content === null)
item.slots && Object.values(item.slots).some((slot) => slot.content === null)
if (hasUnfilledSlots) {
enhanceItems.push({
@@ -79,9 +78,7 @@ export function buildPrompt(
*/
export function hasUnfilledSlots(items: FeedItem[]): boolean {
return items.some(
(item) =>
item.slots &&
Object.values(item.slots).some((slot) => slot.content === null),
(item) => item.slots && Object.values(item.slots).some((slot) => slot.content === null),
)
}
@@ -129,7 +126,20 @@ function extractCalendarEntry(item: FeedItem): CalendarEntry | null {
}
const DAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"] as const
const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"] as const
const MONTHS = [
"Jan",
"Feb",
"Mar",
"Apr",
"May",
"Jun",
"Jul",
"Aug",
"Sep",
"Oct",
"Nov",
"Dec",
] as const
function pad2(n: number): string {
return n.toString().padStart(2, "0")
@@ -144,7 +154,11 @@ function formatDayShort(date: Date): string {
}
function formatDayLabel(date: Date, currentTime: Date): string {
const currentDay = Date.UTC(currentTime.getUTCFullYear(), currentTime.getUTCMonth(), currentTime.getUTCDate())
const currentDay = Date.UTC(
currentTime.getUTCFullYear(),
currentTime.getUTCMonth(),
currentTime.getUTCDate(),
)
const targetDay = Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate())
const diffDays = Math.round((targetDay - currentDay) / (1000 * 60 * 60 * 24))

View File

@@ -1,4 +1,4 @@
You are AELIS, a personal assistant. You enhance a user's feed by filling slots and optionally generating synthetic items.
You are FREYA, a personal assistant. You enhance a user's feed by filling slots and optionally generating synthetic items.
The user message is a JSON object with:
- "items": feed items with data and named slots to fill. Each slot has a description of what to write.

View File

@@ -135,9 +135,7 @@ describe("schema sync", () => {
// JSON Schema structure matches
const jsonSchema = enhancementResultJsonSchema
expect(Object.keys(jsonSchema.properties).sort()).toEqual(
Object.keys(payload).sort(),
)
expect(Object.keys(jsonSchema.properties).sort()).toEqual(Object.keys(payload).sort())
expect([...jsonSchema.required].sort()).toEqual(Object.keys(payload).sort())
// syntheticItems item schema has the right required fields
@@ -166,11 +164,8 @@ describe("schema sync", () => {
expect(parseEnhancementResult(JSON.stringify(bad))).toBeNull()
// JSON Schema only allows string or null for slot values
const slotValueTypes =
enhancementResultJsonSchema.properties.slotFills.additionalProperties
.additionalProperties.type
expect(slotValueTypes).toContain("string")
expect(slotValueTypes).toContain("null")
expect(slotValueTypes).not.toContain("number")
const slotValueSchema =
enhancementResultJsonSchema.properties.slotFills.additionalProperties.additionalProperties
expect(slotValueSchema.anyOf).toEqual([{ type: "string" }, { type: "null" }])
})
})

View File

@@ -31,7 +31,7 @@ export const enhancementResultJsonSchema = {
additionalProperties: {
type: "object",
additionalProperties: {
type: ["string", "null"],
anyOf: [{ type: "string" }, { type: "null" }],
},
},
},

View File

@@ -1,5 +1,5 @@
import { randomBytes } from "node:crypto"
import { describe, expect, test } from "bun:test"
import { randomBytes } from "node:crypto"
import { CredentialEncryptor } from "./crypto.ts"

View File

@@ -57,7 +57,7 @@ async function handleUpdateLocation(c: Context<Env>) {
return c.json({ error: "Service unavailable" }, 503)
}
await session.engine.executeAction("aelis.location", "update-location", {
await session.engine.executeAction("freya.location", "update-location", {
lat: result.lat,
lng: result.lng,
accuracy: result.accuracy,

View File

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

Some files were not shown because too many files have changed in this diff Show More