Compare commits

..

49 Commits

Author SHA1 Message Date
57c3275cce wip convo ui 2026-06-23 00:59:06 +01:00
25713ef614 chore: save wip changes 2026-06-20 16:48:08 +01:00
2e6cae4d02 chore: add zed settings (#151) 2026-06-20 16:46:39 +01:00
8cf38d609b feat: add upgrading expo skill (#150) 2026-06-20 16:46:30 +01:00
e6af1b7851 refactor: move conversation types to core (#149) 2026-06-18 20:47:36 +01:00
769fd5c77d feat: add conversation entries API (#148) 2026-06-18 17:19:47 +01:00
6cc0f7669a fix: upgrade client to expo 56 (#147)
Upgrade the React Native client through Expo SDK 56, align workspace React versions, switch Bun installs to the hoisted linker for Expo compatibility, and fix the Metro proxy to handle localhost/IPv6 loopback after the SDK upgrade.
2026-06-18 16:25:54 +01:00
63e71fb828 feat: add eas-cli to flake (#146) 2026-06-18 14:50:58 +01:00
e9f97d6f02 chore: add GitHub CLI to dev shell (#145) 2026-06-18 13:24:43 +01:00
e52e057548 ci: add nix dev tooling (#144) 2026-06-18 13:12:52 +01:00
d3d9def260 feat: use websocket in agent cli (#143) 2026-06-17 23:35:00 +01:00
227f196d5e feat: add agent websocket endpoint (#142) 2026-06-17 23:19:45 +01:00
e1c58cdf28 feat: add conversations endpoint (#141) 2026-06-17 23:01:08 +01:00
e11051b04b feat: add conversation storage (#140) 2026-06-16 20:16:03 +01:00
95f6c99f19 refactor: split query agent toolbox (#139) 2026-06-15 20:58:07 +01:00
bdf0392a55 fix: update waitlist domain (#138) 2026-06-15 16:51:24 +01:00
fc82af3f55 fix: wrong social media preview aspect ratio (#137) 2026-06-15 14:20:10 +01:00
9dc25dbc94 feat: use new social media preview (#136) 2026-06-15 14:05:52 +01:00
2b02c1a9d0 feat: change social media preview (#135) 2026-06-15 13:46:46 +01:00
fc443d967d feat: execute agent actions (#134) 2026-06-14 23:08:28 +01:00
9836d7499b feat: enable reminders by default (#133) 2026-06-14 22:38:26 +01:00
6785503ff9 build: update lockfile (#132) 2026-06-14 19:24:47 +01:00
54afa3add1 feat: add agent test cli (#131) 2026-06-14 16:06:31 +01:00
825f67db13 feat: add agent query API (#130) 2026-06-14 16:05:04 +01:00
083f6d2695 fix: require server env vars (#129) 2026-06-14 14:50:17 +01:00
789b6a285b feat: seed default user sources (#128) 2026-06-14 14:26:10 +01:00
112d482d55 chore: remove gpg signing skill (#127) 2026-06-14 00:18:25 +01:00
efd7537008 feat: add reminder source (#126) 2026-06-14 00:05:19 +01:00
38b21a1aa4 feat: add google maps mcp source (#125) 2026-06-13 01:59:54 +01:00
ef7301ab18 feat: add exa web search source (#124) 2026-06-13 00:46:53 +01:00
877b955493 feat: add mcp source primitive (#123) 2026-06-12 22:50:42 +01:00
6b1db0b3d3 chore: rename aelis to freya (#122) 2026-06-12 17:35:26 +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
423 changed files with 27098 additions and 7803 deletions

View File

@@ -0,0 +1,134 @@
---
name: upgrading-expo
description: Guidelines for upgrading Expo SDK versions and fixing dependency issues
version: 1.0.0
license: MIT
---
## References
- ./references/react-19.md -- SDK +54: React 19 changes (useContext → use, Context.Provider → Context, forwardRef removal)
- ./references/new-architecture.md -- SDK +53: New Architecture migration guide
- ./references/react-compiler.md -- SDK +54: React Compiler setup and migration guide
- ./references/native-tabs.md -- SDK +55: Native tabs changes (Icon/Label/Badge now accessed via NativeTabs.Trigger.\*)
- ./references/expo-av-to-audio.md -- SDK +55: Migrate audio playback and recording from expo-av to expo-audio
- ./references/expo-av-to-video.md -- SDK +55: Migrate video playback from expo-av to expo-video
- ./references/react-navigation-to-expo-router.md -- SDK +56: Migrate `@react-navigation/*` imports to `expo-router` entry points (codemod + manual mapping)
## Beta/Preview Releases
Beta versions use `.preview` suffix (e.g., `55.0.0-preview.2`), published under `@next` tag.
Check if latest is beta: https://exp.host/--/api/v2/versions (look for `-preview` in `expoVersion`)
```bash
npx expo install expo@next --fix # install beta
```
## Step-by-Step Upgrade Process
1. Upgrade Expo and dependencies
```bash
npx expo install expo@latest
npx expo install --fix
```
2. Run diagnostics: `npx expo-doctor`
3. Clear caches and reinstall
```bash
npx expo export -p ios --clear
rm -rf node_modules .expo
watchman watch-del-all
```
## Breaking Changes Checklist
- Check for removed APIs in release notes
- Update import paths for moved modules
- Review native module changes requiring prebuild
- Test all camera, audio, and video features
- Verify navigation still works correctly
## Prebuild for Native Changes
**First check if `ios/` and `android/` directories exist in the project.** If neither directory exists, the project uses Continuous Native Generation (CNG) and native projects are regenerated at build time — skip this section and "Clear caches for bare workflow" entirely.
If upgrading requires native changes:
```bash
npx expo prebuild --clean
```
This regenerates the `ios` and `android` directories. Ensure the project is not a bare workflow app before running this command.
## Clear caches for bare workflow
These steps only apply when `ios/` and/or `android/` directories exist in the project:
- Clear the cocoapods cache for iOS: `cd ios && pod install --repo-update`
- Clear derived data for Xcode: `npx expo run:ios --no-build-cache`
- Clear the Gradle cache for Android: `cd android && ./gradlew clean`
## Housekeeping
- Review release notes for the target SDK version at https://expo.dev/changelog
- If using Expo SDK 54 or later, ensure react-native-worklets is installed — this is required for react-native-reanimated to work.
- Enable React Compiler in SDK 54+ by adding `"experiments": { "reactCompiler": true }` to app.json — it's stable and recommended
- Delete sdkVersion from `app.json` to let Expo manage it automatically
- Remove implicit packages from `package.json`: `@babel/core`, `babel-preset-expo`, `expo-constants`.
- If the babel.config.js only contains 'babel-preset-expo', delete the file
- If the metro.config.js only contains expo defaults, delete the file
## Deprecated Packages
| Old Package | Replacement |
| -------------------- | ---------------------------------------------------- |
| `expo-av` | `expo-audio` and `expo-video` |
| `expo-permissions` | Individual package permission APIs |
| `@expo/vector-icons` | `expo-symbols` (for SF Symbols) |
| `AsyncStorage` | `expo-sqlite/localStorage/install` |
| `expo-app-loading` | `expo-splash-screen` |
| expo-linear-gradient | experimental_backgroundImage + CSS gradients in View |
When migrating deprecated packages, update all code usage before removing the old package. For expo-av, consult the migration references to convert Audio.Sound to useAudioPlayer, Audio.Recording to useAudioRecorder, and Video components to VideoView with useVideoPlayer.
## expo.install.exclude
Check if package.json has excluded packages:
```json
{
"expo": { "install": { "exclude": ["react-native-reanimated"] } }
}
```
Exclusions are often workarounds that may no longer be needed after upgrading. Review each one.
## Removing patches
Check if there are any outdated patches in the `patches/` directory. Remove them if they are no longer needed.
## Postcss
- `autoprefixer` isn't needed in SDK +53. Remove it from dependencies and check `postcss.config.js` or `postcss.config.mjs` to remove it from the plugins list.
- Use `postcss.config.mjs` in SDK +53.
## Metro
Remove redundant metro config options:
- resolver.unstable_enablePackageExports is enabled by default in SDK +53.
- `experimentalImportSupport` is enabled by default in SDK +54.
- `EXPO_USE_FAST_RESOLVER=1` is removed in SDK +54.
- cjs and mjs extensions are supported by default in SDK +50.
- Expo webpack is deprecated, migrate to [Expo Router and Metro web](https://docs.expo.dev/router/migrate/from-expo-webpack/).
## Hermes engine v1
Since SDK 55, users can opt-in to use Hermes engine v1 for improved runtime performance. This requires setting `useHermesV1: true` in the `expo-build-properties` config plugin, and may require a specific version of the `hermes-compiler` npm package. Hermes v1 will become a default in some future SDK release.
## New Architecture
The new architecture is enabled by default, the app.json field `"newArchEnabled": true` is no longer needed as it's the default. Expo Go only supports the new architecture as of SDK +53.

View File

@@ -0,0 +1,4 @@
interface:
display_name: "Upgrading Expo"
short_description: "Upgrade Expo SDKs, fix dependencies, adopt React 19 / React Compiler, and migrate deprecated Expo packages"
default_prompt: "Use $upgrading-expo to upgrade an Expo SDK, run diagnostics, fix dependency conflicts, decide whether prebuild/cache clearing applies, and migrate away from deprecated Expo packages."

View File

@@ -0,0 +1,132 @@
# Migrating from expo-av to expo-audio
## Imports
```tsx
// Before
import { Audio } from 'expo-av';
// After
import { useAudioPlayer, useAudioRecorder, RecordingPresets, AudioModule, setAudioModeAsync } from 'expo-audio';
```
## Audio Playback
### Before (expo-av)
```tsx
const [sound, setSound] = useState<Audio.Sound>();
async function playSound() {
const { sound } = await Audio.Sound.createAsync(require('./audio.mp3'));
setSound(sound);
await sound.playAsync();
}
useEffect(() => {
return sound ? () => { sound.unloadAsync(); } : undefined;
}, [sound]);
```
### After (expo-audio)
```tsx
const player = useAudioPlayer(require('./audio.mp3'));
// Play
player.play();
```
## Audio Recording
### Before (expo-av)
```tsx
const [recording, setRecording] = useState<Audio.Recording>();
async function startRecording() {
await Audio.requestPermissionsAsync();
await Audio.setAudioModeAsync({ allowsRecordingIOS: true, playsInSilentModeIOS: true });
const { recording } = await Audio.Recording.createAsync(Audio.RecordingOptionsPresets.HIGH_QUALITY);
setRecording(recording);
}
async function stopRecording() {
await recording?.stopAndUnloadAsync();
const uri = recording?.getURI();
}
```
### After (expo-audio)
```tsx
const recorder = useAudioRecorder(RecordingPresets.HIGH_QUALITY);
async function startRecording() {
await AudioModule.requestRecordingPermissionsAsync();
await recorder.prepareToRecordAsync();
recorder.record();
}
async function stopRecording() {
await recorder.stop();
const uri = recorder.uri;
}
```
## Audio Mode
### Before (expo-av)
```tsx
await Audio.setAudioModeAsync({
allowsRecordingIOS: true,
playsInSilentModeIOS: true,
staysActiveInBackground: true,
interruptionModeIOS: InterruptionModeIOS.DoNotMix,
});
```
### After (expo-audio)
```tsx
await setAudioModeAsync({
playsInSilentMode: true,
shouldPlayInBackground: true,
interruptionMode: 'doNotMix',
});
```
## API Mapping
| expo-av | expo-audio |
|---------|------------|
| `Audio.Sound.createAsync()` | `useAudioPlayer(source)` |
| `sound.playAsync()` | `player.play()` |
| `sound.pauseAsync()` | `player.pause()` |
| `sound.setPositionAsync(ms)` | `player.seekTo(seconds)` |
| `sound.setVolumeAsync(vol)` | `player.volume = vol` |
| `sound.setRateAsync(rate)` | `player.playbackRate = rate` |
| `sound.setIsLoopingAsync(loop)` | `player.loop = loop` |
| `sound.unloadAsync()` | Automatic via hook |
| `playbackStatus.positionMillis` | `player.currentTime` (seconds) |
| `playbackStatus.durationMillis` | `player.duration` (seconds) |
| `playbackStatus.isPlaying` | `player.playing` |
| `Audio.Recording.createAsync()` | `useAudioRecorder(preset)` |
| `Audio.RecordingOptionsPresets.*` | `RecordingPresets.*` |
| `recording.stopAndUnloadAsync()` | `recorder.stop()` |
| `recording.getURI()` | `recorder.uri` |
| `Audio.requestPermissionsAsync()` | `AudioModule.requestRecordingPermissionsAsync()` |
## Key Differences
- **No auto-reset on finish**: After `play()` completes, the player stays paused at the end. To replay, call `player.seekTo(0)` then `play()`
- **Time in seconds**: expo-audio uses seconds, not milliseconds (matching web standards)
- **Immediate loading**: Audio loads immediately when the hook mounts—no explicit preloading needed
- **Automatic cleanup**: No need to call `unloadAsync()`, hooks handle resource cleanup on unmount
- **Multiple players**: Create multiple `useAudioPlayer` instances and store them—all load immediately
- **Direct property access**: Set volume, rate, loop directly on the player object (`player.volume = 0.5`)
## API Reference
https://docs.expo.dev/versions/latest/sdk/audio/

View File

@@ -0,0 +1,160 @@
# Migrating from expo-av to expo-video
## Imports
```tsx
// Before
import { Video, ResizeMode } from 'expo-av';
// After
import { useVideoPlayer, VideoView, VideoSource } from 'expo-video';
import { useEvent, useEventListener } from 'expo';
```
## Video Playback
### Before (expo-av)
```tsx
const videoRef = useRef<Video>(null);
const [status, setStatus] = useState({});
<Video
ref={videoRef}
source={{ uri: 'https://example.com/video.mp4' }}
style={{ width: 350, height: 200 }}
resizeMode={ResizeMode.CONTAIN}
isLooping
onPlaybackStatusUpdate={setStatus}
/>
// Control
videoRef.current?.playAsync();
videoRef.current?.pauseAsync();
```
### After (expo-video)
```tsx
const player = useVideoPlayer('https://example.com/video.mp4', player => {
player.loop = true;
});
const { isPlaying } = useEvent(player, 'playingChange', { isPlaying: player.playing });
<VideoView
player={player}
style={{ width: 350, height: 200 }}
contentFit="contain"
/>
// Control
player.play();
player.pause();
```
## Status Updates
### Before (expo-av)
```tsx
<Video
onPlaybackStatusUpdate={status => {
if (status.isLoaded) {
console.log(status.positionMillis, status.durationMillis, status.isPlaying);
if (status.didJustFinish) console.log('finished');
}
}}
/>
```
### After (expo-video)
```tsx
// Reactive state
const { isPlaying } = useEvent(player, 'playingChange', { isPlaying: player.playing });
// Side effects
useEventListener(player, 'playToEnd', () => console.log('finished'));
// Direct access
console.log(player.currentTime, player.duration, player.playing);
```
## Local Files
### Before (expo-av)
```tsx
<Video source={require('./video.mp4')} />
```
### After (expo-video)
```tsx
const player = useVideoPlayer({ assetId: require('./video.mp4') });
```
## Fullscreen and PiP
```tsx
<VideoView
player={player}
allowsFullscreen
allowsPictureInPicture
onFullscreenEnter={() => {}}
onFullscreenExit={() => {}}
/>
```
For PiP and background playback, add to app.json:
```json
{
"expo": {
"plugins": [
["expo-video", { "supportsBackgroundPlayback": true, "supportsPictureInPicture": true }]
]
}
}
```
## API Mapping
| expo-av | expo-video |
|---------|------------|
| `<Video>` | `<VideoView>` |
| `ref={videoRef}` | `player={useVideoPlayer()}` |
| `source={{ uri }}` | Pass to `useVideoPlayer(uri)` |
| `resizeMode={ResizeMode.CONTAIN}` | `contentFit="contain"` |
| `isLooping` | `player.loop = true` |
| `shouldPlay` | `player.play()` in setup |
| `isMuted` | `player.muted = true` |
| `useNativeControls` | `nativeControls={true}` |
| `onPlaybackStatusUpdate` | `useEvent` / `useEventListener` |
| `videoRef.current.playAsync()` | `player.play()` |
| `videoRef.current.pauseAsync()` | `player.pause()` |
| `videoRef.current.replayAsync()` | `player.replay()` |
| `videoRef.current.setPositionAsync(ms)` | `player.currentTime = seconds` |
| `status.positionMillis` | `player.currentTime` (seconds) |
| `status.durationMillis` | `player.duration` (seconds) |
| `status.didJustFinish` | `useEventListener(player, 'playToEnd')` |
## Key Differences
- **Separate player and view**: Player logic decoupled from the view—one player can be used across multiple views
- **Time in seconds**: Uses seconds, not milliseconds
- **Event system**: Uses `useEvent`/`useEventListener` from `expo` instead of callback props
- **Video preloading**: Create a player without mounting a VideoView to preload for faster transitions
- **Built-in caching**: Set `useCaching: true` in VideoSource for persistent offline caching
## Known Issues
- **Uninstall expo-av first**: On Android, having both expo-av and expo-video installed can cause VideoView to show a black screen. Uninstall expo-av before installing expo-video
- **Android: Reusing players**: Mounting the same player in multiple VideoViews simultaneously can cause black screens on Android (works on iOS)
- **Android: currentTime in setup**: Setting `player.currentTime` in the `useVideoPlayer` setup callback may not work on Android—set it after mount instead
- **Changing source**: Use `player.replace(newSource)` to change videos without recreating the player
## API Reference
https://docs.expo.dev/versions/latest/sdk/video/

View File

@@ -0,0 +1,124 @@
# Native Tabs Migration (SDK 55)
In SDK 55, `Label`, `Icon`, `Badge`, and `VectorIcon` are now accessed as static properties on `NativeTabs.Trigger` rather than separate imports.
## Import Changes
```tsx
// SDK 53/54
import {
NativeTabs,
Icon,
Label,
Badge,
VectorIcon,
} from "expo-router/unstable-native-tabs";
// SDK 55+
import { NativeTabs } from "expo-router/unstable-native-tabs";
```
## Component Changes
| SDK 53/54 | SDK 55+ |
| ---------------- | ----------------------------------- |
| `<Icon />` | `<NativeTabs.Trigger.Icon />` |
| `<Label />` | `<NativeTabs.Trigger.Label />` |
| `<Badge />` | `<NativeTabs.Trigger.Badge />` |
| `<VectorIcon />` | `<NativeTabs.Trigger.VectorIcon />` |
| (n/a) | `<NativeTabs.BottomAccessory />` |
## New in SDK 55
### BottomAccessory
New component for Apple Music-style mini players on iOS +26 that float above the tab bar:
```tsx
<NativeTabs>
<NativeTabs.BottomAccessory>
{/* Content above tabs */}
</NativeTabs.BottomAccessory>
<NativeTabs.Trigger name="(index)">
<NativeTabs.Trigger.Label>Home</NativeTabs.Trigger.Label>
</NativeTabs.Trigger>
</NativeTabs>
```
On Android and web, this component will render as a no-op. Position a view absolutely above the tab bar instead.
### Icon `md` Prop
New `md` prop for Material icon glyphs on Android (alongside existing `drawable`):
```tsx
<NativeTabs.Trigger.Icon sf="house" md="home" />
```
## Full Migration Example
### Before (SDK 53/54)
```tsx
import {
NativeTabs,
Icon,
Label,
Badge,
} from "expo-router/unstable-native-tabs";
export default function TabLayout() {
return (
<NativeTabs minimizeBehavior="onScrollDown">
<NativeTabs.Trigger name="(index)">
<Label>Home</Label>
<Icon sf="house.fill" />
<Badge>3</Badge>
</NativeTabs.Trigger>
<NativeTabs.Trigger name="(settings)">
<Label>Settings</Label>
<Icon sf="gear" />
</NativeTabs.Trigger>
<NativeTabs.Trigger name="(search)" role="search">
<Label>Search</Label>
</NativeTabs.Trigger>
</NativeTabs>
);
}
```
### After (SDK 55+)
```tsx
import { NativeTabs } from "expo-router/unstable-native-tabs";
export default function TabLayout() {
return (
<NativeTabs minimizeBehavior="onScrollDown">
<NativeTabs.Trigger name="(index)">
<NativeTabs.Trigger.Label>Home</NativeTabs.Trigger.Label>
<NativeTabs.Trigger.Icon sf="house.fill" md="home" />
<NativeTabs.Trigger.Badge>3</NativeTabs.Trigger.Badge>
</NativeTabs.Trigger>
<NativeTabs.Trigger name="(settings)">
<NativeTabs.Trigger.Label>Settings</NativeTabs.Trigger.Label>
<NativeTabs.Trigger.Icon sf="gear" md="settings" />
</NativeTabs.Trigger>
<NativeTabs.Trigger name="(search)" role="search">
<NativeTabs.Trigger.Label>Search</NativeTabs.Trigger.Label>
</NativeTabs.Trigger>
</NativeTabs>
);
}
```
## Migration Checklist
1. Remove `Icon`, `Label`, `Badge`, `VectorIcon` from imports
2. Keep only `NativeTabs` import from `expo-router/unstable-native-tabs`
3. Replace `<Icon />` with `<NativeTabs.Trigger.Icon />`
4. Replace `<Label />` with `<NativeTabs.Trigger.Label />`
5. Replace `<Badge />` with `<NativeTabs.Trigger.Badge />`
6. Replace `<VectorIcon />` with `<NativeTabs.Trigger.VectorIcon />`
- Read docs for more info https://docs.expo.dev/versions/v55.0.0/sdk/router-native-tabs/

View File

@@ -0,0 +1,79 @@
# New Architecture
The New Architecture is enabled by default in Expo SDK 53+. It replaces the legacy bridge with a faster, synchronous communication layer between JavaScript and native code.
## Documentation
Full guide: https://docs.expo.dev/guides/new-architecture/
## What Changed
- **JSI (JavaScript Interface)** — Direct synchronous calls between JS and native
- **Fabric** — New rendering system with concurrent features
- **TurboModules** — Lazy-loaded native modules with type safety
## SDK Compatibility
| SDK Version | New Architecture Status |
| ----------- | ----------------------- |
| SDK 53+ | Enabled by default |
| SDK 52 | Opt-in via app.json |
| SDK 51- | Experimental |
## Configuration
New Architecture is enabled by default. To explicitly disable (not recommended):
```json
{
"expo": {
"newArchEnabled": false
}
}
```
## Expo Go
Expo Go only supports the New Architecture as of SDK 53. Apps using the old architecture must use development builds.
## Common Migration Issues
### Native Module Compatibility
Some older native modules may not support the New Architecture. Check:
1. Module documentation for New Architecture support
2. GitHub issues for compatibility discussions
3. Consider alternatives if module is unmaintained
### Reanimated
React Native Reanimated requires `react-native-worklets` in SDK 54+:
```bash
npx expo install react-native-worklets
```
### Layout Animations
Some layout animations behave differently. Test thoroughly after upgrading.
## Verifying New Architecture
Check if New Architecture is active:
```tsx
import { Platform } from "react-native";
// Returns true if Fabric is enabled
const isNewArch = global._IS_FABRIC !== undefined;
```
Verify from the command line if the currently running app uses the New Architecture: `bunx xcobra expo eval "_IS_FABRIC"` -> `true`
## Troubleshooting
1. **Clear caches**`npx expo start --clear`
2. **Clean prebuild**`npx expo prebuild --clean`
3. **Check native modules** — Ensure all dependencies support New Architecture
4. **Review console warnings** — Legacy modules log compatibility warnings

View File

@@ -0,0 +1,79 @@
# React 19
React 19 is included in Expo SDK 54. This release simplifies several common patterns.
## Context Changes
### useContext → use
The `use` hook replaces `useContext`:
```tsx
// Before (React 18)
import { useContext } from "react";
const value = useContext(MyContext);
// After (React 19)
import { use } from "react";
const value = use(MyContext);
```
- The `use` hook can also read promises, enabling Suspense-based data fetching.
- `use` can be called conditionally, this simplifies components that consume multiple contexts.
### Context.Provider → Context
Context providers no longer need the `.Provider` suffix:
```tsx
// Before (React 18)
<ThemeContext.Provider value={theme}>
{children}
</ThemeContext.Provider>
// After (React 19)
<ThemeContext value={theme}>
{children}
</ThemeContext>
```
## ref as a Prop
### Removing forwardRef
Components can now receive `ref` as a regular prop. `forwardRef` is no longer needed:
```tsx
// Before (React 18)
import { forwardRef } from "react";
const Input = forwardRef<TextInput, Props>((props, ref) => {
return <TextInput ref={ref} {...props} />;
});
// After (React 19)
function Input({ ref, ...props }: Props & { ref?: React.Ref<TextInput> }) {
return <TextInput ref={ref} {...props} />;
}
```
### Migration Steps
1. Remove `forwardRef` wrapper
2. Add `ref` to the props destructuring
3. Update the type to include `ref?: React.Ref<T>`
## Other React 19 Features
- **Actions** — Functions that handle async transitions
- **useOptimistic** — Optimistic UI updates
- **useFormStatus** — Form submission state (web)
- **Document Metadata** — Native `<title>` and `<meta>` support (web)
## Cleanup Checklist
When upgrading to SDK 54:
- [ ] Replace `useContext` with `use`
- [ ] Remove `.Provider` from Context components
- [ ] Remove `forwardRef` wrappers, use `ref` prop instead

View File

@@ -0,0 +1,59 @@
# React Compiler
React Compiler is stable in Expo SDK 54 and later. It automatically memoizes components and hooks, eliminating the need for manual `useMemo`, `useCallback`, and `React.memo`.
## Enabling React Compiler
Add to `app.json`:
```json
{
"expo": {
"experiments": {
"reactCompiler": true
}
}
}
```
## What React Compiler Does
- Automatically memoizes components and values
- Eliminates unnecessary re-renders
- Removes the need for manual `useMemo` and `useCallback`
- Works with existing code without modifications
## Cleanup After Enabling
Once React Compiler is enabled, you can remove manual memoization:
```tsx
// Before (manual memoization)
const memoizedValue = useMemo(() => computeExpensive(a, b), [a, b]);
const memoizedCallback = useCallback(() => doSomething(a), [a]);
const MemoizedComponent = React.memo(MyComponent);
// After (React Compiler handles it)
const value = computeExpensive(a, b);
const callback = () => doSomething(a);
// Just use MyComponent directly
```
## Requirements
- Expo SDK 54 or later
- New Architecture enabled (default in SDK 54+)
## Verifying It's Working
React Compiler runs at build time. Check the Metro bundler output for compilation messages. You can also use React DevTools to verify components are being optimized.
## Troubleshooting
If you encounter issues:
1. Ensure New Architecture is enabled
2. Clear Metro cache: `npx expo start --clear`
3. Check for incompatible patterns in your code (rare)
React Compiler is designed to work with idiomatic React code. If it can't safely optimize a component, it skips that component without breaking your app.

View File

@@ -0,0 +1,61 @@
# Migrating from react-navigation to expo-router
In SDK 56+, application code must not import from `@react-navigation/*` directly. Repoint those imports to the matching `expo-router` entry points. Runtime API is unchanged — only the module specifiers move.
## Steps
1. Prefer the automated codemod (see below). If it is not viable, fall back to the manual mapping.
2. Replace imports using the table. Use the entry point that matches the `@react-navigation/*` source.
3. After rewriting, check whether any of the rewritten imports are deprecated in `expo-router` (see [Check for deprecated imports](#check-for-deprecated-imports)). If so, surface the deprecation reason and the suggested replacement to the user before continuing.
4. Validate: search for remaining `@react-navigation/` references in source files, then run typecheck/build/start.
5. Remove `@react-navigation/*` packages that are no longer imported from `package.json` and reinstall (delete `node_modules` if needed).
## Automated migration (preferred)
Run from the project root over your application code (replace `src` with the actual directory or glob):
```sh
npx expo-codemod sdk-56-expo-router-react-navigation-replace src
```
```sh
npx expo-codemod sdk-56-expo-router-react-navigation-replace '**/*.{ts,tsx,js,jsx}'
```
## Manual API mapping
| React Navigation source | Expo Router target |
| ------------------------------------- | ------------------------------------------------------------------------ |
| `@react-navigation/native` | `expo-router/react-navigation` |
| `@react-navigation/core` | `expo-router/react-navigation` |
| `@react-navigation/elements` | `expo-router/react-navigation` |
| `@react-navigation/routers` | `expo-router/react-navigation` |
| `@react-navigation/stack` | `expo-router/js-stack` |
| `@react-navigation/bottom-tabs` | `expo-router/js-tabs` |
| `@react-navigation/material-top-tabs` | `expo-router/js-top-tabs` |
| `@react-navigation/native-stack` | No direct equivalent. Use the `Stack` layout from `expo-router` instead. |
**Stack caveat:** Do NOT rewrite `import { Stack } from "expo-router"` to `expo-router/js-stack`. The root `Stack` is the Expo Router layout component used in route files; only use `expo-router/js-stack` when replacing a `@react-navigation/stack` JS stack navigator.
If you encounter a symbol that has no replacement, ask the user to file an issue in the `expo/expo` repository describing what is needed and why.
## Check for deprecated imports
A successful rewrite to `expo-router/*` does not guarantee the new import is the recommended one. Some symbols are re-exported as deprecated shims and the project may need to migrate further (for example, to a different `expo-router` API or to a first-party Expo package).
For each symbol rewritten in step 2:
1. Resolve the rewritten module to its source in `node_modules` (e.g., `node_modules/expo-router/build/react-navigation.d.ts`, `js-stack`, `js-tabs`, `js-top-tabs`).
2. Look for a `@deprecated` JSDoc tag on the named export, or a runtime deprecation warning in the implementation file.
3. If deprecated, capture both the reason and the recommended replacement from the JSDoc/comment.
4. Report each deprecated symbol to the user with: the import path, the symbol, the deprecation reason, and the suggested replacement. Wait for the user to confirm before mass-applying further changes.
## Done when
1. No `@react-navigation/*` imports remain in source files.
2. No unused `@react-navigation/*` entries remain in `package.json`.
3. Typecheck and bundler start without `@react-navigation/*` errors.
## Reference
- Official Expo Router SDK 55 → 56 migration guide: https://docs.expo.dev/router/migrate/sdk-55-to-56

View File

@@ -1,43 +0,0 @@
---
name: gpg-commit-signing
description: Sign git commits with GPG in non-interactive environments. Use when committing code and the `GPG_PRIVATE_KEY_PASSPHRASE` environment variable is available. Triggers on "commit", "sign commit", "GPG", "git commit -S", or any git operation requiring signed commits.
---
# GPG Commit Signing
Sign commits in headless/non-interactive environments where `/dev/tty` is unavailable.
## Workflow
1. Check whether `GPG_PRIVATE_KEY_PASSPHRASE` is set:
```bash
test -n "$GPG_PRIVATE_KEY_PASSPHRASE" && echo "available" || echo "not set"
```
If not set, skip signing — commit without `-S`.
2. Try a direct signed commit first — the environment may already have loopback pinentry configured:
```bash
git commit -S -m "message"
```
If this succeeds, no further steps are needed.
3. If step 2 fails with a `/dev/tty` error, use `--pinentry-mode loopback` via a wrapper script:
```bash
printf '#!/bin/sh\ngpg --batch --pinentry-mode loopback --passphrase "$GPG_PRIVATE_KEY_PASSPHRASE" "$@"\n' > /tmp/gpg-sign.sh
chmod +x /tmp/gpg-sign.sh
git -c gpg.program=/tmp/gpg-sign.sh commit -S -m "message"
rm /tmp/gpg-sign.sh
```
This passes the passphrase directly to gpg on each signing invocation, bypassing the need for a configured gpg-agent.
## Anti-patterns
- Do not echo or log `GPG_PRIVATE_KEY_PASSPHRASE`.
- Do not commit without `-S` when the passphrase is available — the project expects signed commits.
- Do not leave wrapper scripts on disk after committing.

View File

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

View File

@@ -1,39 +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
drizzle-studio:
name: Drizzle Studio
description: Drizzle Studio database browser for aelis-backend
triggeredBy:
- manual
commands:
start: |
FORWARD_URL=$(gitpod environment port open 4983 --name drizzle-studio-server | sed 's|https://||')
echo "Drizzle Studio: https://local.drizzle.studio/?host=${FORWARD_URL}&port=443"
cd apps/aelis-backend && bunx drizzle-kit studio --host 0.0.0.0 --port 4983
aelis-backend:
name: Aelis Backend
description: Hono API server for aelis-backend (port 3000)
triggeredBy:
- manual
commands:
start: |
gitpod --context environment environment port open 3000 --name "Aelis Backend" --protocol http
cd apps/aelis-backend && bun run dev
admin-dashboard:
name: Admin Dashboard
description: Vite dev server for admin-dashboard (port 5174)
triggeredBy:
- manual
commands:
start: |
gitpod --context environment environment port open 5174 --name "Admin Dashboard" --protocol http
cd apps/admin-dashboard && bun run dev --host

View File

@@ -1,12 +1,12 @@
{ {
"$schema": "./node_modules/oxfmt/configuration_schema.json", "$schema": "./node_modules/oxfmt/configuration_schema.json",
"useTabs": true, "useTabs": true,
"semi": false, "semi": false,
"trailingComma": "all", "trailingComma": "all",
"experimentalSortImports": { "experimentalSortImports": {
"order": "asc", "order": "asc",
"ignoreCase": true, "ignoreCase": true,
"newlinesBetween": 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
}

20
.zed/settings.json Normal file
View File

@@ -0,0 +1,20 @@
// Folder-specific settings
//
// For a full list of overridable settings, and general information on folder-specific settings,
// see the documentation: https://zed.dev/docs/configuring-zed#settings-files
{
"languages": {
"TypeScript": {
"formatter": { "language_server": { "name": "oxfmt" } }
},
"TSX": {
"formatter": { "language_server": { "name": "oxfmt" } }
},
"JavaScript": {
"formatter": { "language_server": { "name": "oxfmt" } }
},
"JSX": {
"formatter": { "language_server": { "name": "oxfmt" } }
}
}
}

View File

@@ -2,7 +2,7 @@
## Project ## 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 ## Commands
@@ -39,4 +39,13 @@ Use Bun exclusively. Do not use npm or yarn.
- Branch: `feat/<task>`, `fix/<task>`, `ci/<task>`, etc. - Branch: `feat/<task>`, `fix/<task>`, `ci/<task>`, etc.
- Commits: conventional commit format, title <= 50 chars - Commits: conventional commit format, title <= 50 chars
- Signing: If `GPG_PRIVATE_KEY_PASSPHRASE` env var is available, use it to sign commits with `git commit -S`
## Nix
Use the Nix dev shell for project commands by default.
- Run repo tooling through `nix develop -c`, e.g. `nix develop -c bun test`.
- Use Bun exclusively inside the Nix shell.
- Do not use host `bun`, `node`, `tsc`, or package binaries for project tasks unless explicitly checking host behavior.
- Simple inspection commands like `rg`, `sed`, `ls`, and `git status` may run outside Nix.
- While `flake.nix` is untracked, use `nix develop path:. -c <command>`.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,639 @@
import type { Dispatch, FormEvent, SetStateAction } from "react"
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { Check, Loader2, Pencil, Plus, RefreshCw, RotateCcw, Save, Trash2, X } from "lucide-react"
import { useMemo, useState } from "react"
import { toast } from "sonner"
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 { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { Switch } from "@/components/ui/switch"
import { executeSourceAction, fetchFeed } from "@/lib/api"
const REMINDER_SOURCE_ID = "freya.reminders"
type ReminderPriority = "low" | "normal" | "high"
type ReminderFrequency = "daily" | "weekly" | "monthly" | "yearly"
type ReminderEditScope = "this-occurrence" | "this-and-future" | "entire-series"
interface ReminderRecurrence {
frequency: ReminderFrequency
interval: number
count?: number
until?: string
}
interface ReminderFeedData extends Record<string, unknown> {
reminderId: string
occurrenceId: string
title: string
notes: string | null
originalDueAt: string
dueAt: string
timeZone: string
recurrence: ReminderRecurrence | null
priority: ReminderPriority
completedAt: string | null
}
interface ReminderFormState {
title: string
notes: string
dueAt: string
priority: ReminderPriority
scope: ReminderEditScope
recurs: boolean
frequency: ReminderFrequency
interval: string
count: string
until: string
}
const emptyForm: ReminderFormState = {
title: "",
notes: "",
dueAt: toLocalInput(new Date()),
priority: "normal",
scope: "entire-series",
recurs: false,
frequency: "daily",
interval: "1",
count: "",
until: "",
}
export function ReminderCrudPanel() {
const queryClient = useQueryClient()
const [form, setForm] = useState<ReminderFormState>(emptyForm)
const [editing, setEditing] = useState<ReminderFeedData | null>(null)
const [deleteScopes, setDeleteScopes] = useState<Record<string, ReminderEditScope>>({})
const {
data: feed,
isFetching,
refetch,
} = useQuery({
queryKey: ["feed"],
queryFn: fetchFeed,
})
const reminders = useMemo(
() => (feed?.items ?? []).filter(isReminderItem).map((item) => item.data),
[feed],
)
const actionMutation = useMutation({
mutationFn: (input: { actionId: string; params: unknown }) =>
executeSourceAction(REMINDER_SOURCE_ID, input.actionId, input.params),
})
const busy = actionMutation.isPending
const canConfigureRecurrence = !editing || form.scope !== "this-occurrence"
async function runAction(actionId: string, params: unknown, success: string): Promise<boolean> {
try {
await actionMutation.mutateAsync({ actionId, params })
await queryClient.invalidateQueries({ queryKey: ["feed"] })
toast.success(success)
return true
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err))
return false
}
}
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
if (editing) {
const patch = formToPatch(formFromReminder(editing), form)
if (Object.keys(patch).length === 0) {
toast.info("No changes to save")
return
}
const saved = await runAction(
"update-reminder",
{
reminderId: editing.reminderId,
scope: form.scope,
occurrenceDueAt: editing.originalDueAt,
patch,
},
"Reminder updated",
)
if (saved) resetForm()
return
} else {
const created = await runAction(
"create-reminder",
formToCreatePayload(form),
"Reminder created",
)
if (created) resetForm()
}
}
function startEdit(reminder: ReminderFeedData) {
setEditing(reminder)
setForm(formFromReminder(reminder))
}
function resetForm() {
setEditing(null)
setForm({ ...emptyForm, dueAt: toLocalInput(new Date()) })
}
function getDeleteScope(reminder: ReminderFeedData): ReminderEditScope {
return (
deleteScopes[reminderKey(reminder)] ??
(reminder.recurrence ? "this-occurrence" : "entire-series")
)
}
function setDeleteScope(reminder: ReminderFeedData, scope: ReminderEditScope) {
setDeleteScopes((prev) => ({ ...prev, [reminderKey(reminder)]: scope }))
}
return (
<Card className="-mx-4">
<CardHeader className="pb-4">
<div className="flex items-center justify-between gap-3">
<CardTitle className="text-sm">Reminders</CardTitle>
<Button size="sm" variant="outline" onClick={() => refetch()} disabled={isFetching}>
{isFetching ? (
<Loader2 className="size-3.5 animate-spin" />
) : (
<RefreshCw className="size-3.5" />
)}
Refresh
</Button>
</div>
</CardHeader>
<CardContent className="space-y-5">
<form className="grid gap-4" onSubmit={handleSubmit}>
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-2 sm:col-span-2">
<Label htmlFor="reminder-title" className="text-xs font-medium">
Title
</Label>
<Input
id="reminder-title"
value={form.title}
onChange={(event) => setFormField(setForm, "title", event.target.value)}
disabled={busy}
required
/>
</div>
<div className="space-y-2 sm:col-span-2">
<Label htmlFor="reminder-notes" className="text-xs font-medium">
Notes
</Label>
<Input
id="reminder-notes"
value={form.notes}
onChange={(event) => setFormField(setForm, "notes", event.target.value)}
disabled={busy}
/>
</div>
<div className="space-y-2">
<Label htmlFor="reminder-due-at" className="text-xs font-medium">
Due
</Label>
<Input
id="reminder-due-at"
type="datetime-local"
value={form.dueAt}
onChange={(event) => setFormField(setForm, "dueAt", event.target.value)}
disabled={busy}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="reminder-priority" className="text-xs font-medium">
Priority
</Label>
<Select
value={form.priority}
onValueChange={(value) =>
setFormField(setForm, "priority", value as ReminderPriority)
}
disabled={busy}
>
<SelectTrigger id="reminder-priority">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="low">Low</SelectItem>
<SelectItem value="normal">Normal</SelectItem>
<SelectItem value="high">High</SelectItem>
</SelectContent>
</Select>
</div>
{editing?.recurrence && (
<div className="space-y-2 sm:col-span-2">
<Label htmlFor="reminder-edit-scope" className="text-xs font-medium">
Edit scope
</Label>
<Select
value={form.scope}
onValueChange={(value) =>
setFormField(setForm, "scope", value as ReminderEditScope)
}
disabled={busy}
>
<SelectTrigger id="reminder-edit-scope">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="this-occurrence">This occurrence</SelectItem>
<SelectItem value="this-and-future">This and future</SelectItem>
<SelectItem value="entire-series">Entire series</SelectItem>
</SelectContent>
</Select>
</div>
)}
</div>
{canConfigureRecurrence && (
<div className="grid gap-3 rounded-md border p-3 sm:grid-cols-4">
<div className="flex items-center justify-between gap-3 sm:col-span-4">
<Label htmlFor="reminder-recurs" className="text-xs font-medium">
Recurring
</Label>
<Switch
id="reminder-recurs"
checked={form.recurs}
onCheckedChange={(checked) => setFormField(setForm, "recurs", checked)}
disabled={busy}
/>
</div>
{form.recurs && (
<>
<div className="space-y-2 sm:col-span-2">
<Label htmlFor="reminder-frequency" className="text-xs font-medium">
Frequency
</Label>
<Select
value={form.frequency}
onValueChange={(value) =>
setFormField(setForm, "frequency", value as ReminderFrequency)
}
disabled={busy}
>
<SelectTrigger id="reminder-frequency">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="daily">Daily</SelectItem>
<SelectItem value="weekly">Weekly</SelectItem>
<SelectItem value="monthly">Monthly</SelectItem>
<SelectItem value="yearly">Yearly</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="reminder-interval" className="text-xs font-medium">
Interval
</Label>
<Input
id="reminder-interval"
type="number"
min={1}
value={form.interval}
onChange={(event) => setFormField(setForm, "interval", event.target.value)}
disabled={busy}
/>
</div>
<div className="space-y-2">
<Label htmlFor="reminder-count" className="text-xs font-medium">
Count
</Label>
<Input
id="reminder-count"
type="number"
min={1}
value={form.count}
onChange={(event) => setFormField(setForm, "count", event.target.value)}
disabled={busy}
/>
</div>
<div className="space-y-2 sm:col-span-4">
<Label htmlFor="reminder-until" className="text-xs font-medium">
Until
</Label>
<Input
id="reminder-until"
type="datetime-local"
value={form.until}
onChange={(event) => setFormField(setForm, "until", event.target.value)}
disabled={busy}
/>
</div>
</>
)}
</div>
)}
<div className="flex justify-end gap-2">
{editing && (
<Button type="button" variant="outline" onClick={resetForm} disabled={busy}>
<X className="size-3.5" />
Cancel
</Button>
)}
<Button type="submit" disabled={busy || !form.title || !form.dueAt}>
{busy ? <Loader2 className="size-3.5 animate-spin" /> : <Save className="size-3.5" />}
{editing ? "Update" : "Create"}
</Button>
</div>
</form>
<div className="space-y-2">
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>
{reminders.length} {reminders.length === 1 ? "occurrence" : "occurrences"}
</span>
{!editing && (
<Button size="sm" variant="ghost" onClick={resetForm} disabled={busy}>
<Plus className="size-3.5" />
New
</Button>
)}
</div>
{reminders.length === 0 && (
<div className="rounded-md border border-dashed px-3 py-6 text-center text-sm text-muted-foreground">
No reminders in the current feed.
</div>
)}
{reminders.map((reminder) => {
const deleteScope = getDeleteScope(reminder)
return (
<ReminderRow
key={reminderKey(reminder)}
reminder={reminder}
busy={busy}
deleteScope={deleteScope}
onDeleteScopeChange={(scope) => setDeleteScope(reminder, scope)}
onEdit={() => startEdit(reminder)}
onComplete={() =>
runAction(
reminder.completedAt ? "uncomplete-reminder" : "complete-reminder",
{
reminderId: reminder.reminderId,
occurrenceDueAt: reminder.originalDueAt,
},
reminder.completedAt ? "Reminder reopened" : "Reminder completed",
)
}
onDelete={() => {
if (
!confirm(
`Delete ${formatScope(deleteScope).toLowerCase()} for "${reminder.title}"?`,
)
) {
return
}
void runAction(
"delete-reminder",
{
reminderId: reminder.reminderId,
scope: deleteScope,
occurrenceDueAt: reminder.originalDueAt,
},
"Reminder deleted",
).then((deleted) => {
if (deleted && editing?.reminderId === reminder.reminderId) resetForm()
})
}}
/>
)
})}
</div>
</CardContent>
</Card>
)
}
function ReminderRow({
reminder,
busy,
deleteScope,
onDeleteScopeChange,
onEdit,
onComplete,
onDelete,
}: {
reminder: ReminderFeedData
busy: boolean
deleteScope: ReminderEditScope
onDeleteScopeChange: (scope: ReminderEditScope) => void
onEdit: () => void
onComplete: () => void
onDelete: () => void
}) {
return (
<div className="flex items-start justify-between gap-3 rounded-md border px-3 py-2">
<div className="min-w-0 space-y-1">
<div className="flex flex-wrap items-center gap-2">
<span className="truncate text-sm font-medium">{reminder.title}</span>
<Badge variant={reminder.completedAt ? "secondary" : "outline"} className="text-xs">
{reminder.completedAt ? "Done" : reminder.priority}
</Badge>
{reminder.recurrence && (
<Badge variant="secondary" className="text-xs">
{formatRecurrence(reminder.recurrence)}
</Badge>
)}
</div>
<div className="text-xs text-muted-foreground">{formatDate(reminder.dueAt)}</div>
{reminder.notes && <div className="text-xs text-muted-foreground">{reminder.notes}</div>}
</div>
<div className="flex shrink-0 flex-wrap items-center justify-end gap-1">
{reminder.recurrence && (
<Select
value={deleteScope}
onValueChange={(value) => onDeleteScopeChange(value as ReminderEditScope)}
disabled={busy}
>
<SelectTrigger className="h-8 w-[86px] text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="this-occurrence">This</SelectItem>
<SelectItem value="this-and-future">Future</SelectItem>
<SelectItem value="entire-series">All</SelectItem>
</SelectContent>
</Select>
)}
<Button size="sm" variant="ghost" onClick={onComplete} disabled={busy}>
{reminder.completedAt ? (
<RotateCcw className="size-3.5" />
) : (
<Check className="size-3.5" />
)}
</Button>
<Button size="sm" variant="ghost" onClick={onEdit} disabled={busy}>
<Pencil className="size-3.5" />
</Button>
<Button size="sm" variant="ghost" onClick={onDelete} disabled={busy}>
<Trash2 className="size-3.5 text-destructive" />
</Button>
</div>
</div>
)
}
function formToCreatePayload(form: ReminderFormState): Record<string, unknown> {
return {
title: form.title.trim(),
notes: form.notes.trim() || null,
dueAt: toIsoString(form.dueAt),
timeZone: localTimeZone(),
priority: form.priority,
recurrence: recurrenceValueFromForm(form),
}
}
function formToPatch(initial: ReminderFormState, form: ReminderFormState): Record<string, unknown> {
const patch: Record<string, unknown> = {}
const title = form.title.trim()
const notes = form.notes.trim() || null
const initialNotes = initial.notes.trim() || null
if (title !== initial.title.trim()) patch.title = title
if (notes !== initialNotes) patch.notes = notes
if (form.dueAt !== initial.dueAt) {
patch.dueAt = toIsoString(form.dueAt)
patch.timeZone = localTimeZone()
}
if (form.priority !== initial.priority) patch.priority = form.priority
if (form.scope !== "this-occurrence" && recurrenceChanged(initial, form)) {
patch.recurrence = recurrenceValueFromForm(form)
}
return patch
}
function recurrenceValueFromForm(form: ReminderFormState): ReminderRecurrence | null {
return form.recurs ? recurrenceFromForm(form) : null
}
function recurrenceFromForm(form: ReminderFormState): ReminderRecurrence {
const recurrence: ReminderRecurrence = {
frequency: form.frequency,
interval: Math.max(1, Number(form.interval) || 1),
}
const count = Number(form.count)
if (Number.isInteger(count) && count > 0) recurrence.count = count
if (form.until) recurrence.until = toIsoString(form.until)
return recurrence
}
function formFromReminder(reminder: ReminderFeedData): ReminderFormState {
return {
title: reminder.title,
notes: reminder.notes ?? "",
dueAt: toLocalInput(new Date(reminder.dueAt)),
priority: reminder.priority,
scope: reminder.recurrence ? "this-occurrence" : "entire-series",
recurs: reminder.recurrence !== null,
frequency: reminder.recurrence?.frequency ?? "daily",
interval: String(reminder.recurrence?.interval ?? 1),
count: reminder.recurrence?.count ? String(reminder.recurrence.count) : "",
until: reminder.recurrence?.until ? toLocalInput(new Date(reminder.recurrence.until)) : "",
}
}
function setFormField<TKey extends keyof ReminderFormState>(
setForm: Dispatch<SetStateAction<ReminderFormState>>,
key: TKey,
value: ReminderFormState[TKey],
) {
setForm((prev) => ({ ...prev, [key]: value }))
}
function recurrenceChanged(initial: ReminderFormState, form: ReminderFormState): boolean {
return (
JSON.stringify(recurrenceValueFromForm(initial)) !==
JSON.stringify(recurrenceValueFromForm(form))
)
}
function reminderKey(reminder: ReminderFeedData): string {
return `${reminder.reminderId}:${reminder.occurrenceId}`
}
function isReminderItem(item: FeedItem): item is FeedItem & { data: ReminderFeedData } {
return (
item.sourceId === REMINDER_SOURCE_ID &&
typeof item.data.reminderId === "string" &&
typeof item.data.occurrenceId === "string" &&
typeof item.data.title === "string" &&
typeof item.data.originalDueAt === "string" &&
typeof item.data.dueAt === "string"
)
}
function toLocalInput(date: Date): string {
const offsetMs = date.getTimezoneOffset() * 60 * 1000
return new Date(date.getTime() - offsetMs).toISOString().slice(0, 16)
}
function toIsoString(value: string): string {
return new Date(value).toISOString()
}
function localTimeZone(): string {
return Intl.DateTimeFormat().resolvedOptions().timeZone
}
function formatDate(value: string): string {
return new Date(value).toLocaleString(undefined, {
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
})
}
function formatRecurrence(recurrence: ReminderRecurrence): string {
return recurrence.interval === 1
? recurrence.frequency
: `${recurrence.frequency} / ${recurrence.interval}`
}
function formatScope(scope: ReminderEditScope): string {
switch (scope) {
case "this-occurrence":
return "this occurrence"
case "this-and-future":
return "this and future"
case "entire-series":
return "entire series"
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,164 +1,335 @@
import { getServerUrl } from "./server-url" import { getServerUrl } from "./server-url"
function apiBase() { function apiBase() {
return `${getServerUrl()}/api/admin` return `${getServerUrl()}/api/admin`
} }
function serverBase() { function serverBase() {
return `${getServerUrl()}/api` return `${getServerUrl()}/api`
} }
export interface ConfigFieldDef { export interface ConfigFieldDef {
type: "string" | "number" | "select" type: "string" | "number" | "select" | "multiselect" | "boolean"
label: string label: string
required?: boolean required?: boolean
description?: string description?: string
secret?: boolean secret?: boolean
defaultValue?: string | number defaultValue?: string | number | string[] | boolean
options?: { label: string; value: string }[] options?: { label: string; value: string }[]
} }
export interface SourceDefinition { export interface SourceDefinition {
id: string id: string
name: string name: string
description: string description: string
alwaysEnabled?: boolean alwaysEnabled?: boolean
fields: Record<string, ConfigFieldDef> /** When true, secret fields are stored as per-user credentials via /api/sources/:id/credentials. */
perUserCredentials?: boolean
fields: Record<string, ConfigFieldDef>
} }
export interface SourceConfig { export interface SourceConfig {
sourceId: string sourceId: string
enabled: boolean enabled: boolean
config: Record<string, unknown> config: Record<string, unknown>
} }
const sourceDefinitions: SourceDefinition[] = [ const sourceDefinitions: SourceDefinition[] = [
{ {
id: "aelis.location", id: "freya.location",
name: "Location", name: "Location",
description: "Device location provider. Always enabled as a dependency for other sources.", description: "Device location provider. Always enabled as a dependency for other sources.",
alwaysEnabled: true, alwaysEnabled: true,
fields: {}, fields: {},
}, },
{ {
id: "aelis.weather", id: "freya.weather",
name: "WeatherKit", name: "WeatherKit",
description: "Apple WeatherKit weather data. Requires Apple Developer credentials.", description: "Apple WeatherKit weather data. Requires Apple Developer credentials.",
fields: { fields: {
privateKey: { type: "string", label: "Private Key", required: true, secret: true, description: "Apple WeatherKit private key (PEM format)" }, privateKey: {
keyId: { type: "string", label: "Key ID", required: true, secret: true }, type: "string",
teamId: { type: "string", label: "Team ID", required: true, secret: true }, label: "Private Key",
serviceId: { type: "string", label: "Service ID", required: true, secret: true }, required: true,
units: { type: "select", label: "Units", options: [{ label: "Metric", value: "metric" }, { label: "Imperial", value: "imperial" }], defaultValue: "metric" }, secret: true,
hourlyLimit: { type: "number", label: "Hourly Forecast Limit", defaultValue: 12, description: "Number of hourly forecasts to include" }, description: "Apple WeatherKit private key (PEM format)",
dailyLimit: { type: "number", label: "Daily Forecast Limit", defaultValue: 7, description: "Number of daily forecasts to include" }, },
}, keyId: { type: "string", label: "Key ID", required: true, secret: true },
}, teamId: { type: "string", label: "Team ID", required: true, secret: true },
serviceId: { type: "string", label: "Service ID", required: true, secret: true },
units: {
type: "select",
label: "Units",
options: [
{ label: "Metric", value: "metric" },
{ label: "Imperial", value: "imperial" },
],
defaultValue: "metric",
},
hourlyLimit: {
type: "number",
label: "Hourly Forecast Limit",
defaultValue: 12,
description: "Number of hourly forecasts to include",
},
dailyLimit: {
type: "number",
label: "Daily Forecast Limit",
defaultValue: 7,
description: "Number of daily forecasts to include",
},
},
},
{
id: "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" },
],
},
},
},
{
id: "freya.reminders",
name: "Reminders",
description: "One-off and recurring reminders in the contextual feed.",
fields: {
lookAheadMs: {
type: "number",
label: "Look-ahead Milliseconds",
defaultValue: 24 * 60 * 60 * 1000,
description: "How far into the future reminders should appear in the feed.",
},
lookBackMs: {
type: "number",
label: "Look-back Milliseconds",
defaultValue: 24 * 60 * 60 * 1000,
description: "How far into the past due reminders should remain visible.",
},
includeCompleted: {
type: "boolean",
label: "Include Completed",
defaultValue: false,
description: "Show completed reminder occurrences in the feed.",
},
defaultTimeZone: {
type: "string",
label: "Default Timezone",
defaultValue: "UTC",
description: "IANA timezone used when new reminders omit a timezone.",
},
},
},
{
id: "freya.web-search",
name: "Web Search",
description: "Exa web search action. Requires EXA_API_KEY on the backend.",
fields: {},
},
{
id: "freya.google-maps",
name: "Google Maps",
description: "Google Maps Grounding Lite MCP tools for places, weather, routes, and Place IDs.",
fields: {},
},
] ]
export function fetchSources(): Promise<SourceDefinition[]> { export function fetchSources(): Promise<SourceDefinition[]> {
return Promise.resolve(sourceDefinitions) return Promise.resolve(sourceDefinitions)
} }
export async function fetchSourceConfig( export async function fetchSourceConfig(sourceId: string): Promise<SourceConfig | null> {
sourceId: string, const res = await fetch(`${serverBase()}/sources/${sourceId}`, {
): Promise<SourceConfig | null> { credentials: "include",
const res = await fetch(`${serverBase()}/sources/${sourceId}`, { })
credentials: "include", if (res.status === 404) return null
}) if (!res.ok) throw new Error(`Failed to fetch source config: ${res.status}`)
if (res.status === 404) return null const data = (await res.json()) as { enabled: boolean; config: Record<string, unknown> }
if (!res.ok) throw new Error(`Failed to fetch source config: ${res.status}`) return { sourceId, enabled: data.enabled, config: data.config }
const data = (await res.json()) as { enabled: boolean; config: Record<string, unknown> }
return { sourceId, enabled: data.enabled, config: data.config }
} }
export async function fetchConfigs(): Promise<SourceConfig[]> { export async function fetchConfigs(): Promise<SourceConfig[]> {
const results = await Promise.all( const results = await Promise.all(sourceDefinitions.map((s) => fetchSourceConfig(s.id)))
sourceDefinitions.map((s) => fetchSourceConfig(s.id)), return results.filter((c): c is SourceConfig => c !== null)
)
return results.filter((c): c is SourceConfig => c !== null)
} }
export async function replaceSource( export async function replaceSource(
sourceId: string, sourceId: string,
body: { enabled: boolean; config: unknown }, body: { enabled: boolean; config?: unknown; credentials?: Record<string, unknown> },
): Promise<void> { ): Promise<void> {
const res = await fetch(`${serverBase()}/sources/${sourceId}`, { const res = await fetch(`${serverBase()}/sources/${sourceId}`, {
method: "PUT", method: "PUT",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
credentials: "include", credentials: "include",
body: JSON.stringify(body), body: JSON.stringify(body),
}) })
if (!res.ok) { if (!res.ok) {
const data = (await res.json()) as { error?: string } const data = (await res.json()) as { error?: string }
throw new Error(data.error ?? `Failed to replace source config: ${res.status}`) throw new Error(data.error ?? `Failed to replace source config: ${res.status}`)
} }
} }
export async function updateProviderConfig( export async function updateProviderConfig(
sourceId: string, sourceId: string,
body: Record<string, unknown>, body: Record<string, unknown>,
): Promise<void> { ): Promise<void> {
const res = await fetch(`${apiBase()}/${sourceId}/config`, { const res = await fetch(`${apiBase()}/${sourceId}/config`, {
method: "PUT", method: "PUT",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
credentials: "include", credentials: "include",
body: JSON.stringify(body), body: JSON.stringify(body),
}) })
if (!res.ok) { if (!res.ok) {
const data = (await res.json()) as { error?: string } const data = (await res.json()) as { error?: string }
throw new Error(data.error ?? `Failed to update provider config: ${res.status}`) throw new Error(data.error ?? `Failed to update provider config: ${res.status}`)
} }
}
export 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 async function executeSourceAction(
sourceId: string,
actionId: string,
params: unknown,
): Promise<unknown> {
const res = await fetch(`${serverBase()}/sources/${sourceId}/actions/${actionId}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify(params),
})
if (!res.ok) {
const data = (await res.json()) as { error?: string }
throw new Error(data.error ?? `Failed to execute source action: ${res.status}`)
}
const data = (await res.json()) as { result: unknown }
return data.result
} }
export interface LocationInput { export interface LocationInput {
lat: number lat: number
lng: number lng: number
accuracy: number accuracy: number
} }
export async function pushLocation(location: LocationInput): Promise<void> { export async function pushLocation(location: LocationInput): Promise<void> {
const res = await fetch(`${serverBase()}/location`, { const res = await fetch(`${serverBase()}/location`, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
credentials: "include", credentials: "include",
body: JSON.stringify({ body: JSON.stringify({
...location, ...location,
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
}), }),
}) })
if (!res.ok) { if (!res.ok) {
const data = (await res.json()) as { error?: string } const data = (await res.json()) as { error?: string }
throw new Error(data.error ?? `Failed to push location: ${res.status}`) throw new Error(data.error ?? `Failed to push location: ${res.status}`)
} }
} }
export interface FeedItemSlot { export interface FeedItemSlot {
description: string description: string
content: string | null content: string | null
} }
export interface FeedItem { export interface FeedItem {
id: string id: string
sourceId: string sourceId: string
type: string type: string
timestamp: string timestamp: string
data: Record<string, unknown> data: Record<string, unknown>
signals?: { signals?: {
urgency?: number urgency?: number
timeRelevance?: string timeRelevance?: string
} }
slots?: Record<string, FeedItemSlot> slots?: Record<string, FeedItemSlot>
ui?: unknown ui?: unknown
} }
export interface FeedResponse { export interface FeedResponse {
items: FeedItem[] items: FeedItem[]
errors: { sourceId: string; error: string }[] errors: { sourceId: string; error: string }[]
} }
export async function fetchFeed(): Promise<FeedResponse> { export async function fetchFeed(): Promise<FeedResponse> {
const res = await fetch(`${serverBase()}/feed`, { credentials: "include" }) const res = await fetch(`${serverBase()}/feed`, { credentials: "include" })
if (!res.ok) throw new Error(`Failed to fetch feed: ${res.status}`) if (!res.ok) throw new Error(`Failed to fetch feed: ${res.status}`)
return res.json() as Promise<FeedResponse> return res.json() as Promise<FeedResponse>
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1 +0,0 @@
CREATE INDEX "user_sources_user_id_enabled_idx" ON "user_sources" USING btree ("user_id","enabled");

View File

@@ -1,457 +0,0 @@
{
"id": "d8c59ec7-b686-41a7-a472-da29f3ab6727",
"prevId": "00000000-0000-0000-0000-000000000000",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.account": {
"name": "account",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"account_id": {
"name": "account_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"provider_id": {
"name": "provider_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"access_token": {
"name": "access_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"refresh_token": {
"name": "refresh_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"id_token": {
"name": "id_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"access_token_expires_at": {
"name": "access_token_expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"refresh_token_expires_at": {
"name": "refresh_token_expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"scope": {
"name": "scope",
"type": "text",
"primaryKey": false,
"notNull": false
},
"password": {
"name": "password",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
}
},
"indexes": {
"account_userId_idx": {
"name": "account_userId_idx",
"columns": [
{
"expression": "user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"account_user_id_user_id_fk": {
"name": "account_user_id_user_id_fk",
"tableFrom": "account",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.session": {
"name": "session",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"token": {
"name": "token",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"ip_address": {
"name": "ip_address",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_agent": {
"name": "user_agent",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
}
},
"indexes": {
"session_userId_idx": {
"name": "session_userId_idx",
"columns": [
{
"expression": "user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"session_user_id_user_id_fk": {
"name": "session_user_id_user_id_fk",
"tableFrom": "session",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"session_token_unique": {
"name": "session_token_unique",
"nullsNotDistinct": false,
"columns": [
"token"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.user": {
"name": "user",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email_verified": {
"name": "email_verified",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"image": {
"name": "image",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"user_email_unique": {
"name": "user_email_unique",
"nullsNotDistinct": false,
"columns": [
"email"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.user_sources": {
"name": "user_sources",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"source_id": {
"name": "source_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"enabled": {
"name": "enabled",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": true
},
"config": {
"name": "config",
"type": "jsonb",
"primaryKey": false,
"notNull": false,
"default": "'{}'::jsonb"
},
"credentials": {
"name": "credentials",
"type": "bytea",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"user_sources_user_id_user_id_fk": {
"name": "user_sources_user_id_user_id_fk",
"tableFrom": "user_sources",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"user_sources_user_id_source_id_unique": {
"name": "user_sources_user_id_source_id_unique",
"nullsNotDistinct": false,
"columns": [
"user_id",
"source_id"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.verification": {
"name": "verification",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"identifier": {
"name": "identifier",
"type": "text",
"primaryKey": false,
"notNull": true
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
}
},
"indexes": {
"verification_identifier_idx": {
"name": "verification_identifier_idx",
"columns": [
{
"expression": "identifier",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

View File

@@ -1,35 +0,0 @@
{
"name": "@aelis/backend",
"version": "0.0.0",
"type": "module",
"main": "src/server.ts",
"scripts": {
"dev": "bun run --watch src/server.ts",
"start": "bun run src/server.ts",
"test": "bun test src/",
"db:generate": "bunx drizzle-kit generate",
"db:generate-auth": "bunx --bun auth@latest generate --config auth.ts --output src/db/auth-schema.ts -y",
"db:push": "bunx drizzle-kit push",
"db:migrate": "bunx drizzle-kit migrate",
"db:studio": "bunx drizzle-kit studio",
"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:*",
"@openrouter/sdk": "^0.9.11",
"arktype": "^2.1.29",
"better-auth": "^1",
"drizzle-orm": "^0.45.1",
"hono": "^4",
"lodash.merge": "^4.6.2"
},
"devDependencies": {
"@types/lodash.merge": "^4.6.9",
"drizzle-kit": "^0.31.9"
}
}

View File

@@ -1,62 +0,0 @@
import {
boolean,
customType,
index,
jsonb,
pgTable,
text,
timestamp,
unique,
uuid,
} from "drizzle-orm/pg-core"
// ---------------------------------------------------------------------------
// Better Auth core tables
// Re-exported from CLI-generated schema.
// Regenerate with: bunx --bun auth@latest generate --config auth.ts --output src/db/auth-schema.ts
// ---------------------------------------------------------------------------
export {
user,
session,
account,
verification,
userRelations,
sessionRelations,
accountRelations,
} from "./auth-schema.ts"
import { user } from "./auth-schema.ts"
// ---------------------------------------------------------------------------
// AELIS — per-user source configuration
// ---------------------------------------------------------------------------
const bytea = customType<{ data: Buffer }>({
dataType() {
return "bytea"
},
})
export const userSources = pgTable(
"user_sources",
{
id: uuid("id").primaryKey().defaultRandom(),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
sourceId: text("source_id").notNull(),
enabled: boolean("enabled").notNull().default(true),
config: jsonb("config").default({}),
credentials: bytea("credentials"),
createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at")
.notNull()
.defaultNow()
.$onUpdate(() => new Date()),
},
(t) => [
unique("user_sources_user_id_source_id_unique").on(t.userId, t.sourceId),
index("user_sources_user_id_enabled_idx").on(t.userId, t.enabled),
],
)

View File

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

View File

@@ -1,110 +0,0 @@
import { Hono } from "hono"
import { cors } from "hono/cors"
import { registerAdminHttpHandlers } from "./admin/http.ts"
import { createRequireAdmin } from "./auth/admin-middleware.ts"
import { registerAuthHandlers } from "./auth/http.ts"
import { createAuth } from "./auth/index.ts"
import { createRequireSession } from "./auth/session-middleware.ts"
import { createDatabase } from "./db/index.ts"
import { registerFeedHttpHandlers } from "./engine/http.ts"
import { createFeedEnhancer } from "./enhancement/enhance-feed.ts"
import { createLlmClient } from "./enhancement/llm-client.ts"
import { registerLocationHttpHandlers } from "./location/http.ts"
import { LocationSourceProvider } from "./location/provider.ts"
import { UserSessionManager } from "./session/index.ts"
import { registerSourcesHttpHandlers } from "./sources/http.ts"
import { WeatherSourceProvider } from "./weather/provider.ts"
function main() {
const { db, close: closeDb } = createDatabase(process.env.DATABASE_URL!)
const auth = createAuth(db)
const openrouterApiKey = process.env.OPENROUTER_API_KEY
const feedEnhancer = openrouterApiKey
? createFeedEnhancer({
client: createLlmClient({
apiKey: openrouterApiKey,
model: process.env.OPENROUTER_MODEL || undefined,
}),
})
: null
if (!feedEnhancer) {
console.warn("[enhancement] OPENROUTER_API_KEY not set — feed enhancement disabled")
}
const sessionManager = new UserSessionManager({
db,
providers: [
new LocationSourceProvider(),
new WeatherSourceProvider({
credentials: {
privateKey: process.env.WEATHERKIT_PRIVATE_KEY!,
keyId: process.env.WEATHERKIT_KEY_ID!,
teamId: process.env.WEATHERKIT_TEAM_ID!,
serviceId: process.env.WEATHERKIT_SERVICE_ID!,
},
}),
],
feedEnhancer,
})
const app = new Hono()
const isDev = process.env.NODE_ENV !== "production"
const allowedOrigins = process.env.CORS_ORIGINS?.split(",").map((o) => o.trim()) ?? []
function resolveOrigin(origin: string): string | undefined {
if (isDev) return origin
return allowedOrigins.includes(origin) ? origin : undefined
}
app.use(
"/api/auth/*",
cors({
origin: resolveOrigin,
allowHeaders: ["Content-Type", "Authorization"],
allowMethods: ["POST", "GET", "OPTIONS"],
exposeHeaders: ["Content-Length"],
maxAge: 600,
credentials: true,
}),
)
app.use(
"*",
cors({
origin: resolveOrigin,
credentials: true,
}),
)
app.get("/health", (c) => c.json({ status: "ok" }))
const authSessionMiddleware = createRequireSession(auth)
const adminMiddleware = createRequireAdmin(auth)
registerAuthHandlers(app, auth)
registerFeedHttpHandlers(app, {
sessionManager,
authSessionMiddleware,
})
registerLocationHttpHandlers(app, { sessionManager, authSessionMiddleware })
registerSourcesHttpHandlers(app, { sessionManager, authSessionMiddleware })
registerAdminHttpHandlers(app, { sessionManager, adminMiddleware, db })
process.on("SIGTERM", async () => {
await closeDb()
process.exit(0)
})
return app
}
const app = main()
export default {
port: 3000,
fetch: app.fetch,
}

View File

@@ -1,12 +0,0 @@
import type { FeedSource } from "@aelis/core"
import type { type } from "arktype"
export type ConfigSchema = ReturnType<typeof type>
export interface FeedSourceProvider {
/** The source ID this provider is responsible for (e.g., "aelis.location"). */
readonly sourceId: string
/** Arktype schema for validating user-provided config. Omit if the source has no config. */
readonly configSchema?: ConfigSchema
feedSourceForUser(userId: string, config: unknown): Promise<FeedSource>
}

View File

@@ -1,710 +0,0 @@
import type { ActionDefinition, ContextEntry, FeedItem, FeedSource } from "@aelis/core"
import { describe, expect, mock, spyOn, test } from "bun:test"
import { Hono } from "hono"
import type { Database } from "../db/index.ts"
import type { ConfigSchema, FeedSourceProvider } from "../session/feed-source-provider.ts"
import { mockAuthSessionMiddleware } from "../auth/session-middleware.ts"
import { UserSessionManager } from "../session/user-session-manager.ts"
import { tflConfig } from "../tfl/provider.ts"
import { weatherConfig } from "../weather/provider.ts"
import { SourceNotFoundError } from "./errors.ts"
import { registerSourcesHttpHandlers } from "./http.ts"
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
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, configSchema?: ConfigSchema): FeedSourceProvider {
return {
sourceId,
configSchema,
async feedSourceForUser() {
return createStubSource(sourceId)
},
}
}
const MOCK_USER_ID = "k7Gx2mPqRvNwYs9TdLfA4bHcJeUo1iZn"
type SourceRow = {
userId: string
sourceId: string
enabled: boolean
config: Record<string, unknown>
}
function createInMemoryStore() {
const rows = new Map<string, SourceRow>()
function key(userId: string, sourceId: string) {
return `${userId}:${sourceId}`
}
return {
rows,
seed(userId: string, sourceId: string, data: Partial<SourceRow> = {}) {
rows.set(key(userId, sourceId), {
userId,
sourceId,
enabled: data.enabled ?? true,
config: data.config ?? {},
})
},
forUser(userId: string) {
return {
async enabled() {
return [...rows.values()].filter((r) => r.userId === userId && r.enabled)
},
async find(sourceId: string) {
return rows.get(key(userId, sourceId))
},
async updateConfig(sourceId: string, update: { enabled?: boolean; config?: unknown }) {
const existing = rows.get(key(userId, sourceId))
if (!existing) {
throw new SourceNotFoundError(sourceId, userId)
}
if (update.enabled !== undefined) {
existing.enabled = update.enabled
}
if (update.config !== undefined) {
existing.config = update.config as Record<string, unknown>
}
},
async upsertConfig(sourceId: string, data: { enabled: boolean; config: unknown }) {
const existing = rows.get(key(userId, sourceId))
if (existing) {
existing.enabled = data.enabled
existing.config = data.config as Record<string, unknown>
} else {
rows.set(key(userId, sourceId), {
userId,
sourceId,
enabled: data.enabled,
config: (data.config ?? {}) as Record<string, unknown>,
})
}
},
}
},
}
}
let activeStore: ReturnType<typeof createInMemoryStore>
mock.module("../sources/user-sources.ts", () => ({
sources(_db: unknown, userId: string) {
return activeStore.forUser(userId)
},
}))
const fakeDb = {} as Database
function createApp(providers: FeedSourceProvider[], userId?: string) {
const sessionManager = new UserSessionManager({ providers, db: fakeDb })
const app = new Hono()
registerSourcesHttpHandlers(app, {
sessionManager,
authSessionMiddleware: mockAuthSessionMiddleware(userId),
})
return { app, sessionManager }
}
function patch(app: Hono, sourceId: string, body: unknown) {
return app.request(`/api/sources/${sourceId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
})
}
function get(app: Hono, sourceId: string) {
return app.request(`/api/sources/${sourceId}`, { method: "GET" })
}
function put(app: Hono, sourceId: string, body: unknown) {
return app.request(`/api/sources/${sourceId}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
})
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe("GET /api/sources/:sourceId", () => {
test("returns 401 without auth", async () => {
activeStore = createInMemoryStore()
const { app } = createApp([createStubProvider("aelis.weather", weatherConfig)])
const res = await get(app, "aelis.weather")
expect(res.status).toBe(401)
})
test("returns 404 for unknown source", async () => {
activeStore = createInMemoryStore()
const { app } = createApp([createStubProvider("aelis.weather", weatherConfig)], MOCK_USER_ID)
const res = await get(app, "unknown.source")
expect(res.status).toBe(404)
const body = (await res.json()) as { error: string }
expect(body.error).toContain("not found")
})
test("returns enabled and config for existing source", async () => {
activeStore = createInMemoryStore()
activeStore.seed(MOCK_USER_ID, "aelis.weather", {
enabled: true,
config: { units: "metric" },
})
const { app } = createApp([createStubProvider("aelis.weather", weatherConfig)], MOCK_USER_ID)
const res = await get(app, "aelis.weather")
expect(res.status).toBe(200)
const body = (await res.json()) as { enabled: boolean; config: unknown }
expect(body.enabled).toBe(true)
expect(body.config).toEqual({ units: "metric" })
})
test("returns defaults when user has no row for source", async () => {
activeStore = createInMemoryStore()
const { app } = createApp([createStubProvider("aelis.weather", weatherConfig)], MOCK_USER_ID)
const res = await get(app, "aelis.weather")
expect(res.status).toBe(200)
const body = (await res.json()) as { enabled: boolean; config: unknown }
expect(body.enabled).toBe(false)
expect(body.config).toEqual({})
})
test("returns disabled source", async () => {
activeStore = createInMemoryStore()
activeStore.seed(MOCK_USER_ID, "aelis.weather", {
enabled: false,
config: { units: "imperial" },
})
const { app } = createApp([createStubProvider("aelis.weather", weatherConfig)], MOCK_USER_ID)
const res = await get(app, "aelis.weather")
expect(res.status).toBe(200)
const body = (await res.json()) as { enabled: boolean; config: unknown }
expect(body.enabled).toBe(false)
expect(body.config).toEqual({ units: "imperial" })
})
})
describe("PATCH /api/sources/:sourceId", () => {
test("returns 401 without auth", async () => {
activeStore = createInMemoryStore()
const { app } = createApp([createStubProvider("aelis.weather", weatherConfig)])
const res = await patch(app, "aelis.weather", { enabled: true })
expect(res.status).toBe(401)
})
test("returns 404 for unknown source", async () => {
activeStore = createInMemoryStore()
const { app } = createApp([createStubProvider("aelis.weather", weatherConfig)], MOCK_USER_ID)
const res = await patch(app, "unknown.source", { enabled: true })
expect(res.status).toBe(404)
const body = (await res.json()) as { error: string }
expect(body.error).toContain("not found")
})
test("returns 404 when user has no existing row for source", async () => {
activeStore = createInMemoryStore()
const { app } = createApp([createStubProvider("aelis.weather", weatherConfig)], MOCK_USER_ID)
const res = await patch(app, "aelis.weather", { enabled: true })
expect(res.status).toBe(404)
const body = (await res.json()) as { error: string }
expect(body.error).toContain("not found")
})
test("returns 204 when body is empty object (no-op) on existing source", async () => {
activeStore = createInMemoryStore()
activeStore.seed(MOCK_USER_ID, "aelis.weather")
const { app } = createApp([createStubProvider("aelis.weather", weatherConfig)], MOCK_USER_ID)
const res = await patch(app, "aelis.weather", {})
expect(res.status).toBe(204)
})
test("returns 404 when body is empty object on nonexistent user source", async () => {
activeStore = createInMemoryStore()
const { app } = createApp([createStubProvider("aelis.weather", weatherConfig)], MOCK_USER_ID)
const res = await patch(app, "aelis.weather", {})
expect(res.status).toBe(404)
})
test("returns 400 for invalid JSON body", async () => {
activeStore = createInMemoryStore()
activeStore.seed(MOCK_USER_ID, "aelis.weather")
const { app } = createApp([createStubProvider("aelis.weather", weatherConfig)], MOCK_USER_ID)
const res = await app.request("/api/sources/aelis.weather", {
method: "PATCH",
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 request body contains unknown fields", async () => {
activeStore = createInMemoryStore()
activeStore.seed(MOCK_USER_ID, "aelis.weather")
const { app } = createApp([createStubProvider("aelis.weather", weatherConfig)], MOCK_USER_ID)
const res = await patch(app, "aelis.weather", {
enabled: true,
unknownField: "hello",
})
expect(res.status).toBe(400)
})
test("returns 400 when weather config contains unknown fields", async () => {
activeStore = createInMemoryStore()
activeStore.seed(MOCK_USER_ID, "aelis.weather")
const { app } = createApp([createStubProvider("aelis.weather", weatherConfig)], MOCK_USER_ID)
const res = await patch(app, "aelis.weather", {
config: { units: "metric", unknownField: "hello" },
})
expect(res.status).toBe(400)
})
test("returns 400 when weather config fails validation", async () => {
activeStore = createInMemoryStore()
activeStore.seed(MOCK_USER_ID, "aelis.weather")
const { app } = createApp([createStubProvider("aelis.weather", weatherConfig)], MOCK_USER_ID)
const res = await patch(app, "aelis.weather", {
config: { units: "invalid" },
})
expect(res.status).toBe(400)
})
test("returns 204 and updates enabled", async () => {
activeStore = createInMemoryStore()
activeStore.seed(MOCK_USER_ID, "aelis.weather", {
enabled: true,
config: { units: "metric" },
})
const { app } = createApp([createStubProvider("aelis.weather", weatherConfig)], MOCK_USER_ID)
const res = await patch(app, "aelis.weather", { enabled: false })
expect(res.status).toBe(204)
const row = activeStore.rows.get(`${MOCK_USER_ID}:aelis.weather`)
expect(row!.enabled).toBe(false)
expect(row!.config).toEqual({ units: "metric" })
})
test("returns 204 and updates config", async () => {
activeStore = createInMemoryStore()
activeStore.seed(MOCK_USER_ID, "aelis.weather", {
config: { units: "metric" },
})
const { app } = createApp([createStubProvider("aelis.weather", weatherConfig)], MOCK_USER_ID)
const res = await patch(app, "aelis.weather", {
config: { units: "imperial" },
})
expect(res.status).toBe(204)
const row = activeStore.rows.get(`${MOCK_USER_ID}:aelis.weather`)
expect(row!.config).toEqual({ units: "imperial" })
})
test("preserves config when only updating enabled", async () => {
activeStore = createInMemoryStore()
activeStore.seed(MOCK_USER_ID, "aelis.tfl", {
enabled: true,
config: { lines: ["bakerloo"] },
})
const { app } = createApp([createStubProvider("aelis.tfl", tflConfig)], MOCK_USER_ID)
const res = await patch(app, "aelis.tfl", { enabled: false })
expect(res.status).toBe(204)
const row = activeStore.rows.get(`${MOCK_USER_ID}:aelis.tfl`)
expect(row!.enabled).toBe(false)
expect(row!.config).toEqual({ lines: ["bakerloo"] })
})
test("deep-merges config on update", async () => {
activeStore = createInMemoryStore()
activeStore.seed(MOCK_USER_ID, "aelis.weather", {
config: { units: "metric", hourlyLimit: 12 },
})
const { app } = createApp([createStubProvider("aelis.weather", weatherConfig)], MOCK_USER_ID)
const res = await patch(app, "aelis.weather", {
config: { dailyLimit: 5 },
})
expect(res.status).toBe(204)
const row = activeStore.rows.get(`${MOCK_USER_ID}:aelis.weather`)
expect(row!.config).toEqual({
units: "metric",
hourlyLimit: 12,
dailyLimit: 5,
})
})
test("refreshes source in active session after config update", async () => {
activeStore = createInMemoryStore()
activeStore.seed(MOCK_USER_ID, "aelis.weather", {
config: { units: "metric" },
})
const { app, sessionManager } = createApp(
[createStubProvider("aelis.weather", weatherConfig)],
MOCK_USER_ID,
)
const session = await sessionManager.getOrCreate(MOCK_USER_ID)
const replaceSpy = spyOn(session, "replaceSource")
const res = await patch(app, "aelis.weather", {
config: { units: "imperial" },
})
expect(res.status).toBe(204)
expect(replaceSpy).toHaveBeenCalled()
replaceSpy.mockRestore()
})
test("removes source from session when disabled", async () => {
activeStore = createInMemoryStore()
activeStore.seed(MOCK_USER_ID, "aelis.weather", {
enabled: true,
config: { units: "metric" },
})
const { app, sessionManager } = createApp(
[createStubProvider("aelis.weather", weatherConfig)],
MOCK_USER_ID,
)
const session = await sessionManager.getOrCreate(MOCK_USER_ID)
const removeSpy = spyOn(session, "removeSource")
const res = await patch(app, "aelis.weather", { enabled: false })
expect(res.status).toBe(204)
expect(removeSpy).toHaveBeenCalledWith("aelis.weather")
removeSpy.mockRestore()
})
test("returns 400 when config is provided for source without schema", async () => {
activeStore = createInMemoryStore()
activeStore.seed(MOCK_USER_ID, "aelis.location")
const { app } = createApp([createStubProvider("aelis.location")], MOCK_USER_ID)
const res = await patch(app, "aelis.location", {
config: { something: "value" },
})
expect(res.status).toBe(400)
})
test("returns 400 when empty config is provided for source without schema", async () => {
activeStore = createInMemoryStore()
activeStore.seed(MOCK_USER_ID, "aelis.location")
const { app } = createApp([createStubProvider("aelis.location")], MOCK_USER_ID)
const res = await patch(app, "aelis.location", {
config: {},
})
expect(res.status).toBe(400)
})
test("updates enabled on location source", async () => {
activeStore = createInMemoryStore()
activeStore.seed(MOCK_USER_ID, "aelis.location", { enabled: true })
const { app } = createApp([createStubProvider("aelis.location")], MOCK_USER_ID)
const res = await patch(app, "aelis.location", { enabled: false })
expect(res.status).toBe(204)
const row = activeStore.rows.get(`${MOCK_USER_ID}:aelis.location`)
expect(row!.enabled).toBe(false)
})
})
// ---------------------------------------------------------------------------
// PUT /api/sources/:sourceId
// ---------------------------------------------------------------------------
describe("PUT /api/sources/:sourceId", () => {
test("returns 401 without auth", async () => {
activeStore = createInMemoryStore()
const { app } = createApp([createStubProvider("aelis.weather", weatherConfig)])
const res = await put(app, "aelis.weather", { enabled: true, config: {} })
expect(res.status).toBe(401)
})
test("returns 404 for unknown source", async () => {
activeStore = createInMemoryStore()
const { app } = createApp([createStubProvider("aelis.weather", weatherConfig)], MOCK_USER_ID)
const res = await put(app, "unknown.source", { enabled: true, config: {} })
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", async () => {
activeStore = createInMemoryStore()
const { app } = createApp([createStubProvider("aelis.weather", weatherConfig)], MOCK_USER_ID)
const res = await app.request("/api/sources/aelis.weather", {
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 enabled is missing", async () => {
activeStore = createInMemoryStore()
const { app } = createApp([createStubProvider("aelis.weather", weatherConfig)], MOCK_USER_ID)
const res = await put(app, "aelis.weather", { config: {} })
expect(res.status).toBe(400)
})
test("returns 400 when config is missing", async () => {
activeStore = createInMemoryStore()
const { app } = createApp([createStubProvider("aelis.weather", weatherConfig)], MOCK_USER_ID)
const res = await put(app, "aelis.weather", { enabled: true })
expect(res.status).toBe(400)
})
test("returns 400 when request body contains unknown fields", async () => {
activeStore = createInMemoryStore()
const { app } = createApp([createStubProvider("aelis.weather", weatherConfig)], MOCK_USER_ID)
const res = await put(app, "aelis.weather", {
enabled: true,
config: { units: "metric" },
unknownField: "hello",
})
expect(res.status).toBe(400)
})
test("returns 400 when weather config contains unknown fields", async () => {
activeStore = createInMemoryStore()
const { app } = createApp([createStubProvider("aelis.weather", weatherConfig)], MOCK_USER_ID)
const res = await put(app, "aelis.weather", {
enabled: true,
config: { units: "metric", unknownField: "hello" },
})
expect(res.status).toBe(400)
})
test("returns 400 when config fails schema validation", async () => {
activeStore = createInMemoryStore()
const { app } = createApp([createStubProvider("aelis.weather", weatherConfig)], MOCK_USER_ID)
const res = await put(app, "aelis.weather", {
enabled: true,
config: { units: "invalid" },
})
expect(res.status).toBe(400)
})
test("returns 204 and inserts when row does not exist", async () => {
activeStore = createInMemoryStore()
const { app } = createApp([createStubProvider("aelis.weather", weatherConfig)], MOCK_USER_ID)
const res = await put(app, "aelis.weather", {
enabled: true,
config: { units: "metric" },
})
expect(res.status).toBe(204)
const row = activeStore.rows.get(`${MOCK_USER_ID}:aelis.weather`)
expect(row).toBeDefined()
expect(row!.enabled).toBe(true)
expect(row!.config).toEqual({ units: "metric" })
})
test("returns 204 and fully replaces existing row", async () => {
activeStore = createInMemoryStore()
activeStore.seed(MOCK_USER_ID, "aelis.weather", {
enabled: true,
config: { units: "metric", hourlyLimit: 12 },
})
const { app } = createApp([createStubProvider("aelis.weather", weatherConfig)], MOCK_USER_ID)
const res = await put(app, "aelis.weather", {
enabled: false,
config: { units: "imperial" },
})
expect(res.status).toBe(204)
const row = activeStore.rows.get(`${MOCK_USER_ID}:aelis.weather`)
expect(row!.enabled).toBe(false)
// hourlyLimit should be gone — full replace, not merge
expect(row!.config).toEqual({ units: "imperial" })
})
test("refreshes source in active session after upsert", async () => {
activeStore = createInMemoryStore()
activeStore.seed(MOCK_USER_ID, "aelis.weather", {
config: { units: "metric" },
})
const { app, sessionManager } = createApp(
[createStubProvider("aelis.weather", weatherConfig)],
MOCK_USER_ID,
)
const session = await sessionManager.getOrCreate(MOCK_USER_ID)
const replaceSpy = spyOn(session, "replaceSource")
const res = await put(app, "aelis.weather", {
enabled: true,
config: { units: "imperial" },
})
expect(res.status).toBe(204)
expect(replaceSpy).toHaveBeenCalled()
replaceSpy.mockRestore()
})
test("removes source from session when disabled via upsert", async () => {
activeStore = createInMemoryStore()
activeStore.seed(MOCK_USER_ID, "aelis.weather", {
enabled: true,
config: { units: "metric" },
})
const { app, sessionManager } = createApp(
[createStubProvider("aelis.weather", weatherConfig)],
MOCK_USER_ID,
)
const session = await sessionManager.getOrCreate(MOCK_USER_ID)
const removeSpy = spyOn(session, "removeSource")
const res = await put(app, "aelis.weather", {
enabled: false,
config: { units: "metric" },
})
expect(res.status).toBe(204)
expect(removeSpy).toHaveBeenCalledWith("aelis.weather")
removeSpy.mockRestore()
})
test("adds source to active session when inserting a new source", async () => {
activeStore = createInMemoryStore()
// Seed a different source so the session can be created
activeStore.seed(MOCK_USER_ID, "aelis.location", { enabled: true })
const { app, sessionManager } = createApp(
[createStubProvider("aelis.location"), createStubProvider("aelis.weather", weatherConfig)],
MOCK_USER_ID,
)
// Create session — only has aelis.location
const session = await sessionManager.getOrCreate(MOCK_USER_ID)
expect(session.hasSource("aelis.weather")).toBe(false)
// PUT a new source that didn't exist before
const res = await put(app, "aelis.weather", {
enabled: true,
config: { units: "metric" },
})
expect(res.status).toBe(204)
expect(session.hasSource("aelis.weather")).toBe(true)
})
test("returns 400 when config is provided for source without schema", async () => {
activeStore = createInMemoryStore()
const { app } = createApp([createStubProvider("aelis.location")], MOCK_USER_ID)
const res = await put(app, "aelis.location", {
enabled: true,
config: { something: "value" },
})
expect(res.status).toBe(400)
})
test("returns 400 when empty config is provided for source without schema", async () => {
activeStore = createInMemoryStore()
const { app } = createApp([createStubProvider("aelis.location")], MOCK_USER_ID)
const res = await put(app, "aelis.location", {
enabled: true,
config: {},
})
expect(res.status).toBe(400)
})
test("returns 204 without config field for source without schema", async () => {
activeStore = createInMemoryStore()
const { app } = createApp([createStubProvider("aelis.location")], MOCK_USER_ID)
const res = await put(app, "aelis.location", {
enabled: true,
})
expect(res.status).toBe(204)
})
})

View File

@@ -1,152 +0,0 @@
{
"expo": {
"name": "Aelis",
"slug": "aelis-client",
"version": "1.0.0",
"orientation": "portrait",
"icon": "./assets/images/icon.png",
"scheme": "aelis",
"userInterfaceStyle": "automatic",
"newArchEnabled": true,
"ios": {
"infoPlist": {
"NSAppTransportSecurity": {
"NSAllowsArbitraryLoads": true
},
"ITSAppUsesNonExemptEncryption": false
},
"bundleIdentifier": "sh.nym.aelis"
},
"android": {
"adaptiveIcon": {
"backgroundColor": "#E6F4FE",
"foregroundImage": "./assets/images/android-icon-foreground.png",
"backgroundImage": "./assets/images/android-icon-background.png",
"monochromeImage": "./assets/images/android-icon-monochrome.png"
},
"edgeToEdgeEnabled": true,
"predictiveBackGestureEnabled": false,
"package": "sh.nym.aelis"
},
"web": {
"output": "static",
"favicon": "./assets/images/favicon.png"
},
"plugins": [
"expo-router",
[
"expo-splash-screen",
{
"image": "./assets/images/splash-icon.png",
"imageWidth": 200,
"resizeMode": "contain",
"backgroundColor": "#ffffff",
"dark": {
"backgroundColor": "#000000"
}
}
],
[
"expo-font",
{
"android": {
"fonts": [
{
"fontFamily": "Inter",
"fontDefinitions": [
{ "path": "./assets/fonts/Inter_100Thin.ttf", "weight": 100 },
{ "path": "./assets/fonts/Inter_100Thin_Italic.ttf", "weight": 100, "style": "italic" },
{ "path": "./assets/fonts/Inter_200ExtraLight.ttf", "weight": 200 },
{ "path": "./assets/fonts/Inter_200ExtraLight_Italic.ttf", "weight": 200, "style": "italic" },
{ "path": "./assets/fonts/Inter_300Light.ttf", "weight": 300 },
{ "path": "./assets/fonts/Inter_300Light_Italic.ttf", "weight": 300, "style": "italic" },
{ "path": "./assets/fonts/Inter_400Regular.ttf", "weight": 400 },
{ "path": "./assets/fonts/Inter_400Regular_Italic.ttf", "weight": 400, "style": "italic" },
{ "path": "./assets/fonts/Inter_500Medium.ttf", "weight": 500 },
{ "path": "./assets/fonts/Inter_500Medium_Italic.ttf", "weight": 500, "style": "italic" },
{ "path": "./assets/fonts/Inter_600SemiBold.ttf", "weight": 600 },
{ "path": "./assets/fonts/Inter_600SemiBold_Italic.ttf", "weight": 600, "style": "italic" },
{ "path": "./assets/fonts/Inter_700Bold.ttf", "weight": 700 },
{ "path": "./assets/fonts/Inter_700Bold_Italic.ttf", "weight": 700, "style": "italic" },
{ "path": "./assets/fonts/Inter_800ExtraBold.ttf", "weight": 800 },
{ "path": "./assets/fonts/Inter_800ExtraBold_Italic.ttf", "weight": 800, "style": "italic" },
{ "path": "./assets/fonts/Inter_900Black.ttf", "weight": 900 },
{ "path": "./assets/fonts/Inter_900Black_Italic.ttf", "weight": 900, "style": "italic" }
]
},
{
"fontFamily": "Source Serif 4",
"fontDefinitions": [
{ "path": "./assets/fonts/SourceSerif4_200ExtraLight.ttf", "weight": 200 },
{ "path": "./assets/fonts/SourceSerif4_200ExtraLight_Italic.ttf", "weight": 200, "style": "italic" },
{ "path": "./assets/fonts/SourceSerif4_300Light.ttf", "weight": 300 },
{ "path": "./assets/fonts/SourceSerif4_300Light_Italic.ttf", "weight": 300, "style": "italic" },
{ "path": "./assets/fonts/SourceSerif4_400Regular.ttf", "weight": 400 },
{ "path": "./assets/fonts/SourceSerif4_400Regular_Italic.ttf", "weight": 400, "style": "italic" },
{ "path": "./assets/fonts/SourceSerif4_500Medium.ttf", "weight": 500 },
{ "path": "./assets/fonts/SourceSerif4_500Medium_Italic.ttf", "weight": 500, "style": "italic" },
{ "path": "./assets/fonts/SourceSerif4_600SemiBold.ttf", "weight": 600 },
{ "path": "./assets/fonts/SourceSerif4_600SemiBold_Italic.ttf", "weight": 600, "style": "italic" },
{ "path": "./assets/fonts/SourceSerif4_700Bold.ttf", "weight": 700 },
{ "path": "./assets/fonts/SourceSerif4_700Bold_Italic.ttf", "weight": 700, "style": "italic" },
{ "path": "./assets/fonts/SourceSerif4_800ExtraBold.ttf", "weight": 800 },
{ "path": "./assets/fonts/SourceSerif4_800ExtraBold_Italic.ttf", "weight": 800, "style": "italic" },
{ "path": "./assets/fonts/SourceSerif4_900Black.ttf", "weight": 900 },
{ "path": "./assets/fonts/SourceSerif4_900Black_Italic.ttf", "weight": 900, "style": "italic" }
]
}
]
},
"ios": {
"fonts": [
"./assets/fonts/Inter_100Thin.ttf",
"./assets/fonts/Inter_100Thin_Italic.ttf",
"./assets/fonts/Inter_200ExtraLight.ttf",
"./assets/fonts/Inter_200ExtraLight_Italic.ttf",
"./assets/fonts/Inter_300Light.ttf",
"./assets/fonts/Inter_300Light_Italic.ttf",
"./assets/fonts/Inter_400Regular.ttf",
"./assets/fonts/Inter_400Regular_Italic.ttf",
"./assets/fonts/Inter_500Medium.ttf",
"./assets/fonts/Inter_500Medium_Italic.ttf",
"./assets/fonts/Inter_600SemiBold.ttf",
"./assets/fonts/Inter_600SemiBold_Italic.ttf",
"./assets/fonts/Inter_700Bold.ttf",
"./assets/fonts/Inter_700Bold_Italic.ttf",
"./assets/fonts/Inter_800ExtraBold.ttf",
"./assets/fonts/Inter_800ExtraBold_Italic.ttf",
"./assets/fonts/Inter_900Black.ttf",
"./assets/fonts/Inter_900Black_Italic.ttf",
"./assets/fonts/SourceSerif4_200ExtraLight.ttf",
"./assets/fonts/SourceSerif4_200ExtraLight_Italic.ttf",
"./assets/fonts/SourceSerif4_300Light.ttf",
"./assets/fonts/SourceSerif4_300Light_Italic.ttf",
"./assets/fonts/SourceSerif4_400Regular.ttf",
"./assets/fonts/SourceSerif4_400Regular_Italic.ttf",
"./assets/fonts/SourceSerif4_500Medium.ttf",
"./assets/fonts/SourceSerif4_500Medium_Italic.ttf",
"./assets/fonts/SourceSerif4_600SemiBold.ttf",
"./assets/fonts/SourceSerif4_600SemiBold_Italic.ttf",
"./assets/fonts/SourceSerif4_700Bold.ttf",
"./assets/fonts/SourceSerif4_700Bold_Italic.ttf",
"./assets/fonts/SourceSerif4_800ExtraBold.ttf",
"./assets/fonts/SourceSerif4_800ExtraBold_Italic.ttf",
"./assets/fonts/SourceSerif4_900Black.ttf",
"./assets/fonts/SourceSerif4_900Black_Italic.ttf"
]
}
}
]
],
"experiments": {
"typedRoutes": true,
"reactCompiler": true
},
"extra": {
"router": {},
"eas": {
"projectId": "61092d23-36aa-418e-929d-ea40dc912e8f"
}
}
}
}

View File

@@ -1,60 +0,0 @@
{
"name": "aelis-client",
"version": "1.0.0",
"private": true,
"main": "expo-router/entry",
"scripts": {
"start": "./scripts/run-dev-server.sh",
"reset-project": "node ./scripts/reset-project.js",
"android": "expo start --android",
"ios": "expo start --ios",
"web": "expo start --web",
"lint": "expo lint",
"build:ios": "eas build --profile development --platform ios --non-interactive",
"build:ios-simulator": "eas build --profile development-simulator --platform ios --non-interactive",
"debugger": "bun run scripts/open-debugger.ts"
},
"dependencies": {
"@expo-google-fonts/inter": "^0.4.2",
"@expo-google-fonts/source-serif-4": "^0.4.1",
"@expo/vector-icons": "^15.0.3",
"@json-render/react-native": "^0.13.0",
"@react-navigation/bottom-tabs": "^7.4.0",
"@react-navigation/elements": "^2.6.3",
"@react-navigation/native": "^7.1.8",
"@tanstack/react-query": "^5.90.21",
"expo": "~54.0.33",
"expo-constants": "~18.0.13",
"expo-dev-client": "~6.0.20",
"expo-font": "~14.0.11",
"expo-haptics": "~15.0.8",
"expo-image": "~3.0.11",
"expo-linking": "~8.0.11",
"expo-location": "~19.0.8",
"expo-router": "~6.0.23",
"expo-splash-screen": "~31.0.13",
"expo-status-bar": "~3.0.9",
"expo-symbols": "~1.0.8",
"expo-system-ui": "~6.0.9",
"expo-web-browser": "~15.0.10",
"react": "19.1.0",
"react-dom": "19.1.0",
"react-native": "0.81.5",
"react-native-gesture-handler": "~2.28.0",
"react-native-reanimated": "~4.1.1",
"react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.16.0",
"react-native-svg": "15.12.1",
"react-native-web": "~0.21.0",
"react-native-worklets": "0.5.1",
"twrnc": "^4.16.0",
"zod": "^4.3.6"
},
"devDependencies": {
"@types/react": "~19.1.0",
"eas-cli": "^18.0.1",
"eslint": "^9.25.0",
"eslint-config-expo": "~10.0.0",
"typescript": "~5.9.2"
}
}

View File

@@ -1,127 +0,0 @@
// Reverse proxy that sits in front of Metro so that all requests
// (including those arriving via Tailscale or Ona port-forwarding) reach
// Metro as loopback connections. This satisfies the isLocalSocket check
// in Expo's debug middleware, making /debugger-frontend, /json, and
// /open-debugger accessible from a remote browser.
import type { ServerWebSocket } from "bun"
const PROXY_PORT = parseInt(process.env.PROXY_PORT || "8080", 10)
const METRO_PORT = parseInt(process.env.METRO_PORT || "8081", 10)
const METRO_BASE = `http://127.0.0.1:${METRO_PORT}`
function forwardHeaders(headers: Headers): Headers {
const result = new Headers(headers)
result.delete("origin")
result.delete("referer")
result.set("host", `127.0.0.1:${METRO_PORT}`)
return result
}
interface WsData {
upstream: WebSocket
isDevice: boolean
}
Bun.serve<WsData>({
port: PROXY_PORT,
async fetch(req, server) {
const url = new URL(req.url)
// WebSocket upgrade — bridge to Metro's ws endpoint
if (req.headers.get("upgrade")?.toLowerCase() === "websocket") {
const wsUrl = `ws://127.0.0.1:${METRO_PORT}${url.pathname}${url.search}`
const upstream = new WebSocket(wsUrl)
// Wait for upstream to connect before upgrading the client
try {
await new Promise<void>((resolve, reject) => {
upstream.addEventListener("open", () => resolve())
upstream.addEventListener("error", () => reject(new Error("upstream ws failed")))
})
} catch {
return new Response("Upstream WebSocket unavailable", { status: 502 })
}
const isDevice = url.pathname.startsWith("/inspector/device")
const ok = server.upgrade(req, { data: { upstream, isDevice } })
if (!ok) {
upstream.close()
return new Response("WebSocket upgrade failed", { status: 500 })
}
return undefined
}
// HTTP proxy
const upstream = `${METRO_BASE}${url.pathname}${url.search}`
const res = await fetch(upstream, {
method: req.method,
headers: forwardHeaders(req.headers),
body: req.body,
redirect: "manual",
})
return new Response(res.body, {
status: res.status,
statusText: res.statusText,
headers: res.headers,
})
},
websocket: {
message(ws: ServerWebSocket<WsData>, msg) {
ws.data.upstream.send(msg)
},
open(ws: ServerWebSocket<WsData>) {
const { upstream } = ws.data
upstream.addEventListener("message", (ev) => {
if (typeof ev.data === "string") {
ws.send(ev.data)
} else if (ev.data instanceof ArrayBuffer) {
ws.sendBinary(new Uint8Array(ev.data))
}
})
upstream.addEventListener("close", () => ws.close())
upstream.addEventListener("error", () => ws.close())
// Print debugger URL shortly after a device connects,
// giving Metro time to register the target.
if (ws.data.isDevice) {
setTimeout(() => printDebuggerUrl(), 1000)
}
},
close(ws: ServerWebSocket<WsData>) {
ws.data.upstream.close()
},
},
})
const tsIp = await Bun.$`tailscale ip -4`.text().then((s) => s.trim())
async function printDebuggerUrl() {
const base = `http://${tsIp}:${PROXY_PORT}`
const res = await fetch(`${METRO_BASE}/json`)
if (!res.ok) return
interface DebugTarget {
webSocketDebuggerUrl: string
reactNative?: {
capabilities?: { prefersFuseboxFrontend?: boolean }
}
}
const targets: DebugTarget[] = await res.json()
const target = targets.find((t) => t.reactNative?.capabilities?.prefersFuseboxFrontend)
if (!target) return
const wsPath = target.webSocketDebuggerUrl
.replace(/^ws:\/\//, "")
.replace(`127.0.0.1:${METRO_PORT}`, `${tsIp}:${PROXY_PORT}`)
console.log(
`\n React Native DevTools:\n ${base}/debugger-frontend/rn_fusebox.html?ws=${encodeURIComponent(wsPath)}&sources.hide_add_folder=true&unstable_enableNetworkPanel=true\n`,
)
}
console.log(`[proxy] listening on :${PROXY_PORT}, forwarding to 127.0.0.1:${METRO_PORT}`)

View File

@@ -1,52 +0,0 @@
// Opens React Native DevTools in Chrome, connected to the first
// available Hermes debug target. Requires Metro + proxy to be running.
import { $ } from "bun"
const PROXY_PORT = process.env.PROXY_PORT || "8080"
const METRO_PORT = process.env.METRO_PORT || "8081"
const tsIp = (await $`tailscale ip -4`.text()).trim()
const base = `http://${tsIp}:${PROXY_PORT}`
interface DebugTarget {
devtoolsFrontendUrl: string
webSocketDebuggerUrl: string
reactNative?: {
capabilities?: {
prefersFuseboxFrontend?: boolean
}
}
}
const res = await fetch(`${base}/json`)
if (!res.ok) {
console.error("Failed to fetch /json — is Metro running?")
process.exit(1)
}
const targets: DebugTarget[] = await res.json()
const target = targets.find((t) => t.reactNative?.capabilities?.prefersFuseboxFrontend)
if (!target) {
console.error("No debug target found. Is the app connected?")
process.exit(1)
}
const wsUrl = target.webSocketDebuggerUrl
.replace(/^ws:\/\//, "")
.replace(`127.0.0.1:${METRO_PORT}`, `${tsIp}:${PROXY_PORT}`)
const url = `${base}/debugger-frontend/rn_fusebox.html?ws=${encodeURIComponent(wsUrl)}&sources.hide_add_folder=true&unstable_enableNetworkPanel=true`
console.log(url)
// Open in Chrome app mode if on macOS
try {
await $`open -a "Google Chrome" --args --app=${url}`.quiet()
} catch {
try {
await $`xdg-open ${url}`.quiet()
} catch {
console.log("Open the URL above in Chrome.")
}
}

View File

@@ -1,15 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
PROXY_PORT=8080
METRO_PORT=8081
# Start a reverse proxy so Metro sees all requests as loopback.
# This makes debugger endpoints (/debugger-frontend, /json, /open-debugger)
# accessible through the Tailscale IP.
PROXY_PORT=$PROXY_PORT METRO_PORT=$METRO_PORT bun run scripts/dev-proxy.ts &
PROXY_PID=$!
trap "kill $PROXY_PID 2>/dev/null" EXIT
EXPO_PACKAGER_PROXY_URL=http://$(tailscale ip -4):$PROXY_PORT bunx expo start --localhost -p $METRO_PORT

View File

@@ -1,6 +0,0 @@
import { ApiRequestMiddleware } from "./client"
export const authMiddleware: ApiRequestMiddleware = (_url, init) => {
// TODO: placeholder auth middleware
return init
}

View File

@@ -1,65 +0,0 @@
import "react-native-reanimated"
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import { Stack } from "expo-router"
import { StatusBar } from "expo-status-bar"
import React from "react"
import { useColorScheme } from "react-native"
import tw, { useDeviceContext } from "twrnc"
import { authMiddleware } from "@/api/auth-middleware"
import { ApiClient, ApiClientContext } from "@/api/client"
const queryClient = new QueryClient()
const apiClient = new ApiClient({
baseUrl: process.env.EXPO_PUBLIC_API_BASE_URL ?? "",
middlewares: [authMiddleware],
})
export default function RootLayout() {
useDeviceContext(tw)
const colorScheme = useColorScheme()
const headerBg = colorScheme === "dark" ? "#1c1917" : "#f5f5f4"
const headerTint = colorScheme === "dark" ? "#e7e5e4" : "#1c1917"
return (
<ContextProvider>
<Stack
screenOptions={{
headerShown: false,
contentStyle: { backgroundColor: headerBg },
}}
>
<Stack.Screen
name="components/index"
options={{
headerShown: true,
title: "Components",
headerStyle: { backgroundColor: headerBg },
headerTintColor: headerTint,
headerShadowVisible: false,
}}
/>
<Stack.Screen
name="components/[name]"
options={{
headerShown: true,
title: "",
headerStyle: { backgroundColor: headerBg },
headerTintColor: headerTint,
headerShadowVisible: false,
}}
/>
</Stack>
<StatusBar style="auto" />
</ContextProvider>
)
}
function ContextProvider({ children }: React.PropsWithChildren) {
return (
<QueryClientProvider client={queryClient}>
<ApiClientContext value={apiClient}>{children}</ApiClientContext>
</QueryClientProvider>
)
}

View File

@@ -1,28 +0,0 @@
import { Link } from "expo-router"
import { Pressable } from "react-native"
import { SafeAreaView } from "react-native-safe-area-context"
import tw from "twrnc"
import { Button } from "@/components/ui/button"
import { FeedCard } from "@/components/ui/feed-card"
import { MonospaceText } from "@/components/ui/monospace-text"
import { SansSerifText } from "@/components/ui/sans-serif-text"
import { SerifText } from "@/components/ui/serif-text"
export default function HomeScreen() {
return (
<SafeAreaView style={tw`bg-stone-100 dark:bg-stone-900 flex-1 px-5 pt-6 gap-4`}>
<FeedCard>
<SerifText style={tw`text-4xl`}>Hello world asdsadsa</SerifText>
<SansSerifText style={tw`text-4xl font-bold`}>Hello world</SansSerifText>
<MonospaceText style={tw`text-4xl`}>asdjsakljdl</MonospaceText>
<Button style={tw`self-start`} label="Test" />
</FeedCard>
<Link href="/components" asChild>
<Pressable>
<SansSerifText style={tw`text-teal-600`}>View component library</SansSerifText>
</Pressable>
</Link>
</SafeAreaView>
)
}

View File

@@ -1,42 +0,0 @@
import { View } from "react-native"
import tw from "twrnc"
import { Button } from "./button"
import { type Showcase, Section } from "../showcase"
function ButtonShowcase() {
return (
<View style={tw`gap-6`}>
<Section title="Default">
<Button style={tw`self-start`} label="Press me" />
</Section>
<Section title="Leading icon">
<Button
style={tw`self-start`}
label="Add item"
leadingIcon={<Button.Icon name="plus" />}
/>
</Section>
<Section title="Trailing icon">
<Button
style={tw`self-start`}
label="Next"
trailingIcon={<Button.Icon name="arrow-right" />}
/>
</Section>
<Section title="Both icons">
<Button
style={tw`self-start`}
label="Download"
leadingIcon={<Button.Icon name="download" />}
trailingIcon={<Button.Icon name="chevron-down" />}
/>
</Section>
</View>
)
}
export const buttonShowcase: Showcase = {
title: "Button",
component: ButtonShowcase,
}

View File

@@ -1,43 +0,0 @@
import Feather from "@expo/vector-icons/Feather"
import { type PressableProps, Pressable, View } from "react-native"
import tw from "twrnc"
import { SansSerifText } from "./sans-serif-text"
type FeatherIconName = React.ComponentProps<typeof Feather>["name"]
type ButtonIconProps = {
name: FeatherIconName
}
function ButtonIcon({ name }: ButtonIconProps) {
return <Feather name={name} size={18} color={tw.color("text-stone-100 dark:text-stone-200")} />
}
type ButtonProps = Omit<PressableProps, "children"> & {
label: string
leadingIcon?: React.ReactNode
trailingIcon?: React.ReactNode
}
export function Button({ style, label, leadingIcon, trailingIcon, ...props }: ButtonProps) {
const hasIcons = leadingIcon != null || trailingIcon != null
const textElement = <SansSerifText style={tw`text-stone-100 dark:text-stone-200 font-medium`}>{label}</SansSerifText>
return (
<Pressable style={[tw`rounded-full bg-teal-600 px-4 py-3 w-fit`, style]} {...props}>
{hasIcons ? (
<View style={tw`flex-row items-center gap-1.5`}>
{leadingIcon}
{textElement}
{trailingIcon}
</View>
) : (
textElement
)}
</Pressable>
)
}
Button.Icon = ButtonIcon

View File

@@ -0,0 +1,15 @@
{
"name": "@freya/agent-test-cli",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"format": "oxfmt --write .",
"start": "bun run src/agent-test-cli.ts",
"typecheck": "bun tsc --noEmit"
},
"dependencies": {
"@freya/agent-protocol": "workspace:*",
"@nym.sh/jrpc": "^0.1.0"
}
}

View File

@@ -0,0 +1,888 @@
import type {
AgentClientApi,
AgentEvent,
AgentServerApi,
SendMessageResult,
} from "@freya/agent-protocol"
import type { JrpcChannel, JrpcMessage, JsonRpcMessage } from "@nym.sh/jrpc"
import { JsonRpcClient, JsonRpcServer } from "@nym.sh/jrpc"
type JsonObject = Record<string, unknown>
interface AuthUser {
id: string
name: string
email: string
image: string | null
}
interface AuthSession {
user: AuthUser
session: {
id: string
token: string
}
}
interface QueryToolDefinition {
name: string
label: string
description: string
parameters: unknown
}
interface QueryToolsResponse {
tools: QueryToolDefinition[]
}
interface ResultResponse {
result: unknown
}
interface SourceActionsResponse {
actions: Record<string, { id: string; description?: string }>
}
interface RequestOptions {
method?: "GET" | "POST"
body?: unknown
}
class CookieJar {
private readonly cookies = new Map<string, string>()
apply(response: Response): void {
for (const header of readSetCookieHeaders(response.headers)) {
const cookie = parseCookie(header)
if (!cookie) continue
this.cookies.set(cookie.name, cookie.value)
}
}
header(): string | undefined {
if (this.cookies.size === 0) return undefined
return [...this.cookies.entries()].map(([name, value]) => `${name}=${value}`).join("; ")
}
}
class AgentWebSocketSession implements AgentClientApi {
private readonly channel: WebSocketJrpcChannel
private readonly client: JsonRpcClient<AgentServerApi>
private readonly server: JsonRpcServer<AgentClientApi>
private conversationId: string | undefined
private responseHadText = false
private constructor(channel: WebSocketJrpcChannel) {
this.channel = channel
this.client = new JsonRpcClient<AgentServerApi>(channel)
this.server = new JsonRpcServer<AgentClientApi>(
{
notify: this.notify.bind(this),
},
channel,
)
}
static async connect(backendUrl: string, cookies: CookieJar): Promise<AgentWebSocketSession> {
const channel = new WebSocketJrpcChannel(agentWebSocketUrl(backendUrl), cookies.header())
const session = new AgentWebSocketSession(channel)
try {
await channel.waitUntilOpen()
void session.server.start().catch((err: unknown) => {
if (!channel.isClosed()) {
console.error(`\nWebSocket JSON-RPC failed: ${formatError(err)}\n`)
}
})
await session.client.call("ping")
} catch (err) {
channel.close()
throw err
}
return session
}
async ask(message: string): Promise<void> {
this.responseHadText = false
const result = await this.sendMessage(message)
if (result.conversationId) {
this.conversationId = result.conversationId
}
if (!this.responseHadText) {
console.log(`\nagent> ${result.message || "(no message)"}`)
}
console.log("")
}
notify(event: AgentEvent): void {
switch (event.type) {
case "conversation_started":
this.conversationId = event.conversationId
break
case "message_created":
this.printMessage(event.text)
break
case "tool_started":
console.log(`\ntool> ${event.toolName} started`)
break
case "tool_finished":
console.log(`tool> ${event.toolName} ${event.ok ? "finished" : "failed"}`)
break
case "message_finished":
break
case "message_failed":
console.log(`\nagent! ${event.error}`)
break
}
}
describeConversation(): string {
return this.conversationId ? `Conversation: ${this.conversationId}` : "No conversation yet."
}
close(): void {
this.channel.close()
}
private async sendMessage(message: string): Promise<SendMessageResult> {
return this.client.call("sendMessage", message)
}
private printMessage(text: string): void {
if (text === "") return
console.log(`\nagent> ${text}`)
this.responseHadText = true
}
}
class WebSocketJrpcChannel implements JrpcChannel {
private readonly ws: WebSocket
private readonly opened: Promise<void>
private closed = false
private openedOnce = false
private queue: JrpcMessage[] = []
private waiters: Array<(result: IteratorResult<JrpcMessage, void>) => void> = []
constructor(url: string, cookieHeader?: string) {
this.ws = new WebSocket(url, createWebSocketOptions(cookieHeader))
this.opened = new Promise((resolve, reject) => {
this.ws.onopen = () => {
this.openedOnce = true
resolve()
}
this.ws.onerror = () => {
if (!this.openedOnce) {
reject(new Error(`Could not connect to ${url}`))
}
}
this.ws.onclose = (event) => {
if (!this.openedOnce) {
reject(new Error(formatWebSocketClose(url, event)))
}
this.close()
}
})
this.ws.onmessage = (event) => {
this.receive(event.data)
}
}
waitUntilOpen(): Promise<void> {
return this.opened
}
isClosed(): boolean {
return this.closed
}
async send(msg: JsonRpcMessage): Promise<void> {
await this.opened
if (this.closed || this.ws.readyState !== WebSocket.OPEN) {
throw new Error("JSON-RPC WebSocket channel is closed")
}
this.ws.send(JSON.stringify(msg))
}
async next(): Promise<IteratorResult<JrpcMessage, void>> {
const msg = this.queue.shift()
if (msg) {
return { done: false, value: msg }
}
if (this.closed) {
return { done: true, value: undefined }
}
return new Promise((resolve) => {
this.waiters.push(resolve)
})
}
async return(): Promise<IteratorResult<JrpcMessage, void>> {
this.close()
return { done: true, value: undefined }
}
async throw(error?: unknown): Promise<IteratorResult<JrpcMessage, void>> {
this.close()
throw error
}
async [Symbol.asyncDispose](): Promise<void> {
await this.return()
}
close(): void {
if (this.closed) return
this.closed = true
for (const resolve of this.waiters.splice(0)) {
resolve({ done: true, value: undefined })
}
if (this.ws.readyState === WebSocket.CONNECTING || this.ws.readyState === WebSocket.OPEN) {
this.ws.close()
}
}
[Symbol.asyncIterator](): AsyncGenerator<JrpcMessage, void, unknown> {
return this
}
private receive(message: unknown): void {
const parsed = parseJrpcMessage(message)
if (!parsed) {
this.ws.close(1003, "Invalid JSON-RPC message")
this.close()
return
}
this.push(parsed)
}
private push(msg: JrpcMessage): void {
if (this.closed) return
const resolve = this.waiters.shift()
if (resolve) {
resolve({ done: false, value: msg })
return
}
this.queue.push(msg)
}
}
async function main(): Promise<void> {
if (wantsHelp()) {
printUsage()
return
}
printIntro()
const backendUrl = askRequired(
"Backend URL",
Bun.env.FREYA_BACKEND_URL ?? "http://localhost:3000",
normalizeBackendUrl,
)
const email = askRequired("Email", Bun.env.FREYA_EMAIL)
const password = askRequired("Password", Bun.env.FREYA_PASSWORD, undefined, true)
const cookies = new CookieJar()
try {
const session = await signIn(backendUrl, cookies, email, password)
console.log(`\nSigned in as ${session.user.email}`)
await runChatLoop(backendUrl, cookies, session)
} catch (err) {
console.error(`\n${formatError(err)}`)
}
}
async function signIn(
backendUrl: string,
cookies: CookieJar,
email: string,
password: string,
): Promise<AuthSession> {
await requestJson(backendUrl, cookies, "/api/auth/sign-in/email", {
method: "POST",
body: { email, password },
})
const data = await requestJson(backendUrl, cookies, "/api/auth/get-session")
if (!isAuthSession(data)) {
throw new Error("Sign-in succeeded, but no session was returned")
}
return data
}
async function runChatLoop(
backendUrl: string,
cookies: CookieJar,
session: AuthSession,
): Promise<void> {
const agent = await AgentWebSocketSession.connect(backendUrl, cookies)
console.log("Connected to /api/agent/ws")
printHelp()
try {
for (;;) {
const input = askOptional("you> ")?.trim()
if (!input) continue
if (input === "/quit" || input === "/exit") {
console.log("Bye.")
return
}
if (input === "/help") {
printHelp()
continue
}
if (input === "/session") {
console.log(`${session.user.name || session.user.email} (${session.user.id})`)
continue
}
if (input === "/conversation") {
console.log(agent.describeConversation())
continue
}
if (input === "/tools") {
await runCliCommand(() => listQueryTools(backendUrl, cookies))
continue
}
if (input.startsWith("/tool ")) {
await runCliCommand(() =>
executeQueryTool(backendUrl, cookies, input.slice("/tool ".length)),
)
continue
}
if (input.startsWith("/actions ")) {
await runCliCommand(() =>
listSourceActions(backendUrl, cookies, input.slice("/actions ".length)),
)
continue
}
if (input.startsWith("/action ")) {
await runCliCommand(() =>
executeSourceAction(backendUrl, cookies, input.slice("/action ".length)),
)
continue
}
try {
await agent.ask(input)
} catch (err) {
console.error(`\n${formatError(err)}\n`)
}
}
} finally {
agent.close()
}
}
async function runCliCommand(command: () => Promise<void>): Promise<void> {
try {
await command()
} catch (err) {
console.error(`\n${formatError(err)}\n`)
}
}
async function listQueryTools(backendUrl: string, cookies: CookieJar): Promise<void> {
const data = await requestJson(backendUrl, cookies, "/api/agent/tools")
if (!isQueryToolsResponse(data)) {
throw new Error("Agent tools returned an unexpected response shape")
}
console.log("")
for (const tool of data.tools) {
console.log(`${tool.name} - ${tool.label}`)
console.log(` ${tool.description}`)
console.log(` params=${formatJson(tool.parameters)}`)
}
console.log("")
}
async function executeQueryTool(
backendUrl: string,
cookies: CookieJar,
command: string,
): Promise<void> {
const parsed = splitFirst(command.trim())
if (!parsed) {
throw new Error("Usage: /tool <name> <json-params>; example: /tool freya_list_context {}")
}
const params = parseJsonArgument(parsed.rest, {})
const data = await requestJson(backendUrl, cookies, `/api/agent/tools/${urlPart(parsed.head)}`, {
method: "POST",
body: params,
})
if (!isResultResponse(data)) {
throw new Error("Tool execution returned an unexpected response shape")
}
console.log(`\ntool ${parsed.head}>`)
console.log(formatJson(data.result))
console.log("")
}
async function listSourceActions(
backendUrl: string,
cookies: CookieJar,
command: string,
): Promise<void> {
const sourceId = command.trim()
if (!sourceId) {
throw new Error("Usage: /actions <source-id>")
}
const data = await requestJson(backendUrl, cookies, `/api/sources/${urlPart(sourceId)}/actions`)
if (!isSourceActionsResponse(data)) {
throw new Error("Source actions returned an unexpected response shape")
}
const actions = Object.entries(data.actions)
console.log("")
if (actions.length === 0) {
console.log(`No actions for ${sourceId}.`)
} else {
for (const [key, action] of actions) {
console.log(`${sourceId}/${key}`)
console.log(` id=${action.id}`)
if (action.description) console.log(` ${action.description}`)
}
}
console.log("")
}
async function executeSourceAction(
backendUrl: string,
cookies: CookieJar,
command: string,
): Promise<void> {
const source = splitFirst(command.trim())
if (!source) {
throw new Error(
'Usage: /action <source-id> <action-id> <json-params>; example: /action freya.location update-location {"lat":51.5,"lng":-0.1}',
)
}
const action = splitFirst(source.rest)
if (!action) {
throw new Error(
'Usage: /action <source-id> <action-id> <json-params>; example: /action freya.location update-location {"lat":51.5,"lng":-0.1}',
)
}
const params = parseJsonArgument(action.rest, {})
const data = await requestJson(
backendUrl,
cookies,
`/api/sources/${urlPart(source.head)}/actions/${urlPart(action.head)}`,
{
method: "POST",
body: params,
},
)
if (!isResultResponse(data)) {
throw new Error("Source action returned an unexpected response shape")
}
console.log(`\naction ${source.head}/${action.head}>`)
console.log(formatJson(data.result))
console.log("")
}
async function requestJson(
backendUrl: string,
cookies: CookieJar,
path: string,
options: RequestOptions = {},
): Promise<unknown> {
const headers = new Headers()
headers.set("Accept", "application/json")
const cookieHeader = cookies.header()
if (cookieHeader) headers.set("Cookie", cookieHeader)
let body: string | undefined
if (options.body !== undefined) {
headers.set("Content-Type", "application/json")
body = JSON.stringify(options.body)
}
const response = await fetch(`${backendUrl}${path}`, {
method: options.method ?? "GET",
headers,
body,
})
cookies.apply(response)
if (!response.ok) {
throw new Error(await readResponseError(response, path))
}
return response.json()
}
function printIntro(): void {
console.log("FREYA agent test CLI")
console.log("Connect to a backend, sign in, then send test messages to /api/agent/ws.\n")
}
function printUsage(): void {
console.log("FREYA agent test CLI")
console.log("")
console.log("Usage:")
console.log(" bun run agent-test-cli")
console.log(
" FREYA_BACKEND_URL=http://localhost:3000 FREYA_EMAIL=user@example.com FREYA_PASSWORD=secret bun run agent-test-cli",
)
console.log("")
printHelp()
}
function printHelp(): void {
console.log("\nCommands:")
console.log(" /tools List agent debug tools")
console.log(" /tool Execute an agent debug tool with JSON params")
console.log(" /actions List source actions: /actions <source-id>")
console.log(" /action Execute source action: /action <source-id> <action-id> <json-params>")
console.log(" /conversation Show the current websocket conversation")
console.log(" /session Show the signed-in user")
console.log(" /help Show commands")
console.log(" /quit Exit\n")
}
function askRequired(
label: string,
defaultValue?: string,
transform?: (value: string) => string,
hidden = false,
): string {
if (hidden && defaultValue) {
const value = defaultValue.trim()
if (value) return transform ? transform(value) : value
}
const canRetry = canRunStty()
for (;;) {
const answer = hidden
? askHidden(label, defaultValue)
: askOptional(formatPromptLabel(label, defaultValue))
const value = (answer || defaultValue || "").trim()
if (!value) {
if (!canRetry) {
throw new Error(`${label} is required`)
}
console.log(`${label} is required.`)
continue
}
return transform ? transform(value) : value
}
}
function askOptional(label: string): string | null {
return prompt(label)
}
function askHidden(label: string, defaultValue?: string): string | null {
const shouldHide = !defaultValue && canRunStty()
if (!shouldHide) return askOptional(formatPromptLabel(label, defaultValue))
try {
Bun.spawnSync(["stty", "-echo"], { stdin: "inherit", stdout: "inherit", stderr: "inherit" })
return askOptional(`${label}: `)
} finally {
Bun.spawnSync(["stty", "echo"], { stdin: "inherit", stdout: "inherit", stderr: "inherit" })
console.log("")
}
}
function wantsHelp(): boolean {
return Bun.argv.some((arg) => arg === "--help" || arg === "-h")
}
function normalizeBackendUrl(value: string): string {
const withProtocol = /^[a-z]+:\/\//i.test(value) ? value : `http://${value}`
try {
const url = new URL(withProtocol)
if (url.protocol !== "http:" && url.protocol !== "https:") {
throw new Error("Backend URL must use http or https")
}
return url.toString().replace(/\/+$/, "")
} catch {
throw new Error(`Invalid backend URL: ${value}`)
}
}
function agentWebSocketUrl(backendUrl: string): string {
const url = new URL(backendUrl)
url.protocol = url.protocol === "https:" ? "wss:" : "ws:"
url.pathname = "/api/agent/ws"
url.search = ""
url.hash = ""
return url.toString()
}
function createWebSocketOptions(cookieHeader?: string): Bun.WebSocketOptions | undefined {
if (!cookieHeader) return undefined
return {
headers: {
Cookie: cookieHeader,
},
}
}
function formatWebSocketClose(
url: string,
event: { code: number; reason: string; wasClean: boolean },
): string {
const reason = event.reason ? `: ${event.reason}` : ""
return `Could not connect to ${url} (${event.code}${reason})`
}
function formatPromptLabel(label: string, defaultValue?: string): string {
return defaultValue ? `${label} (${defaultValue}): ` : `${label}: `
}
function splitFirst(value: string): { head: string; rest: string } | null {
const trimmed = value.trim()
if (!trimmed) return null
const match = /\s/.exec(trimmed)
if (!match) {
return { head: trimmed, rest: "" }
}
const head = trimmed.slice(0, match.index)
const rest = trimmed.slice(match.index).trim()
return { head, rest }
}
function parseJsonArgument(value: string, fallback: unknown): unknown {
if (!value.trim()) return fallback
try {
return JSON.parse(value)
} catch (err) {
throw new Error(`Invalid JSON params: ${formatError(err)}`)
}
}
function formatJson(value: unknown): string {
const serialized = JSON.stringify(value, null, 2)
return serialized ?? "undefined"
}
function urlPart(value: string): string {
return encodeURIComponent(value)
}
function canRunStty(): boolean {
const result = Bun.spawnSync(["stty", "-g"], { stdin: "inherit", stdout: "pipe", stderr: "pipe" })
return result.exitCode === 0
}
function readSetCookieHeaders(headers: Headers): string[] {
const setCookies = headers.getSetCookie()
if (setCookies && setCookies.length > 0) return setCookies
const header = headers.get("set-cookie")
if (!header) return []
return splitSetCookieHeader(header)
}
function parseCookie(header: string): { name: string; value: string } | null {
const [cookiePair] = header.split(";")
if (!cookiePair) return null
const index = cookiePair.indexOf("=")
if (index <= 0) return null
return {
name: cookiePair.slice(0, index).trim(),
value: cookiePair.slice(index + 1).trim(),
}
}
function splitSetCookieHeader(header: string): string[] {
const parts: string[] = []
let start = 0
let inExpires = false
for (let index = 0; index < header.length; index += 1) {
const char = header[index]
const remainder = header.slice(index).toLowerCase()
if (remainder.startsWith("expires=")) {
inExpires = true
continue
}
if (inExpires && char === ";") {
inExpires = false
continue
}
if (char === "," && !inExpires) {
parts.push(header.slice(start, index).trim())
start = index + 1
}
}
parts.push(header.slice(start).trim())
return parts.filter(Boolean)
}
function parseJrpcMessage(message: unknown): JrpcMessage | null {
const text = webSocketMessageText(message)
if (!text) return null
try {
const value: unknown = JSON.parse(text)
return isJrpcMessage(value) ? value : null
} catch {
return null
}
}
function webSocketMessageText(message: unknown): string | null {
if (typeof message === "string") return message
if (message instanceof Uint8Array) return Buffer.from(message).toString("utf8")
if (message instanceof ArrayBuffer) return Buffer.from(message).toString("utf8")
return null
}
async function readResponseError(response: Response, path: string): Promise<string> {
const text = await response.text()
if (response.status === 404 && path === "/api/agent") {
return "Backend does not expose /api/agent. Restart the WIP backend on port 3000 or check FREYA_BACKEND_URL."
}
if (!text) return `Request failed: ${response.status} ${response.statusText}`
try {
const data: unknown = JSON.parse(text)
if (isJsonObject(data)) {
const message = readString(data, "message") ?? readString(data, "error")
if (message) return message
}
} catch {
return `Request failed: ${response.status} ${response.statusText}: ${text}`
}
return `Request failed: ${response.status} ${response.statusText}: ${text}`
}
function isAuthSession(value: unknown): value is AuthSession {
if (!isJsonObject(value)) return false
const user = value.user
const session = value.session
return (
isJsonObject(user) &&
isJsonObject(session) &&
typeof user.id === "string" &&
typeof user.name === "string" &&
typeof user.email === "string" &&
(user.image === null || typeof user.image === "string") &&
typeof session.id === "string" &&
typeof session.token === "string"
)
}
function isQueryToolsResponse(value: unknown): value is QueryToolsResponse {
if (!isJsonObject(value) || !Array.isArray(value.tools)) return false
return value.tools.every(isQueryToolDefinition)
}
function isQueryToolDefinition(value: unknown): value is QueryToolDefinition {
return (
isJsonObject(value) &&
typeof value.name === "string" &&
typeof value.label === "string" &&
typeof value.description === "string" &&
"parameters" in value
)
}
function isResultResponse(value: unknown): value is ResultResponse {
return isJsonObject(value) && "result" in value
}
function isSourceActionsResponse(value: unknown): value is SourceActionsResponse {
if (!isJsonObject(value) || !isJsonObject(value.actions)) return false
return Object.values(value.actions).every(isSourceActionDefinition)
}
function isSourceActionDefinition(value: unknown): value is { id: string; description?: string } {
return (
isJsonObject(value) &&
typeof value.id === "string" &&
(value.description === undefined || typeof value.description === "string")
)
}
function isJrpcMessage(value: unknown): value is JrpcMessage {
if (!isJsonObject(value) || value.jsonrpc !== "2.0" || typeof value.id !== "number") {
return false
}
if ("method" in value) {
return (
typeof value.method === "string" &&
(value.params === undefined || Array.isArray(value.params))
)
}
if ("result" in value) {
return true
}
if ("error" in value) {
return isJsonRpcErrorObject(value.error)
}
return false
}
function isJsonRpcErrorObject(value: unknown): boolean {
return isJsonObject(value) && typeof value.code === "number" && typeof value.message === "string"
}
function isJsonObject(value: unknown): value is JsonObject {
return typeof value === "object" && value !== null && !Array.isArray(value)
}
function readString(object: JsonObject, key: string): string | undefined {
const value = object[key]
return typeof value === "string" ? value : undefined
}
function formatError(error: unknown): string {
return error instanceof Error ? error.message : String(error)
}
await main()

View File

@@ -0,0 +1,4 @@
{
"extends": "../../tsconfig.json",
"include": ["src/**/*.ts"]
}

View File

@@ -5,15 +5,13 @@ DATABASE_URL=postgresql://user:password@localhost:5432/aris
BETTER_AUTH_SECRET= BETTER_AUTH_SECRET=
# Encryption key for source credentials at rest (32 bytes, generate with: openssl rand -base64 32) # Encryption key for source credentials at rest (32 bytes, generate with: openssl rand -base64 32)
CREDENTIALS_ENCRYPTION_KEY= CREDENTIAL_ENCRYPTION_KEY=
# Base URL of the backend # Base URL of the backend
BETTER_AUTH_URL=http://localhost:3000 BETTER_AUTH_URL=http://localhost:3000
# OpenRouter (LLM feed enhancement) # OpenRouter (LLM feed enhancement)
OPENROUTER_API_KEY= OPENROUTER_API_KEY=
# Optional: override the default model (default: openai/gpt-4.1-mini)
# OPENROUTER_MODEL=openai/gpt-4.1-mini
# Apple WeatherKit credentials # Apple WeatherKit credentials
WEATHERKIT_PRIVATE_KEY= WEATHERKIT_PRIVATE_KEY=

View File

@@ -49,6 +49,33 @@ CREATE TABLE "user_sources" (
CONSTRAINT "user_sources_user_id_source_id_unique" UNIQUE("user_id","source_id") CONSTRAINT "user_sources_user_id_source_id_unique" UNIQUE("user_id","source_id")
); );
--> statement-breakpoint --> statement-breakpoint
CREATE TABLE "reminders" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" text NOT NULL,
"title" text NOT NULL,
"notes" text,
"due_at" timestamp NOT NULL,
"time_zone" text DEFAULT 'UTC' NOT NULL,
"recurrence" jsonb,
"priority" text DEFAULT 'normal' NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "reminder_occurrence_overrides" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" text NOT NULL,
"reminder_id" uuid NOT NULL,
"occurrence_id" text NOT NULL,
"original_due_at" timestamp NOT NULL,
"patch" jsonb,
"completed_at" timestamp,
"deleted_at" timestamp,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL,
CONSTRAINT "reminder_occurrence_overrides_reminder_id_occurrence_id_unique" UNIQUE("reminder_id","occurrence_id")
);
--> statement-breakpoint
CREATE TABLE "verification" ( CREATE TABLE "verification" (
"id" text PRIMARY KEY NOT NULL, "id" text PRIMARY KEY NOT NULL,
"identifier" text NOT NULL, "identifier" text NOT NULL,
@@ -61,6 +88,13 @@ CREATE TABLE "verification" (
ALTER TABLE "account" ADD CONSTRAINT "account_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint ALTER TABLE "account" ADD CONSTRAINT "account_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "session" ADD CONSTRAINT "session_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint ALTER TABLE "session" ADD CONSTRAINT "session_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "user_sources" ADD CONSTRAINT "user_sources_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint ALTER TABLE "user_sources" ADD CONSTRAINT "user_sources_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "reminders" ADD CONSTRAINT "reminders_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "reminder_occurrence_overrides" ADD CONSTRAINT "reminder_occurrence_overrides_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "reminder_occurrence_overrides" ADD CONSTRAINT "reminder_occurrence_overrides_reminder_id_reminders_id_fk" FOREIGN KEY ("reminder_id") REFERENCES "public"."reminders"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "account_userId_idx" ON "account" USING btree ("user_id");--> statement-breakpoint CREATE INDEX "account_userId_idx" ON "account" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "session_userId_idx" ON "session" USING btree ("user_id");--> statement-breakpoint CREATE INDEX "session_userId_idx" ON "session" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "verification_identifier_idx" ON "verification" USING btree ("identifier"); CREATE INDEX "reminders_user_id_due_at_idx" ON "reminders" USING btree ("user_id","due_at");--> statement-breakpoint
CREATE INDEX "reminders_user_id_updated_at_idx" ON "reminders" USING btree ("user_id","updated_at");--> statement-breakpoint
CREATE INDEX "reminder_occurrence_overrides_user_id_reminder_id_idx" ON "reminder_occurrence_overrides" USING btree ("user_id","reminder_id");--> statement-breakpoint
CREATE INDEX "reminder_occurrence_overrides_user_id_original_due_at_idx" ON "reminder_occurrence_overrides" USING btree ("user_id","original_due_at");--> statement-breakpoint
CREATE INDEX "verification_identifier_idx" ON "verification" USING btree ("identifier");

View File

@@ -0,0 +1,49 @@
CREATE INDEX "user_sources_user_id_enabled_idx" ON "user_sources" USING btree ("user_id","enabled");--> statement-breakpoint
CREATE TABLE "conversation_entries" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"conversation_id" uuid NOT NULL,
"sequence" integer NOT NULL,
"kind" text NOT NULL,
"visibility" text DEFAULT 'internal' NOT NULL,
"file_id" uuid,
"payload" jsonb NOT NULL,
"metadata" jsonb DEFAULT '{}'::jsonb NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
CONSTRAINT "conversation_entries_conversation_id_sequence_unique" UNIQUE("conversation_id","sequence")
);
--> statement-breakpoint
CREATE TABLE "conversations" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" text NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "files" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" text NOT NULL,
"storage_key" text NOT NULL,
"original_name" text,
"mime_type" text NOT NULL,
"size_bytes" integer NOT NULL,
"metadata" jsonb DEFAULT '{}'::jsonb NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
CONSTRAINT "files_storage_key_unique" UNIQUE("storage_key")
);
--> statement-breakpoint
ALTER TABLE "session" ADD COLUMN "impersonated_by" text;--> statement-breakpoint
ALTER TABLE "user" ADD COLUMN "role" text;--> statement-breakpoint
ALTER TABLE "user" ADD COLUMN "banned" boolean DEFAULT false;--> statement-breakpoint
ALTER TABLE "user" ADD COLUMN "ban_reason" text;--> statement-breakpoint
ALTER TABLE "user" ADD COLUMN "ban_expires" timestamp;--> statement-breakpoint
ALTER TABLE "conversation_entries" ADD CONSTRAINT "conversation_entries_conversation_id_conversations_id_fk" FOREIGN KEY ("conversation_id") REFERENCES "public"."conversations"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "conversation_entries" ADD CONSTRAINT "conversation_entries_file_id_files_id_fk" FOREIGN KEY ("file_id") REFERENCES "public"."files"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "conversation_entries" ADD CONSTRAINT "conversation_entries_attachment_file_id_check" CHECK (("conversation_entries"."kind" = 'attachment' and "conversation_entries"."file_id" is not null) or ("conversation_entries"."kind" <> 'attachment' and "conversation_entries"."file_id" is null));--> statement-breakpoint
ALTER TABLE "conversations" ADD CONSTRAINT "conversations_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "files" ADD CONSTRAINT "files_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "conversation_entries_conversation_id_sequence_idx" ON "conversation_entries" USING btree ("conversation_id","sequence");--> statement-breakpoint
CREATE INDEX "conversation_entries_conversation_id_visibility_sequence_idx" ON "conversation_entries" USING btree ("conversation_id","visibility","sequence");--> statement-breakpoint
CREATE INDEX "conversation_entries_kind_idx" ON "conversation_entries" USING btree ("kind");--> statement-breakpoint
CREATE INDEX "conversation_entries_file_id_idx" ON "conversation_entries" USING btree ("file_id");--> statement-breakpoint
CREATE INDEX "conversations_user_id_updated_at_idx" ON "conversations" USING btree ("user_id","updated_at");--> statement-breakpoint
CREATE INDEX "files_user_id_created_at_idx" ON "files" USING btree ("user_id","created_at");

View File

@@ -1,6 +1,6 @@
{ {
"id": "d963322c-77e2-4ac9-bd3c-ca544c85ae35", "id": "d8c59ec7-b686-41a7-a472-da29f3ab6727",
"prevId": "d8c59ec7-b686-41a7-a472-da29f3ab6727", "prevId": "00000000-0000-0000-0000-000000000000",
"version": "7", "version": "7",
"dialect": "postgresql", "dialect": "postgresql",
"tables": { "tables": {
@@ -346,29 +346,7 @@
"default": "now()" "default": "now()"
} }
}, },
"indexes": { "indexes": {},
"user_sources_user_id_enabled_idx": {
"name": "user_sources_user_id_enabled_idx",
"columns": [
{
"expression": "user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "enabled",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": { "foreignKeys": {
"user_sources_user_id_user_id_fk": { "user_sources_user_id_user_id_fk": {
"name": "user_sources_user_id_user_id_fk", "name": "user_sources_user_id_user_id_fk",
@@ -463,6 +441,296 @@
"policies": {}, "policies": {},
"checkConstraints": {}, "checkConstraints": {},
"isRLSEnabled": false "isRLSEnabled": false
},
"public.reminders": {
"name": "reminders",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true
},
"notes": {
"name": "notes",
"type": "text",
"primaryKey": false,
"notNull": false
},
"due_at": {
"name": "due_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"time_zone": {
"name": "time_zone",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'UTC'"
},
"recurrence": {
"name": "recurrence",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
"priority": {
"name": "priority",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'normal'"
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"reminders_user_id_due_at_idx": {
"name": "reminders_user_id_due_at_idx",
"columns": [
{
"expression": "user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "due_at",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"reminders_user_id_updated_at_idx": {
"name": "reminders_user_id_updated_at_idx",
"columns": [
{
"expression": "user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "updated_at",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"reminders_user_id_user_id_fk": {
"name": "reminders_user_id_user_id_fk",
"tableFrom": "reminders",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.reminder_occurrence_overrides": {
"name": "reminder_occurrence_overrides",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"reminder_id": {
"name": "reminder_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"occurrence_id": {
"name": "occurrence_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"original_due_at": {
"name": "original_due_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"patch": {
"name": "patch",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
"completed_at": {
"name": "completed_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"deleted_at": {
"name": "deleted_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"reminder_occurrence_overrides_user_id_reminder_id_idx": {
"name": "reminder_occurrence_overrides_user_id_reminder_id_idx",
"columns": [
{
"expression": "user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "reminder_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"reminder_occurrence_overrides_user_id_original_due_at_idx": {
"name": "reminder_occurrence_overrides_user_id_original_due_at_idx",
"columns": [
{
"expression": "user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "original_due_at",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"reminder_occurrence_overrides_user_id_user_id_fk": {
"name": "reminder_occurrence_overrides_user_id_user_id_fk",
"tableFrom": "reminder_occurrence_overrides",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"reminder_occurrence_overrides_reminder_id_reminders_id_fk": {
"name": "reminder_occurrence_overrides_reminder_id_reminders_id_fk",
"tableFrom": "reminder_occurrence_overrides",
"tableTo": "reminders",
"columnsFrom": [
"reminder_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"reminder_occurrence_overrides_reminder_id_occurrence_id_unique": {
"name": "reminder_occurrence_overrides_reminder_id_occurrence_id_unique",
"nullsNotDistinct": false,
"columns": [
"reminder_id",
"occurrence_id"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
} }
}, },
"enums": {}, "enums": {},
@@ -476,4 +744,4 @@
"schemas": {}, "schemas": {},
"tables": {} "tables": {}
} }
} }

File diff suppressed because it is too large Load Diff

View File

@@ -17,4 +17,4 @@
"breakpoints": true "breakpoints": true
} }
] ]
} }

View File

@@ -0,0 +1,44 @@
{
"name": "@freya/backend",
"version": "0.0.0",
"type": "module",
"main": "src/server.ts",
"scripts": {
"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",
"db:generate-auth": "bunx --bun auth@latest generate --config auth.ts --output src/db/auth-schema.ts -y",
"db:push": "bunx drizzle-kit push",
"db:migrate": "bunx drizzle-kit migrate",
"db:studio": "bunx drizzle-kit studio",
"create-admin": "bun run src/scripts/create-admin.ts"
},
"dependencies": {
"@better-auth/core": "^1.6.20",
"@better-auth/expo": "^1.6.20",
"@earendil-works/pi-coding-agent": "^0.79.1",
"@freya/agent-protocol": "workspace:*",
"@freya/core": "workspace:*",
"@freya/source-caldav": "workspace:*",
"@freya/source-google-calendar": "workspace:*",
"@freya/source-google-maps": "workspace:*",
"@freya/source-location": "workspace:*",
"@freya/source-reminders": "workspace:*",
"@freya/source-tfl": "workspace:*",
"@freya/source-weatherkit": "workspace:*",
"@freya/source-web-search": "workspace:*",
"@nym.sh/jrpc": "^0.1.0",
"@openrouter/sdk": "^0.9.11",
"arktype": "^2.1.29",
"better-auth": "^1.6.20",
"drizzle-orm": "^0.45.1",
"hono": "^4",
"lodash.merge": "^4.6.2",
"typebox": "^1.1.38"
},
"devDependencies": {
"@types/lodash.merge": "^4.6.9",
"drizzle-kit": "^0.31.9"
}
}

View File

@@ -1,4 +1,4 @@
import type { ActionDefinition, ContextEntry, FeedItem, FeedSource } from "@aelis/core" import type { ActionDefinition, ContextEntry, FeedItem, FeedSource } from "@freya/core"
import { describe, expect, mock, test } from "bun:test" import { describe, expect, mock, test } from "bun:test"
import { Hono } from "hono" import { Hono } from "hono"
@@ -44,6 +44,20 @@ mock.module("../sources/user-sources.ts", () => ({
}), }),
})) }))
mock.module("../conversations/storage.ts", () => ({
conversations: (_db: Database, userId: string) => ({
async getOrCreateConversation() {
return { id: `conversation-${userId}` }
},
async listEntries() {
return []
},
async appendEntry() {
return { id: "entry-1", sequence: 1 }
},
}),
}))
function createStubSource(id: string): FeedSource { function createStubSource(id: string): FeedSource {
return { return {
id, id,
@@ -118,9 +132,9 @@ const validWeatherConfig = {
describe("PUT /api/admin/:sourceId/config", () => { describe("PUT /api/admin/:sourceId/config", () => {
test("returns 404 for unknown provider", async () => { test("returns 404 for unknown provider", async () => {
const { app } = createApp([createStubProvider("aelis.location")]) const { app } = createApp([createStubProvider("freya.location")])
const res = await app.request("/api/admin/aelis.nonexistent/config", { const res = await app.request("/api/admin/freya.nonexistent/config", {
method: "PUT", method: "PUT",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ key: "value" }), body: JSON.stringify({ key: "value" }),
@@ -132,9 +146,9 @@ describe("PUT /api/admin/:sourceId/config", () => {
}) })
test("returns 404 for provider without runtime config support", async () => { test("returns 404 for provider without runtime config support", async () => {
const { app } = createApp([createStubProvider("aelis.location")]) const { app } = createApp([createStubProvider("freya.location")])
const res = await app.request("/api/admin/aelis.location/config", { const res = await app.request("/api/admin/freya.location/config", {
method: "PUT", method: "PUT",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ key: "value" }), body: JSON.stringify({ key: "value" }),
@@ -146,9 +160,9 @@ describe("PUT /api/admin/:sourceId/config", () => {
}) })
test("returns 400 for invalid JSON body", async () => { test("returns 400 for invalid JSON body", async () => {
const { app } = createApp([createStubProvider("aelis.weather")]) const { app } = createApp([createStubProvider("freya.weather")])
const res = await app.request("/api/admin/aelis.weather/config", { const res = await app.request("/api/admin/freya.weather/config", {
method: "PUT", method: "PUT",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: "not json", body: "not json",
@@ -160,9 +174,9 @@ describe("PUT /api/admin/:sourceId/config", () => {
}) })
test("returns 400 when weather config fails validation", async () => { test("returns 400 when weather config fails validation", async () => {
const { app } = createApp([createStubProvider("aelis.weather")]) const { app } = createApp([createStubProvider("freya.weather")])
const res = await app.request("/api/admin/aelis.weather/config", { const res = await app.request("/api/admin/freya.weather/config", {
method: "PUT", method: "PUT",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ credentials: { privateKey: 123 } }), body: JSON.stringify({ credentials: { privateKey: 123 } }),
@@ -174,11 +188,11 @@ describe("PUT /api/admin/:sourceId/config", () => {
}) })
test("returns 204 and applies valid weather config", async () => { test("returns 204 and applies valid weather config", async () => {
const { app, sessionManager } = createApp([createStubProvider("aelis.weather")]) const { app, sessionManager } = createApp([createStubProvider("freya.weather")])
const originalProvider = sessionManager.getProvider("aelis.weather") const originalProvider = sessionManager.getProvider("freya.weather")
const res = await app.request("/api/admin/aelis.weather/config", { const res = await app.request("/api/admin/freya.weather/config", {
method: "PUT", method: "PUT",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify(validWeatherConfig), body: JSON.stringify(validWeatherConfig),
@@ -187,9 +201,9 @@ describe("PUT /api/admin/:sourceId/config", () => {
expect(res.status).toBe(204) expect(res.status).toBe(204)
// Provider was replaced with a new instance // Provider was replaced with a new instance
const provider = sessionManager.getProvider("aelis.weather") const provider = sessionManager.getProvider("freya.weather")
expect(provider).toBeDefined() expect(provider).toBeDefined()
expect(provider!.sourceId).toBe("aelis.weather") expect(provider!.sourceId).toBe("freya.weather")
expect(provider).not.toBe(originalProvider) expect(provider).not.toBe(originalProvider)
}) })
}) })

View File

@@ -60,7 +60,7 @@ async function handleUpdateProviderConfig(c: Context<Env>) {
} }
switch (sourceId) { switch (sourceId) {
case "aelis.weather": { case "freya.weather": {
const parsed = WeatherKitSourceProviderConfig(body) const parsed = WeatherKitSourceProviderConfig(body)
if (parsed instanceof type.errors) { if (parsed instanceof type.errors) {
return c.json({ error: parsed.summary }, 400) return c.json({ error: parsed.summary }, 400)

View File

@@ -0,0 +1,347 @@
import { ConversationEntryKind } from "@freya/core"
import { describe, expect, test } from "bun:test"
import type { AppendConversationEntryInput } from "../conversations/storage.ts"
import type {
ConversationStorage,
ConversationStorageEntry,
} from "./conversation-recording-query-agent.ts"
import { ConversationRecordingQueryAgent } from "./conversation-recording-query-agent.ts"
import {
createQueryAgentEventListeners,
QueryAgentEvent,
type QueryAgent,
type QueryAgentAsk,
type QueryAgentCompactionEvent,
type QueryAgentEventListeners,
type QueryAgentEventListener,
type QueryAgentEventMap,
type QueryAgentStreamEvent,
} from "./query-agent.ts"
interface RecordedEntry {
conversationId: string
input: AppendConversationEntryInput
}
class FakeQueryAgent implements QueryAgent {
readonly inputs: QueryAgentAsk[] = []
private readonly events: QueryAgentStreamEvent[]
private readonly eventListeners = createQueryAgentEventListeners()
constructor(events: QueryAgentStreamEvent[]) {
this.events = events
}
async *ask(input: QueryAgentAsk): AsyncIterable<QueryAgentStreamEvent> {
this.inputs.push(input)
for (const event of this.events) {
yield event
}
}
addEventListener<T extends QueryAgentEvent>(
type: T,
listener: QueryAgentEventListener<T>,
): () => void {
const listeners = this.listenersFor(type)
listeners.add(listener)
return () => {
listeners.delete(listener)
}
}
async emitCompaction(event: QueryAgentCompactionEvent): Promise<void> {
await this.emitEvent(event)
}
private async emitEvent<T extends QueryAgentEvent>(event: QueryAgentEventMap[T]): Promise<void> {
const listeners = this.listenersFor(event.type)
for (const listener of listeners) {
await listener(event)
}
}
private listenersFor<T extends QueryAgentEvent>(type: T): QueryAgentEventListeners[T] {
return this.eventListeners[type]
}
dispose(): void {}
}
class FakeConversationStorage implements ConversationStorage {
getOrCreateCount = 0
readonly entries: RecordedEntry[] = []
conversationId = "conversation-1"
async getOrCreateConversation(): Promise<{ id: string }> {
this.getOrCreateCount += 1
return { id: this.conversationId }
}
async appendEntry(
conversationId: string,
input: AppendConversationEntryInput,
): Promise<ConversationStorageEntry> {
this.entries.push({ conversationId, input })
return {
id: `entry-${this.entries.length}`,
sequence: this.entries.length,
kind: input.kind,
payload: input.payload,
metadata: input.metadata ?? {},
createdAt: new Date("2026-06-15T00:00:00.000Z"),
}
}
async listEntries(_conversationId: string): Promise<ConversationStorageEntry[]> {
return this.entries.map((entry, index) => ({
id: `entry-${index + 1}`,
sequence: index + 1,
kind: entry.input.kind,
payload: entry.input.payload,
metadata: entry.input.metadata ?? {},
createdAt: new Date("2026-06-15T00:00:00.000Z"),
}))
}
}
describe("ConversationRecordingQueryAgent", () => {
test("records user and assistant messages in the conversation timeline", async () => {
const queryAgent = new FakeQueryAgent([
{ type: "text_delta", text: "Hello " },
{ type: "text_delta", text: "there." },
{ type: "done" },
])
const storage = new FakeConversationStorage()
const agent = new ConversationRecordingQueryAgent({
agent: queryAgent,
storage,
modelProvider: "openrouter",
modelId: "test-model",
})
const events = await collectEvents(
agent.ask({
message: "hi",
}),
)
expect(events[0]).toEqual({ type: "conversation", conversationId: "conversation-1" })
expect(queryAgent.inputs[0]?.conversationId).toBe("conversation-1")
expect(storage.getOrCreateCount).toBe(1)
expect(storage.entries).toHaveLength(2)
const userEntry = storage.entries[0]!.input
if (userEntry.kind !== ConversationEntryKind.UserMessage) {
throw new Error("Expected user message entry")
}
expect(userEntry.payload.parts).toEqual([{ type: "text", text: "hi" }])
const assistantEntry = storage.entries[1]!.input
if (assistantEntry.kind !== ConversationEntryKind.AssistantMessage) {
throw new Error("Expected assistant message entry")
}
expect(assistantEntry.payload.parts).toEqual([{ type: "text", text: "Hello there." }])
expect(assistantEntry.metadata?.modelRun?.provider).toBe("openrouter")
expect(assistantEntry.metadata?.modelRun?.model).toBe("test-model")
})
test("uses a provided conversation id without creating a default conversation", async () => {
const queryAgent = new FakeQueryAgent([{ type: "done" }])
const storage = new FakeConversationStorage()
const agent = new ConversationRecordingQueryAgent({
agent: queryAgent,
storage,
modelProvider: "openrouter",
modelId: "test-model",
})
const events = await collectEvents(
agent.ask({
conversationId: "conversation-existing",
message: "continue",
}),
)
expect(events[0]).toEqual({
type: "conversation",
conversationId: "conversation-existing",
})
expect(storage.getOrCreateCount).toBe(0)
expect(storage.entries[0]?.conversationId).toBe("conversation-existing")
expect(queryAgent.inputs[0]?.conversationId).toBe("conversation-existing")
})
test("uses the eager default conversation id without reading storage on ask", async () => {
const queryAgent = new FakeQueryAgent([{ type: "done" }])
const storage = new FakeConversationStorage()
const agent = new ConversationRecordingQueryAgent({
agent: queryAgent,
storage,
defaultConversationId: "conversation-eager",
modelProvider: "openrouter",
modelId: "test-model",
})
const events = await collectEvents(
agent.ask({
message: "continue",
}),
)
expect(events[0]).toEqual({
type: "conversation",
conversationId: "conversation-eager",
})
expect(storage.getOrCreateCount).toBe(0)
expect(storage.entries[0]?.conversationId).toBe("conversation-eager")
expect(queryAgent.inputs[0]?.conversationId).toBe("conversation-eager")
})
test("rejects switching away from the eager default conversation", async () => {
const queryAgent = new FakeQueryAgent([{ type: "done" }])
const storage = new FakeConversationStorage()
const agent = new ConversationRecordingQueryAgent({
agent: queryAgent,
storage,
defaultConversationId: "conversation-eager",
modelProvider: "openrouter",
modelId: "test-model",
})
const events = await collectEvents(
agent.ask({
conversationId: "conversation-other",
message: "continue",
}),
)
expect(events).toEqual([
{
type: "error",
message: "Conversation switching is not supported for this session",
},
])
expect(storage.entries).toHaveLength(0)
expect(queryAgent.inputs).toHaveLength(0)
})
test("records tool activity and agent errors as internal entries", async () => {
const queryAgent = new FakeQueryAgent([
{ type: "tool_start", toolName: "freya_get_feed" },
{ type: "tool_end", toolName: "freya_get_feed", ok: true },
{ type: "error", message: "model unavailable" },
])
const storage = new FakeConversationStorage()
const agent = new ConversationRecordingQueryAgent({
agent: queryAgent,
storage,
modelProvider: "openrouter",
modelId: "test-model",
})
await collectEvents(
agent.ask({
message: "what now?",
}),
)
expect(storage.entries.map((entry) => entry.input.kind)).toEqual([
ConversationEntryKind.UserMessage,
ConversationEntryKind.ToolCall,
ConversationEntryKind.ToolResult,
ConversationEntryKind.SystemNote,
])
const toolCall = storage.entries[1]!.input
if (toolCall.kind !== ConversationEntryKind.ToolCall) {
throw new Error("Expected tool call entry")
}
expect(toolCall.payload.toolName).toBe("freya_get_feed")
const toolResult = storage.entries[2]!.input
if (toolResult.kind !== ConversationEntryKind.ToolResult) {
throw new Error("Expected tool result entry")
}
expect(toolResult.payload.ok).toBe(true)
const systemNote = storage.entries[3]!.input
if (systemNote.kind !== ConversationEntryKind.SystemNote) {
throw new Error("Expected system note entry")
}
expect(systemNote.payload).toMatchObject({
type: "agent_error",
message: "model unavailable",
})
})
test("records compaction events as context summaries", async () => {
const queryAgent = new FakeQueryAgent([
{ type: "text_delta", text: "Kept answer." },
{ type: "done" },
])
const storage = new FakeConversationStorage()
const agent = new ConversationRecordingQueryAgent({
agent: queryAgent,
storage,
defaultConversationId: "conversation-1",
modelProvider: "openrouter",
modelId: "test-model",
})
const forwardedCompactions: QueryAgentCompactionEvent[] = []
agent.addEventListener(QueryAgentEvent.Compaction, (event) => {
forwardedCompactions.push(event)
})
await collectEvents(
agent.ask({
message: "remember this",
}),
)
await queryAgent.emitCompaction({
type: QueryAgentEvent.Compaction,
conversationId: "conversation-1",
summary: "The user prefers compact summaries.",
firstKeptEntryId: "pi-entry-7",
compactedEntryRange: {
startSequence: 1,
endSequence: 1,
},
tokensBefore: 1234,
details: { reason: "threshold" },
fromExtension: false,
})
const summaryEntry = storage.entries.at(-1)?.input
if (summaryEntry?.kind !== ConversationEntryKind.ContextSummary) {
throw new Error("Expected context summary entry")
}
expect(summaryEntry.payload.covers).toEqual({
startSequence: 1,
endSequence: 1,
})
expect(summaryEntry.payload.summary.importantDetails).toEqual([
"The user prefers compact summaries.",
])
expect(summaryEntry.metadata?.piCompaction).toMatchObject({
firstKeptEntryId: "pi-entry-7",
tokensBefore: 1234,
fromExtension: false,
details: { reason: "threshold" },
})
expect(forwardedCompactions).toHaveLength(1)
})
})
async function collectEvents(
events: AsyncIterable<QueryAgentStreamEvent>,
): Promise<QueryAgentStreamEvent[]> {
const result: QueryAgentStreamEvent[] = []
for await (const event of events) {
result.push(event)
}
return result
}

View File

@@ -0,0 +1,256 @@
import type { ConversationEntryMetadata } from "@freya/core"
import { ConversationEntryKind } from "@freya/core"
import { randomUUID } from "node:crypto"
import type {
AppendConversationEntryInput,
ConversationEntryRow,
} from "../conversations/storage.ts"
import {
createQueryAgentEventListeners,
QueryAgentEvent,
type QueryAgent,
type QueryAgentAsk,
type QueryAgentCompactionEvent,
type QueryAgentEventListeners,
type QueryAgentEventListener,
type QueryAgentEventMap,
type QueryAgentStreamEvent,
} from "./query-agent.ts"
/** Storage operations used to persist and replay query-agent conversation entries. */
export interface ConversationStorage {
getOrCreateConversation(): Promise<{ id: string }>
appendEntry(
conversationId: string,
input: AppendConversationEntryInput,
): Promise<ConversationStorageEntry>
listEntries(conversationId: string): Promise<ConversationStorageEntry[]>
}
/** Minimal persisted entry shape needed by recording and replay agents. */
export type ConversationStorageEntry = Pick<
ConversationEntryRow,
"id" | "sequence" | "kind" | "payload" | "metadata" | "createdAt"
>
/** Configuration for wrapping a QueryAgent with conversation recording. */
export interface ConversationRecordingQueryAgentConfig {
agent: QueryAgent
storage: ConversationStorage
defaultConversationId?: string
route?: string
modelProvider: string
modelId: string
}
const DefaultRoute = "agent_query"
export class ConversationRecordingQueryAgent implements QueryAgent {
private readonly agent: QueryAgent
private readonly storage: ConversationStorage
private readonly defaultConversationId: string | undefined
private readonly route: string
private readonly modelProvider: string
private readonly modelId: string
private readonly eventListeners = createQueryAgentEventListeners()
private readonly removeAgentCompactionListener: () => void
constructor(config: ConversationRecordingQueryAgentConfig) {
this.agent = config.agent
this.storage = config.storage
this.defaultConversationId = config.defaultConversationId
this.route = config.route ?? DefaultRoute
this.modelProvider = config.modelProvider
this.modelId = config.modelId
this.removeAgentCompactionListener = this.agent.addEventListener(
QueryAgentEvent.Compaction,
async (event) => {
await this.appendCompactionSummary(event)
await this.emitEvent(event)
},
)
}
async *ask(input: QueryAgentAsk): AsyncIterable<QueryAgentStreamEvent> {
if (
this.defaultConversationId &&
input.conversationId &&
input.conversationId !== this.defaultConversationId
) {
yield {
type: "error",
message: "Conversation switching is not supported for this session",
}
return
}
const conversationId =
input.conversationId ??
this.defaultConversationId ??
(await this.storage.getOrCreateConversation()).id
const runId = randomUUID()
const userEntry = await this.storage.appendEntry(conversationId, {
kind: ConversationEntryKind.UserMessage,
payload: {
role: "user",
parts: [{ type: "text", text: input.message }],
},
metadata: { runId },
})
yield { type: "conversation", conversationId }
const assistantText: string[] = []
for await (const event of this.agent.ask({
...input,
conversationId,
userMessageEntry: {
id: userEntry.id,
sequence: userEntry.sequence,
},
})) {
switch (event.type) {
case "conversation":
break
case "text_delta":
assistantText.push(event.text)
yield event
break
case "tool_start":
await this.storage.appendEntry(conversationId, {
kind: ConversationEntryKind.ToolCall,
payload: {
toolName: event.toolName,
runId,
},
metadata: { runId },
})
yield event
break
case "tool_end":
await this.storage.appendEntry(conversationId, {
kind: ConversationEntryKind.ToolResult,
payload: {
toolName: event.toolName,
ok: event.ok,
runId,
},
metadata: { runId },
})
yield event
break
case "error":
await this.storage.appendEntry(conversationId, {
kind: ConversationEntryKind.SystemNote,
payload: {
type: "agent_error",
message: event.message,
runId,
},
metadata: { runId },
})
yield event
return
case "done":
await this.appendAssistantMessage(conversationId, assistantText, runId)
yield event
return
}
}
await this.appendAssistantMessage(conversationId, assistantText, runId)
}
dispose(): void {
this.removeAgentCompactionListener()
this.clearEventListeners()
this.agent.dispose()
}
addEventListener<T extends QueryAgentEvent>(
type: T,
listener: QueryAgentEventListener<T>,
): () => void {
const listeners = this.listenersFor(type)
listeners.add(listener)
return () => {
listeners.delete(listener)
}
}
private async appendAssistantMessage(
conversationId: string,
assistantText: string[],
runId: string,
): Promise<void> {
const text = assistantText.join("")
if (text.length === 0) return
await this.storage.appendEntry(conversationId, {
kind: ConversationEntryKind.AssistantMessage,
payload: {
role: "assistant",
parts: [{ type: "text", text }],
},
metadata: this.modelRunMetadata(runId),
})
}
private modelRunMetadata(runId: string): ConversationEntryMetadata {
const metadata: ConversationEntryMetadata = { runId }
metadata.modelRun = {
route: this.route,
provider: this.modelProvider,
model: this.modelId,
}
return metadata
}
private async appendCompactionSummary(event: QueryAgentCompactionEvent): Promise<void> {
if (event.compactedEntryRange === null) return
await this.storage.appendEntry(event.conversationId, {
kind: ConversationEntryKind.ContextSummary,
payload: {
covers: event.compactedEntryRange,
summary: {
durableFacts: [],
preferences: [],
decisions: [],
openTasks: [],
importantDetails: [event.summary],
},
promptVersion: "pi-sdk-compaction-v1",
},
metadata: {
piCompaction: {
firstKeptEntryId: event.firstKeptEntryId,
tokensBefore: event.tokensBefore,
fromExtension: event.fromExtension,
details: event.details,
},
},
})
}
private async emitEvent<T extends QueryAgentEvent>(event: QueryAgentEventMap[T]): Promise<void> {
const listeners = this.listenersFor(event.type)
for (const listener of listeners) {
await listener(event)
}
}
private listenersFor<T extends QueryAgentEvent>(type: T): QueryAgentEventListeners[T] {
return this.eventListeners[type]
}
private clearEventListeners(): void {
for (const listeners of Object.values(this.eventListeners)) {
listeners.clear()
}
}
}

View File

@@ -0,0 +1,173 @@
import { Context, contextKey, type ActionDefinition, type FeedItem } from "@freya/core"
import { describe, expect, test } from "bun:test"
import type { UserSessionManager } from "../session/index.ts"
import { createQueryDebugTools } from "./debug-tools.ts"
const TestTime = new Date("2026-06-14T12:00:00.000Z")
describe("query debug tools", () => {
test("lists enabled source summaries", async () => {
const tools = createTestDebugTools()
const result = await tools.execute("user-1", "freya_list_sources", {})
const sources = expectArray(expectRecord(result).sources).map(expectRecord)
const location = sources.find((source) => source.sourceId === "freya.location")
const reminders = sources.find((source) => source.sourceId === "freya.reminders")
const weather = sources.find((source) => source.sourceId === "freya.weather")
expect(location?.hasContext).toBe(true)
expect(location?.contextEntryCount).toBe(1)
expect(reminders?.hasFeedItems).toBe(true)
expect(reminders?.feedItemCount).toBe(1)
expect(weather?.errors).toEqual([{ sourceId: "freya.weather", message: "weather unavailable" }])
})
test("gets context by exact key", async () => {
const tools = createTestDebugTools()
const result = await tools.execute("user-1", "freya_get_context", {
key: ["freya.location", "location"],
match: "exact",
})
const record = expectRecord(result)
expect(record.found).toBe(true)
expect(record.value).toEqual({ latitude: 51.5, longitude: -0.1 })
})
test("gets one feed item with source details", async () => {
const tools = createTestDebugTools()
const result = await tools.execute("user-1", "freya_get_feed_item", {
feedItemId: "reminder-1",
})
const record = expectRecord(result)
const item = expectRecord(record.item)
const source = expectRecord(record.source)
expect(record.found).toBe(true)
expect(item.id).toBe("reminder-1")
expect(source.sourceId).toBe("freya.reminders")
expect(source.actions).toEqual([
{
id: "create-reminder",
description: "Create a reminder",
},
])
})
test("executes source action directly", async () => {
const tools = createTestDebugTools()
const params = { title: "Buy tea" }
const result = await tools.execute("user-1", "freya_execute_action", {
sourceId: "freya.reminders",
actionId: "create-reminder",
params,
})
expect(result).toEqual({
ok: true,
sourceId: "freya.reminders",
actionId: "create-reminder",
result: {
sourceId: "freya.reminders",
actionId: "create-reminder",
params,
},
})
})
})
function createTestDebugTools() {
const context = new Context(TestTime)
context.set([
[
contextKey("freya.location", "location"),
{
latitude: 51.5,
longitude: -0.1,
},
],
])
const item: FeedItem = {
id: "reminder-1",
sourceId: "freya.reminders",
type: "reminder",
timestamp: TestTime,
data: { title: "Buy milk" },
}
const actions: Record<string, Record<string, ActionDefinition>> = {
"freya.location": {
"update-location": {
id: "update-location",
description: "Update location",
},
},
"freya.reminders": {
"create-reminder": {
id: "create-reminder",
description: "Create a reminder",
},
},
}
const session = {
async feed() {
return {
context,
items: [item],
errors: [{ sourceId: "freya.weather", error: new Error("weather unavailable") }],
}
},
engine: {
currentContext() {
return context
},
async listActions(sourceId: string) {
return actions[sourceId] ?? {}
},
async executeAction(sourceId: string, actionId: string, params: unknown) {
const sourceActions = actions[sourceId]
if (!sourceActions) {
throw new Error(`Source not found: ${sourceId}`)
}
if (!(actionId in sourceActions)) {
throw new Error(`Action "${actionId}" not found on source "${sourceId}"`)
}
return { sourceId, actionId, params }
},
},
hasSource(sourceId: string) {
return sourceId in actions
},
async listActions() {
return Object.entries(actions).map(([sourceId, sourceActions]) => ({
sourceId,
actions: sourceActions,
}))
},
}
return createQueryDebugTools({
async getOrCreate() {
return session
},
} as unknown as UserSessionManager)
}
function expectRecord(value: unknown): Record<string, unknown> {
expect(typeof value).toBe("object")
expect(value).not.toBeNull()
expect(Array.isArray(value)).toBe(false)
return value as Record<string, unknown>
}
function expectArray(value: unknown): unknown[] {
expect(Array.isArray(value)).toBe(true)
return value as unknown[]
}

View File

@@ -0,0 +1,420 @@
import { contextKey, type ContextKeyPart } from "@freya/core"
import type { UserSessionManager } from "../session/index.ts"
type ToolParams = Record<string, unknown>
export interface QueryDebugToolDefinition {
name: string
label: string
description: string
parameters: unknown
}
export interface QueryDebugTools {
list(): QueryDebugToolDefinition[]
execute(userId: string, toolName: string, params: unknown): Promise<unknown>
}
const FreyaQueryContextTool = "freya_query_context"
const FreyaListSourcesTool = "freya_list_sources"
const FreyaGetContextTool = "freya_get_context"
const FreyaListContextTool = "freya_list_context"
const FreyaGetSourceDataTool = "freya_get_source_data"
const FreyaGetFeedItemTool = "freya_get_feed_item"
const FreyaExecuteActionTool = "freya_execute_action"
export function createQueryDebugTools(sessionManager: UserSessionManager): QueryDebugTools {
return new DefaultQueryDebugTools(sessionManager)
}
class DefaultQueryDebugTools implements QueryDebugTools {
constructor(private readonly sessionManager: UserSessionManager) {}
list(): QueryDebugToolDefinition[] {
return [
{
name: FreyaListSourcesTool,
label: "List FREYA Sources",
description:
"List enabled source IDs and summarize available feed items, context entries, actions, and errors.",
parameters: {},
},
{
name: FreyaGetContextTool,
label: "Get FREYA Context",
description: "Read specific FREYA context entries by key with exact or prefix matching.",
parameters: {
key: "ContextKeyPart[]",
match: '"exact" | "prefix"?',
},
},
{
name: FreyaGetFeedItemTool,
label: "Get FREYA Feed Item",
description:
"Read one feed item by ID, including related source context, actions, and errors.",
parameters: {
feedItemId: "string",
},
},
{
name: FreyaQueryContextTool,
label: "Query FREYA Context",
description:
"Read the user's current FREYA feed, source graph context, source errors, and available actions.",
parameters: {
question: "string",
feedItemId: "string?",
},
},
{
name: FreyaListContextTool,
label: "List FREYA Context",
description: "List all current FREYA context graph entries for the user.",
parameters: {},
},
{
name: FreyaGetSourceDataTool,
label: "Get FREYA Source Data",
description:
"Get current feed items, context entries, actions, and errors for a specific FREYA source ID.",
parameters: {
sourceId: "string",
feedItemId: "string?",
},
},
{
name: FreyaExecuteActionTool,
label: "Execute FREYA Action",
description: "Execute an available source action immediately.",
parameters: {
sourceId: "string",
actionId: "string",
params: "unknown?",
},
},
]
}
async execute(userId: string, toolName: string, params: unknown): Promise<unknown> {
switch (toolName) {
case FreyaListSourcesTool:
return this.listSources(userId)
case FreyaGetContextTool:
return this.getContext(userId, expectToolParams(params, ["key"]))
case FreyaGetFeedItemTool:
return this.getFeedItem(userId, expectToolParams(params, ["feedItemId"]))
case FreyaQueryContextTool:
return this.queryContext(userId, expectToolParams(params, ["question"]))
case FreyaListContextTool:
return this.listContext(userId)
case FreyaGetSourceDataTool:
return this.getSourceData(userId, expectToolParams(params, ["sourceId"]))
case FreyaExecuteActionTool:
return this.executeAction(userId, expectToolParams(params, ["sourceId", "actionId"]))
default:
throw new Error(`Unknown debug tool: ${toolName}`)
}
}
private async listSources(userId: string): Promise<unknown> {
const userSession = await this.sessionManager.getOrCreate(userId)
const feed = await userSession.feed()
const context = userSession.engine.currentContext()
const contextEntries = context.entries()
const actions = await userSession.listActions()
const feedCounts = countBy(feed.items.map((item) => item.sourceId))
const contextCounts = countBy(
contextEntries
.map((entry) => entry.key[0])
.filter((part): part is string => typeof part === "string"),
)
const errors = groupErrorsBySource(
feed.errors.map((error) => ({
sourceId: error.sourceId,
message: error.error.message,
})),
)
const actionEntries = new Map(actions.map((entry) => [entry.sourceId, entry.actions]))
const sourceIds = new Set<string>([
...actionEntries.keys(),
...feedCounts.keys(),
...contextCounts.keys(),
...errors.keys(),
])
return {
time: context.time.toISOString(),
sources: [...sourceIds].sort().map((sourceId) => {
const sourceActions = actionEntries.get(sourceId) ?? {}
const feedItemCount = feedCounts.get(sourceId) ?? 0
const contextEntryCount = contextCounts.get(sourceId) ?? 0
return {
sourceId,
hasFeedItems: feedItemCount > 0,
feedItemCount,
hasContext: contextEntryCount > 0,
contextEntryCount,
actions: Object.values(sourceActions).map((action) => ({
id: action.id,
description: action.description ?? null,
})),
errors: errors.get(sourceId) ?? [],
}
}),
}
}
private async getContext(userId: string, params: ToolParams): Promise<unknown> {
const key = expectContextKey(params, "key")
const match = optionalMatch(params, "match") ?? "prefix"
const userSession = await this.sessionManager.getOrCreate(userId)
await userSession.feed()
const context = userSession.engine.currentContext()
const keyObject = contextKey(...key)
if (match === "exact") {
const value = context.get(keyObject)
return {
time: context.time.toISOString(),
match,
key,
found: value !== undefined,
value: value ?? null,
}
}
const entries = context.find(keyObject)
return {
time: context.time.toISOString(),
match,
key,
count: entries.length,
entries,
}
}
private async getFeedItem(userId: string, params: ToolParams): Promise<unknown> {
const feedItemId = expectString(params, "feedItemId")
const userSession = await this.sessionManager.getOrCreate(userId)
const feed = await userSession.feed()
const context = userSession.engine.currentContext()
const item = feed.items.find((candidate) => candidate.id === feedItemId)
if (!item) {
return {
time: context.time.toISOString(),
feedItemId,
found: false,
item: null,
}
}
const sourceActions = userSession.hasSource(item.sourceId)
? await userSession.engine.listActions(item.sourceId)
: {}
const errors = feed.errors
.filter((error) => error.sourceId === item.sourceId)
.map((error) => ({
sourceId: error.sourceId,
message: error.error.message,
}))
return {
time: context.time.toISOString(),
feedItemId,
found: true,
item,
source: {
sourceId: item.sourceId,
hasSource: userSession.hasSource(item.sourceId),
context: context.entries().filter((entry) => entry.key[0] === item.sourceId),
actions: Object.values(sourceActions).map((action) => ({
id: action.id,
description: action.description ?? null,
})),
errors,
},
}
}
private async queryContext(userId: string, params: ToolParams): Promise<unknown> {
const question = expectString(params, "question")
const feedItemId = optionalString(params, "feedItemId")
const userSession = await this.sessionManager.getOrCreate(userId)
const feed = await userSession.feed()
const context = userSession.engine.currentContext()
const selectedItem = feedItemId ? feed.items.find((item) => item.id === feedItemId) : undefined
const actions = await userSession.listActions()
return {
time: context.time.toISOString(),
question,
feedItemId: feedItemId ?? null,
selectedItem: selectedItem ?? null,
items: feed.items,
context: context.entries(),
availableActions: actions.map((entry) => ({
sourceId: entry.sourceId,
actions: Object.values(entry.actions).map((action) => ({
id: action.id,
description: action.description ?? null,
})),
})),
errors: feed.errors.map((error) => ({
sourceId: error.sourceId,
message: error.error.message,
})),
}
}
private async listContext(userId: string): Promise<unknown> {
const userSession = await this.sessionManager.getOrCreate(userId)
await userSession.feed()
const context = userSession.engine.currentContext()
const entries = context.entries()
return {
time: context.time.toISOString(),
count: entries.length,
entries,
}
}
private async getSourceData(userId: string, params: ToolParams): Promise<unknown> {
const sourceId = expectString(params, "sourceId")
const feedItemId = optionalString(params, "feedItemId")
const userSession = await this.sessionManager.getOrCreate(userId)
const feed = await userSession.feed()
const context = userSession.engine.currentContext()
const sourceActions = userSession.hasSource(sourceId)
? await userSession.engine.listActions(sourceId)
: {}
const items = feed.items.filter((item) => item.sourceId === sourceId)
const selectedItem = feedItemId ? items.find((item) => item.id === feedItemId) : undefined
const contextEntries = context.entries().filter((entry) => entry.key[0] === sourceId)
const errors = feed.errors
.filter((error) => error.sourceId === sourceId)
.map((error) => ({
sourceId: error.sourceId,
message: error.error.message,
}))
return {
time: context.time.toISOString(),
sourceId,
hasSource: userSession.hasSource(sourceId),
feedItemId: feedItemId ?? null,
selectedItem: selectedItem ?? null,
items,
context: contextEntries,
actions: Object.values(sourceActions).map((action) => ({
id: action.id,
description: action.description ?? null,
})),
errors,
}
}
private async executeAction(userId: string, params: ToolParams): Promise<unknown> {
const sourceId = expectString(params, "sourceId")
const actionId = expectString(params, "actionId")
const actionParams = "params" in params ? params.params : undefined
const userSession = await this.sessionManager.getOrCreate(userId)
const result = await userSession.engine.executeAction(sourceId, actionId, actionParams)
return {
ok: true,
sourceId,
actionId,
result: result ?? null,
}
}
}
function expectToolParams(value: unknown, requiredKeys: string[]): ToolParams {
if (!isRecord(value)) {
throw new Error("Tool params must be a JSON object")
}
for (const key of requiredKeys) {
if (!(key in value)) {
throw new Error(`Missing required param: ${key}`)
}
}
return value
}
function expectString(params: ToolParams, key: string): string {
const value = params[key]
if (typeof value !== "string" || value.length === 0) {
throw new Error(`Param "${key}" must be a non-empty string`)
}
return value
}
function optionalString(params: ToolParams, key: string): string | undefined {
const value = params[key]
if (value === undefined) return undefined
if (typeof value !== "string") {
throw new Error(`Param "${key}" must be a string`)
}
return value
}
function expectContextKey(params: ToolParams, key: string): ContextKeyPart[] {
const value = params[key]
if (!Array.isArray(value) || value.length === 0) {
throw new Error(`Param "${key}" must be a non-empty array`)
}
if (!value.every(isContextKeyPart)) {
throw new Error(`Param "${key}" contains an invalid context key part`)
}
return value
}
function optionalMatch(params: ToolParams, key: string): "exact" | "prefix" | undefined {
const value = params[key]
if (value === undefined) return undefined
if (value !== "exact" && value !== "prefix") {
throw new Error(`Param "${key}" must be "exact" or "prefix"`)
}
return value
}
function isContextKeyPart(value: unknown): value is ContextKeyPart {
if (typeof value === "string" || typeof value === "number") return true
if (!isRecord(value)) return false
return Object.values(value).every(
(part) => typeof part === "string" || typeof part === "number" || typeof part === "boolean",
)
}
function countBy(values: string[]): Map<string, number> {
const result = new Map<string, number>()
for (const value of values) {
result.set(value, (result.get(value) ?? 0) + 1)
}
return result
}
function groupErrorsBySource(
errors: Array<{ sourceId: string; message: string }>,
): Map<string, Array<{ sourceId: string; message: string }>> {
const result = new Map<string, Array<{ sourceId: string; message: string }>>()
for (const error of errors) {
const group = result.get(error.sourceId) ?? []
group.push(error)
result.set(error.sourceId, group)
}
return result
}
function isRecord(value: unknown): value is ToolParams {
return typeof value === "object" && value !== null && !Array.isArray(value)
}

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