Compare commits

..

1 Commits

Author SHA1 Message Date
ad24265892 feat: add TfL source config to admin dashboard
Co-authored-by: Ona <no-reply@ona.com>
2026-03-29 14:32:42 +00:00
404 changed files with 7417 additions and 25167 deletions

View File

@@ -1,134 +0,0 @@
---
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

@@ -1,4 +0,0 @@
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

@@ -1,132 +0,0 @@
# 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

@@ -1,160 +0,0 @@
# 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

@@ -1,124 +0,0 @@
# 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

@@ -1,79 +0,0 @@
# 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

@@ -1,79 +0,0 @@
# 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

@@ -1,59 +0,0 @@
# 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

@@ -1,61 +0,0 @@
# 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

@@ -0,0 +1,43 @@
---
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: freya-waitlist-website IMAGE_NAME: aelis-waitlist-website
jobs: jobs:
build: build:

39
.ona/automations.yaml Normal file
View File

@@ -0,0 +1,39 @@
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

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

View File

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

View File

@@ -1,20 +0,0 @@
// 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
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). 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).
## Commands ## Commands
@@ -39,13 +39,4 @@ 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 @@
# freya # aelis
To install dependencies: To install dependencies:
@@ -8,14 +8,14 @@ bun install
## Packages ## Packages
### @freya/source-tfl ### @aelis/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/freya-source-tfl cd packages/aelis-source-tfl
bun run test bun run test
``` ```

View File

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

View File

@@ -0,0 +1,11 @@
{
"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

@@ -0,0 +1,23 @@
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,40 +1,48 @@
{ {
"name": "admin-dashboard", "name": "admin-dashboard",
"version": "0.0.1", "private": true,
"private": true, "version": "0.0.1",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "tsc -b && vite build", "build": "tsc -b && vite build",
"lint": "oxlint .", "lint": "eslint .",
"format": "oxfmt --write .", "format": "prettier --write \"**/*.{ts,tsx}\"",
"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.3", "react": "^19.2.0",
"react-dom": "19.2.3", "react-dom": "^19.2.0",
"shadcn": "^4.0.8", "shadcn": "^4.0.8",
"sonner": "^2.0.7", "sonner": "^2.0.7",
"tailwind-merge": "^3.5.0", "tailwind-merge": "^3.5.0",
"tailwindcss": "^4.1.17", "tailwindcss": "^4.1.17",
"tw-animate-css": "^1.4.0" "tw-animate-css": "^1.4.0"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^24.10.1", "@eslint/js": "^9.39.1",
"@types/react": "^19.2.5", "@types/node": "^24.10.1",
"@types/react-dom": "^19.2.3", "@types/react": "^19.2.5",
"@vitejs/plugin-react": "^5.1.1", "@types/react-dom": "^19.2.3",
"typescript": "^6", "@vitejs/plugin-react": "^5.1.1",
"vite": "^7.2.4" "eslint": "^9.39.1",
} "eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"prettier": "^3.8.1",
"prettier-plugin-tailwindcss": "^0.7.2",
"typescript": "~5.9.3",
"typescript-eslint": "^8.46.4",
"vite": "^7.2.4"
}
} }

View File

@@ -1,25 +1,25 @@
import { useQueryClient, type QueryClient } from "@tanstack/react-query"
import { createRouter, RouterProvider } from "@tanstack/react-router" import { createRouter, RouterProvider } from "@tanstack/react-router"
import { useQueryClient, type QueryClient } from "@tanstack/react-query"
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,144 +1,146 @@
import { useQuery } from "@tanstack/react-query" import { useQuery } from "@tanstack/react-query"
import { Loader2, RefreshCw, TriangleAlert } from "lucide-react"
import { useState } from "react" import { useState } from "react"
import { Loader2, RefreshCw, TriangleAlert } from "lucide-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">Query the feed as the current user.</p> <p className="text-sm text-muted-foreground">
</div> Query the feed as the current user.
<Button onClick={() => refetch()} disabled={isFetching} size="sm"> </p>
{isFetching ? ( </div>
<Loader2 className="size-3.5 animate-spin" /> <Button onClick={() => refetch()} disabled={isFetching} size="sm">
) : ( {isFetching ? (
<RefreshCw className="size-3.5" /> <Loader2 className="size-3.5 animate-spin" />
)} ) : (
{feed ? "Refresh" : "Fetch"} <RefreshCw className="size-3.5" />
</Button> )}
</div> {feed ? "Refresh" : "Fetch"}
</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,70 +1,75 @@
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">Backend server information.</p> <p className="text-sm text-muted-foreground">
</div> Backend server information.
</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>Connected backend instance.</CardDescription> <CardDescription>
</CardHeader> Connected backend instance.
<CardContent> </CardDescription>
<div className="space-y-3 text-sm"> </CardHeader>
<div className="flex items-center justify-between gap-4"> <CardContent>
<span className="shrink-0 text-muted-foreground">URL</span> <div className="space-y-3 text-sm">
<span className="select-text truncate font-mono text-xs">{serverUrl}</span> <div className="flex items-center justify-between gap-4">
</div> <span className="shrink-0 text-muted-foreground">URL</span>
<div className="flex items-center justify-between"> <span className="select-text truncate font-mono text-xs">{serverUrl}</span>
<span className="text-muted-foreground">Status</span> </div>
{status === "checking" && ( <div className="flex items-center justify-between">
<span className="flex items-center gap-1.5 text-xs text-muted-foreground"> <span className="text-muted-foreground">Status</span>
<Loader2 className="size-3 animate-spin" /> {status === "checking" && (
Checking <span className="flex items-center gap-1.5 text-xs text-muted-foreground">
</span> <Loader2 className="size-3 animate-spin" />
)} Checking
{status === "ok" && ( </span>
<span className="flex items-center gap-1.5 text-xs text-muted-foreground"> )}
<CircleCheck className="size-3.5 text-primary" /> {status === "ok" && (
Connected <span className="flex items-center gap-1.5 text-xs text-muted-foreground">
</span> <CircleCheck className="size-3.5 text-primary" />
)} Connected
{status === "error" && ( </span>
<span className="flex items-center gap-1.5 text-xs text-destructive"> )}
<CircleX className="size-3.5" /> {status === "error" && (
{errorMsg ?? "Unreachable"} <span className="flex items-center gap-1.5 text-xs text-destructive">
</span> <CircleX className="size-3.5" />
)} {errorMsg ?? "Unreachable"}
</div> </span>
</div> )}
</CardContent> </div>
</Card> </div>
</div> </CardContent>
) </Card>
</div>
)
} }

View File

@@ -1,98 +1,100 @@
import { useMutation } from "@tanstack/react-query" import { useMutation } from "@tanstack/react-query"
import { Loader2, Settings2 } from "lucide-react"
import { useState } from "react" import { useState } from "react"
import { Loader2, Settings2 } from "lucide-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@freya.local" placeholder="admin@aelis.local"
required required
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="password">Password</Label> <Label htmlFor="password">Password</Label>
<Input <Input
id="password" id="password"
type="password" type="password"
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
required required
/> />
</div> </div>
<Button type="submit" className="w-full" disabled={loading}>
{loading && <Loader2 className="size-4 animate-spin" />} <Button type="submit" className="w-full" disabled={loading}>
{loading ? "Signing in…" : "Sign in"} {loading && <Loader2 className="size-4 animate-spin" />}
</Button> {loading ? "Signing in…" : "Sign in"}
</form> </Button>
</CardContent>
</Card> </form>
</div> </CardContent>
) </Card>
</div>
)
} }

View File

@@ -1,639 +0,0 @@
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,530 +1,500 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { Info, Loader2, MapPin, Trash2 } from "lucide-react"
import { useState } from "react" import { useState } from "react"
import { Info, Loader2, MapPin, Trash2 } from "lucide-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 hasUserConfigFields(): boolean { function invalidate() {
return Object.values(source.fields).some((field) => !isCredentialField(field)) queryClient.invalidateQueries({ queryKey: ["sourceConfig", source.id] })
} queryClient.invalidateQueries({ queryKey: ["configs"] })
onUpdate()
}
function buildReplaceBody(enabledValue: boolean): Parameters<typeof replaceSource>[1] { const saveMutation = useMutation({
const body: Parameters<typeof replaceSource>[1] = { enabled: enabledValue } mutationFn: async () => {
const promises: Promise<void>[] = [
replaceSource(source.id, { enabled, config: getUserConfig() }),
]
if (hasUserConfigFields()) { const credentialFields = getCredentialFields()
body.config = getUserConfig() const hasCredentials = Object.values(credentialFields).some(
} (v) => typeof v === "string" && v.length > 0,
)
if (hasCredentials) {
promises.push(
updateProviderConfig(source.id, { credentials: credentialFields }),
)
}
return body await Promise.all(promises)
} },
onSuccess() {
setDirty({})
invalidate()
toast.success("Configuration saved")
},
onError(err) {
toast.error(err.message)
},
})
function invalidate() { const toggleMutation = useMutation({
queryClient.invalidateQueries({ queryKey: ["sourceConfig", source.id] }) mutationFn: (checked: boolean) =>
queryClient.invalidateQueries({ queryKey: ["configs"] }) replaceSource(source.id, { enabled: checked, config: getUserConfig() }),
onUpdate() onSuccess(_data, checked) {
} invalidate()
toast.success(`Source ${checked ? "enabled" : "disabled"}`)
},
onError(err) {
toast.error(err.message)
},
})
const saveMutation = useMutation({ const deleteMutation = useMutation({
mutationFn: async () => { mutationFn: () => replaceSource(source.id, { enabled: false, config: {} }),
const credentialFields = getCredentialFields() onSuccess() {
const hasCredentials = Object.values(credentialFields).some( setDirty({})
(v) => typeof v === "string" && v.length > 0, invalidate()
) toast.success("Configuration deleted")
},
onError(err) {
toast.error(err.message)
},
})
const body = buildReplaceBody(enabled) function handleFieldChange(fieldName: string, value: unknown) {
if (hasCredentials && source.perUserCredentials) { setDirty((prev) => ({ ...prev, [fieldName]: value }))
body.credentials = credentialFields }
}
await replaceSource(source.id, body)
// For non-per-user credentials (provider-level), still use the admin endpoint. const fieldEntries = Object.entries(source.fields)
if (hasCredentials && !source.perUserCredentials) { const hasFields = fieldEntries.length > 0
await updateProviderConfig(source.id, { credentials: credentialFields }) const busy = saveMutation.isPending || toggleMutation.isPending || deleteMutation.isPending
}
},
onSuccess() {
setDirty({})
invalidate()
toast.success("Configuration saved")
},
onError(err) {
toast.error(err.message)
},
})
const toggleMutation = useMutation({ const requiredFields = fieldEntries.filter(([, f]) => f.required)
mutationFn: (checked: boolean) => replaceSource(source.id, buildReplaceBody(checked)), const optionalFields = fieldEntries.filter(([, f]) => !f.required)
onSuccess(_data, checked) {
invalidate()
toast.success(`Source ${checked ? "enabled" : "disabled"}`)
},
onError(err) {
toast.error(err.message)
},
})
const deleteMutation = useMutation({ if (isLoading) {
mutationFn: () => replaceSource(source.id, buildReplaceBody(false)), return (
onSuccess() { <div className="flex items-center justify-center py-12">
setDirty({}) <Loader2 className="size-5 animate-spin text-muted-foreground" />
invalidate() </div>
toast.success("Configuration deleted") )
}, }
onError(err) {
toast.error(err.message)
},
})
function handleFieldChange(fieldName: string, value: unknown) { return (
setDirty((prev) => ({ ...prev, [fieldName]: value })) <div className="mx-auto max-w-xl space-y-6">
} {/* Header */}
<div className="flex items-center justify-between gap-4">
<div className="space-y-1">
<div className="flex items-center gap-3">
<h2 className="text-lg font-semibold tracking-tight">{source.name}</h2>
{source.alwaysEnabled ? (
<Badge variant="secondary">Always on</Badge>
) : enabled ? (
<Badge className="bg-primary/10 text-primary">Enabled</Badge>
) : (
<Badge variant="outline">Disabled</Badge>
)}
const fieldEntries = Object.entries(source.fields) </div>
const hasFields = fieldEntries.length > 0 <p className="text-sm text-muted-foreground">{source.description}</p>
const busy = saveMutation.isPending || toggleMutation.isPending || deleteMutation.isPending </div>
{!source.alwaysEnabled && (
<Switch
checked={enabled}
onCheckedChange={(checked) => toggleMutation.mutate(checked)}
disabled={busy}
/>
)}
</div>
const requiredFields = fieldEntries.filter(([, f]) => f.required) {/* Config form */}
const optionalFields = fieldEntries.filter(([, f]) => !f.required) {hasFields && !source.alwaysEnabled && (
<>
{/* Required fields */}
{requiredFields.length > 0 && (
<Card className="-mx-4">
<CardHeader className="pb-4">
<CardTitle className="text-sm">Credentials</CardTitle>
<CardDescription>Required fields to connect this source.</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{requiredFields.map(([name, field]) => (
<FieldInput
key={name}
name={name}
field={field}
value={formValues[name]}
onChange={(v) => handleFieldChange(name, v)}
disabled={busy}
/>
))}
</CardContent>
</Card>
)}
if (isLoading) { {/* Optional fields */}
return ( {optionalFields.length > 0 && (
<div className="flex items-center justify-center py-12"> <Card className="-mx-4">
<Loader2 className="size-5 animate-spin text-muted-foreground" /> <CardHeader className="pb-4">
</div> <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>
)}
return ( {/* Actions */}
<div className="mx-auto max-w-xl space-y-6"> <div className="flex items-center justify-end gap-3">
{/* Header */} {serverConfig && (
<div className="flex items-center justify-between gap-4"> <Button
<div className="space-y-1"> onClick={() => deleteMutation.mutate()}
<div className="flex items-center gap-3"> disabled={busy}
<h2 className="text-lg font-semibold tracking-tight">{source.name}</h2> variant="outline"
{source.alwaysEnabled ? ( className="text-destructive hover:text-destructive"
<Badge variant="secondary">Always on</Badge> >
) : enabled ? ( {deleteMutation.isPending ? (
<Badge className="bg-primary/10 text-primary">Enabled</Badge> <Loader2 className="size-4 animate-spin" />
) : ( ) : (
<Badge variant="outline">Disabled</Badge> <Trash2 className="size-4" />
)} )}
</div> {deleteMutation.isPending ? "Deleting…" : "Delete configuration"}
<p className="text-sm text-muted-foreground">{source.description}</p> </Button>
</div> )}
{!source.alwaysEnabled && ( <Button onClick={() => saveMutation.mutate()} disabled={busy}>
<Switch {saveMutation.isPending && <Loader2 className="size-4 animate-spin" />}
checked={enabled} {saveMutation.isPending ? "Saving…" : "Save configuration"}
onCheckedChange={(checked) => toggleMutation.mutate(checked)} </Button>
disabled={busy} </div>
/> </>
)} )}
</div>
{/* Config form */} {/* Always-on sources */}
{hasFields && !source.alwaysEnabled && ( {source.alwaysEnabled && source.id !== "aelis.location" && (
<> <>
{/* Required fields */} <Separator />
{requiredFields.length > 0 && ( <p className="text-sm text-muted-foreground">
<Card className="-mx-4"> This source is always enabled and requires no configuration.
<CardHeader className="pb-4"> </p>
<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 */} {source.id === "aelis.location" && <LocationCard />}
{optionalFields.length > 0 && ( </div>
<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"> <Label htmlFor="loc-lat" className="text-xs font-medium">Latitude</Label>
Latitude <Input
</Label> id="loc-lat"
<Input type="number"
id="loc-lat" step="any"
type="number" value={lat}
step="any" onChange={(e) => setLat(e.target.value)}
value={lat} placeholder="51.5074"
onChange={(e) => setLat(e.target.value)} disabled={locationMutation.isPending}
placeholder="51.5074" />
disabled={locationMutation.isPending} </div>
/> <div className="space-y-2">
</div> <Label htmlFor="loc-lng" className="text-xs font-medium">Longitude</Label>
<div className="space-y-2"> <Input
<Label htmlFor="loc-lng" className="text-xs font-medium"> id="loc-lng"
Longitude type="number"
</Label> step="any"
<Input value={lng}
id="loc-lng" onChange={(e) => setLng(e.target.value)}
type="number" placeholder="-0.1278"
step="any" disabled={locationMutation.isPending}
value={lng} />
onChange={(e) => setLng(e.target.value)} </div>
placeholder="-0.1278" </div>
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 === "multiselect" && field.options) { if (field.type === "multiselect" && field.options) {
const selected = Array.isArray(value) ? (value as string[]) : [] const selected = Array.isArray(value) ? (value as string[]) : []
function toggle(optValue: string) { function toggle(optValue: string) {
const next = selected.includes(optValue) const next = selected.includes(optValue)
? selected.filter((v) => v !== optValue) ? selected.filter((v) => v !== optValue)
: [...selected, optValue] : [...selected, optValue]
onChange(next) onChange(next)
} }
return ( return (
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-xs font-medium">{labelContent}</Label> <Label className="text-xs font-medium">
<div className="flex flex-wrap gap-1.5"> {labelContent}
{field.options!.map((opt) => { </Label>
const isSelected = selected.includes(opt.value) <div className="flex flex-wrap gap-1.5">
return ( {field.options!.map((opt) => {
<Badge const isSelected = selected.includes(opt.value)
key={opt.value} return (
variant={isSelected ? "default" : "outline"} <Badge
className={`cursor-pointer select-none ${isSelected ? "" : "opacity-60 hover:opacity-100"}`} key={opt.value}
onClick={() => !disabled && toggle(opt.value)} variant={isSelected ? "default" : "outline"}
> className={`cursor-pointer select-none ${isSelected ? "" : "opacity-60 hover:opacity-100"}`}
{opt.label} onClick={() => !disabled && toggle(opt.value)}
</Badge> >
) {opt.label}
})} </Badge>
</div> )
</div> })}
) </div>
} </div>
)
}
if (field.type === "number") { if (field.type === "number") {
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>
<Input <Input
id={name} id={name}
type="number" type="number"
value={value === undefined || value === null ? "" : String(value)} value={value === undefined || value === null ? "" : String(value)}
onChange={(e) => { onChange={(e) => {
const v = e.target.value const v = e.target.value
onChange(v === "" ? undefined : Number(v)) onChange(v === "" ? undefined : Number(v))
}} }}
placeholder={field.defaultValue !== undefined ? String(field.defaultValue) : undefined} placeholder={field.defaultValue !== undefined ? String(field.defaultValue) : undefined}
disabled={disabled} disabled={disabled}
/> />
</div> </div>
) )
} }
if (field.type === "boolean") { return (
return ( <div className="space-y-2">
<div className="flex items-center justify-between gap-3 rounded-md border px-3 py-2"> <Label htmlFor={name} className="text-xs font-medium">
<Label htmlFor={name} className="text-xs font-medium"> {labelContent}
{labelContent} </Label>
</Label> <Input
<Switch id={name} checked={value === true} onCheckedChange={onChange} disabled={disabled} /> id={name}
</div> type={field.secret ? "password" : "text"}
) value={String(value ?? "")}
} onChange={(e) => onChange(e.target.value)}
placeholder={field.defaultValue !== undefined ? String(field.defaultValue) : undefined}
return ( disabled={disabled}
<div className="space-y-2"> />
<Label htmlFor={name} className="text-xs font-medium"> </div>
{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 if (field.type === "boolean") { } else if (field.type === "multiselect") {
values[name] = false values[name] = []
} else if (field.type === "multiselect") { } else {
values[name] = [] values[name] = field.type === "number" ? undefined : ""
} else { }
values[name] = field.type === "number" ? undefined : "" }
} return values
}
return values
} }

View File

@@ -5,219 +5,226 @@ 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<ThemeProviderState | undefined>(undefined) const ThemeProviderContext = React.createContext<
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("input, textarea, select, [contenteditable='true']") const editableParent = target.closest(
if (editableParent) { "input, textarea, select, [contenteditable='true']"
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 = nextTheme === "system" ? getSystemTheme() : nextTheme const resolvedTheme =
const restoreTransitions = disableTransitionOnChange ? disableTransitionsTemporarily() : null nextTheme === "system" ? getSystemTheme() : nextTheme
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 { ChevronDownIcon, ChevronUpIcon } from "lucide-react"
import { Accordion as AccordionPrimitive } from "radix-ui"
import * as React from "react" import * as React from "react"
import { Accordion as AccordionPrimitive } from "radix-ui"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { ChevronDownIcon, ChevronUpIcon } from "lucide-react"
function Accordion({ className, ...props }: React.ComponentProps<typeof AccordionPrimitive.Root>) { function Accordion({
return ( className,
<AccordionPrimitive.Root ...props
data-slot="accordion" }: React.ComponentProps<typeof AccordionPrimitive.Root>) {
className={cn("flex w-full flex-col overflow-hidden rounded-md border", className)} return (
{...props} <AccordionPrimitive.Root
/> 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 <ChevronDownIcon data-slot="accordion-trigger-icon" className="pointer-events-none shrink-0 group-aria-expanded/accordion-trigger:hidden" />
data-slot="accordion-trigger-icon" <ChevronUpIcon data-slot="accordion-trigger-icon" className="pointer-events-none hidden shrink-0 group-aria-expanded/accordion-trigger:inline" />
className="pointer-events-none shrink-0 group-aria-expanded/accordion-trigger:hidden" </AccordionPrimitive.Trigger>
/> </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,73 +1,76 @@
import { cva, type VariantProps } from "class-variance-authority"
import * as React from "react" import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
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({ className, ...props }: React.ComponentProps<"div">) { function AlertDescription({
return ( className,
<div ...props
data-slot="alert-description" }: React.ComponentProps<"div">) {
className={cn( return (
"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", <div
className, data-slot="alert-description"
)} className={cn(
{...props} "text-xs/relaxed text-balance text-muted-foreground md:text-pretty [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground [&_p:not(:last-child)]:mb-4",
/> className
) )}
{...props}
/>
)
} }
function AlertAction({ className, ...props }: React.ComponentProps<"div">) { 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,46 +1,49 @@
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: "bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80", secondary:
destructive: "bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
"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", destructive:
outline: "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",
"border-border bg-input/20 text-foreground dark:bg-input/30 [a]:hover:bg-muted [a]:hover:text-muted-foreground", outline:
ghost: "hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50", "border-border bg-input/20 text-foreground dark:bg-input/30 [a]:hover:bg-muted [a]:hover:text-muted-foreground",
link: "text-primary underline-offset-4 hover:underline", ghost:
}, "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"> & VariantProps<typeof badgeVariants> & { asChild?: boolean }) { }: React.ComponentProps<"span"> &
const Comp = asChild ? Slot.Root : "span" VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
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,81 +3,98 @@ 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 <div data-slot="card-title" className={cn("text-sm font-medium", className)} {...props} /> return (
<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("col-start-2 row-span-2 row-start-1 self-start justify-self-end", className)} className={cn(
{...props} "col-start-2 row-span-2 row-start-1 self-start justify-self-end",
/> 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 { Card, CardHeader, CardFooter, CardTitle, CardAction, CardDescription, CardContent } export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}

View File

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

View File

@@ -1,183 +1,193 @@
import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react"
import { Select as SelectPrimitive } from "radix-ui"
import * as React from "react" import * as React from "react"
import { Select as SelectPrimitive } from "radix-ui"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react"
function Select({ ...props }: React.ComponentProps<typeof SelectPrimitive.Root>) { function Select({
return <SelectPrimitive.Root data-slot="select" {...props} /> ...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />
} }
function SelectGroup({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.Group>) { function SelectGroup({
return ( className,
<SelectPrimitive.Group ...props
data-slot="select-group" }: React.ComponentProps<typeof SelectPrimitive.Group>) {
className={cn("scroll-my-1 p-1", className)} return (
{...props} <SelectPrimitive.Group
/> data-slot="select-group"
) className={cn("scroll-my-1 p-1", className)}
{...props}
/>
)
} }
function SelectValue({ ...props }: React.ComponentProps<typeof SelectPrimitive.Value>) { function SelectValue({
return <SelectPrimitive.Value data-slot="select-value" {...props} /> ...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( 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 )}
"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={position}
position === "popper" && align={align}
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1", {...props}
className, >
)} <SelectScrollUpButton />
position={position} <SelectPrimitive.Viewport
align={align} data-position={position}
{...props} className={cn(
> "data-[position=popper]:h-(--radix-select-trigger-height) data-[position=popper]:w-full data-[position=popper]:min-w-(--radix-select-trigger-width)",
<SelectScrollUpButton /> position === "popper" && ""
<SelectPrimitive.Viewport )}
data-position={position} >
className={cn( {children}
"data-[position=popper]:h-(--radix-select-trigger-height) data-[position=popper]:w-full data-[position=popper]:min-w-(--radix-select-trigger-width)", </SelectPrimitive.Viewport>
position === "popper" && "", <SelectScrollDownButton />
)} </SelectPrimitive.Content>
> </SelectPrimitive.Portal>
{children} )
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
)
} }
function SelectLabel({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.Label>) { function SelectLabel({
return ( className,
<SelectPrimitive.Label ...props
data-slot="select-label" }: React.ComponentProps<typeof SelectPrimitive.Label>) {
className={cn("px-2 py-1.5 text-xs text-muted-foreground", className)} return (
{...props} <SelectPrimitive.Label
/> 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("pointer-events-none -mx-1 my-1 h-px bg-border/50", className)} className={cn(
{...props} "pointer-events-none -mx-1 my-1 h-px bg-border/50",
/> 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 { Separator as SeparatorPrimitive } from "radix-ui"
import * as React from "react" import * as React from "react"
import { Separator as SeparatorPrimitive } from "radix-ui"
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,128 +1,142 @@
import { XIcon } from "lucide-react"
import { Dialog as SheetPrimitive } from "radix-ui"
import * as React from "react" import * as React from "react"
import { Dialog as SheetPrimitive } from "radix-ui"
import { Button } from "@/components/ui/button"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { XIcon } from "lucide-react"
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({ ...props }: React.ComponentProps<typeof SheetPrimitive.Trigger>) { function SheetTrigger({
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} /> ...props
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
} }
function SheetClose({ ...props }: React.ComponentProps<typeof SheetPrimitive.Close>) { function SheetClose({
return <SheetPrimitive.Close data-slot="sheet-close" {...props} /> ...props
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
} }
function SheetPortal({ ...props }: React.ComponentProps<typeof SheetPrimitive.Portal>) { function SheetPortal({
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} /> ...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 variant="ghost" className="absolute top-4 right-4" size="icon-sm"> <Button
<XIcon /> variant="ghost"
<span className="sr-only">Close</span> className="absolute top-4 right-4"
</Button> size="icon-sm"
</SheetPrimitive.Close> >
)} <XIcon
</SheetPrimitive.Content> />
</SheetPortal> <span className="sr-only">Close</span>
) </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({ className, ...props }: React.ComponentProps<typeof SheetPrimitive.Title>) { function SheetTitle({
return ( className,
<SheetPrimitive.Title ...props
data-slot="sheet-title" }: React.ComponentProps<typeof SheetPrimitive.Title>) {
className={cn("text-sm font-medium text-foreground", className)} return (
{...props} <SheetPrimitive.Title
/> 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,46 +1,49 @@
"use client" "use client"
import {
CircleCheckIcon,
InfoIcon,
TriangleAlertIcon,
OctagonXIcon,
Loader2Icon,
} from "lucide-react"
import { Toaster as Sonner, type ToasterProps } from "sonner"
import { useTheme } from "@/components/theme-provider" import { useTheme } from "@/components/theme-provider"
import { Toaster as Sonner, type ToasterProps } from "sonner"
import { CircleCheckIcon, InfoIcon, TriangleAlertIcon, OctagonXIcon, Loader2Icon } from "lucide-react"
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: <CircleCheckIcon className="size-4" />, success: (
info: <InfoIcon className="size-4" />, <CircleCheckIcon className="size-4" />
warning: <TriangleAlertIcon className="size-4" />, ),
error: <OctagonXIcon className="size-4" />, info: (
loading: <Loader2Icon className="size-4 animate-spin" />, <InfoIcon className="size-4" />
}} ),
style={ warning: (
{ <TriangleAlertIcon className="size-4" />
"--normal-bg": "var(--popover)", ),
"--normal-text": "var(--popover-foreground)", error: (
"--normal-border": "var(--border)", <OctagonXIcon className="size-4" />
"--border-radius": "var(--radius)", ),
} as React.CSSProperties loading: (
} <Loader2Icon className="size-4 animate-spin" />
toastOptions={{ ),
classNames: { }}
toast: "cn-toast", style={
}, {
}} "--normal-bg": "var(--popover)",
{...props} "--normal-text": "var(--popover-foreground)",
/> "--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 { Switch as SwitchPrimitive } from "radix-ui"
import * as React from "react" import * as React from "react"
import { Switch as SwitchPrimitive } from "radix-ui"
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,53 +1,57 @@
"use client" "use client"
import { Tooltip as TooltipPrimitive } from "radix-ui"
import * as React from "react" import * as React from "react"
import { Tooltip as TooltipPrimitive } from "radix-ui"
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({ ...props }: React.ComponentProps<typeof TooltipPrimitive.Root>) { function Tooltip({
return <TooltipPrimitive.Root data-slot="tooltip" {...props} /> ...props
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
} }
function TooltipTrigger({ ...props }: React.ComponentProps<typeof TooltipPrimitive.Trigger>) { function TooltipTrigger({
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} /> ...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,335 +1,197 @@
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" | "multiselect" | "boolean" type: "string" | "number" | "select" | "multiselect"
label: string label: string
required?: boolean required?: boolean
description?: string description?: string
secret?: boolean secret?: boolean
defaultValue?: string | number | string[] | boolean defaultValue?: string | number | string[]
options?: { label: string; value: string }[] options?: { label: string; value: string }[]
} }
export interface SourceDefinition { export interface SourceDefinition {
id: string id: string
name: string name: string
description: string description: string
alwaysEnabled?: boolean alwaysEnabled?: boolean
/** When true, secret fields are stored as per-user credentials via /api/sources/:id/credentials. */ fields: Record<string, ConfigFieldDef>
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: "freya.location", id: "aelis.location",
name: "Location", name: "Location",
description: "Device location provider. Always enabled as a dependency for other sources.", description: "Device location provider. Always enabled as a dependency for other sources.",
alwaysEnabled: true, alwaysEnabled: true,
fields: {}, fields: {},
}, },
{ {
id: "freya.weather", id: "aelis.weather",
name: "WeatherKit", name: "WeatherKit",
description: "Apple WeatherKit weather data. Requires Apple Developer credentials.", description: "Apple WeatherKit weather data. Requires Apple Developer credentials.",
fields: { fields: {
privateKey: { privateKey: { type: "string", label: "Private Key", required: true, secret: true, description: "Apple WeatherKit private key (PEM format)" },
type: "string", keyId: { type: "string", label: "Key ID", required: true, secret: true },
label: "Private Key", teamId: { type: "string", label: "Team ID", required: true, secret: true },
required: true, serviceId: { type: "string", label: "Service ID", required: true, secret: true },
secret: true, units: { type: "select", label: "Units", options: [{ label: "Metric", value: "metric" }, { label: "Imperial", value: "imperial" }], defaultValue: "metric" },
description: "Apple WeatherKit private key (PEM format)", 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" },
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: { id: "aelis.tfl",
type: "select", name: "TfL",
label: "Units", description: "Transport for London tube line status alerts.",
options: [ fields: {
{ label: "Metric", value: "metric" }, lines: {
{ label: "Imperial", value: "imperial" }, type: "multiselect",
], label: "Lines",
defaultValue: "metric", description: "Lines to monitor. Leave empty for all lines.",
}, defaultValue: [],
hourlyLimit: { options: [
type: "number", { label: "Bakerloo", value: "bakerloo" },
label: "Hourly Forecast Limit", { label: "Central", value: "central" },
defaultValue: 12, { label: "Circle", value: "circle" },
description: "Number of hourly forecasts to include", { label: "District", value: "district" },
}, { label: "Hammersmith & City", value: "hammersmith-city" },
dailyLimit: { { label: "Jubilee", value: "jubilee" },
type: "number", { label: "Metropolitan", value: "metropolitan" },
label: "Daily Forecast Limit", { label: "Northern", value: "northern" },
defaultValue: 7, { label: "Piccadilly", value: "piccadilly" },
description: "Number of daily forecasts to include", { label: "Victoria", value: "victoria" },
}, { label: "Waterloo & City", value: "waterloo-city" },
}, { label: "Lioness", value: "lioness" },
}, { label: "Mildmay", value: "mildmay" },
{ { label: "Windrush", value: "windrush" },
id: "freya.caldav", { label: "Weaver", value: "weaver" },
name: "CalDAV", { label: "Suffragette", value: "suffragette" },
description: "Calendar events from any CalDAV server (Nextcloud, Radicale, Baikal, etc.).", { label: "Liberty", value: "liberty" },
perUserCredentials: true, { label: "Elizabeth", value: "elizabeth" },
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(sourceId: string): Promise<SourceConfig | null> { export async function fetchSourceConfig(
const res = await fetch(`${serverBase()}/sources/${sourceId}`, { sourceId: string,
credentials: "include", ): Promise<SourceConfig | null> {
}) const res = await fetch(`${serverBase()}/sources/${sourceId}`, {
if (res.status === 404) return null credentials: "include",
if (!res.ok) throw new Error(`Failed to fetch source config: ${res.status}`) })
const data = (await res.json()) as { enabled: boolean; config: Record<string, unknown> } if (res.status === 404) return null
return { sourceId, enabled: data.enabled, config: data.config } if (!res.ok) throw new Error(`Failed to fetch source config: ${res.status}`)
const data = (await res.json()) as { enabled: boolean; config: Record<string, unknown> }
return { sourceId, enabled: data.enabled, config: data.config }
} }
export async function fetchConfigs(): Promise<SourceConfig[]> { export async function fetchConfigs(): Promise<SourceConfig[]> {
const results = await Promise.all(sourceDefinitions.map((s) => fetchSourceConfig(s.id))) const results = await Promise.all(
return results.filter((c): c is SourceConfig => c !== null) sourceDefinitions.map((s) => fetchSourceConfig(s.id)),
)
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; credentials?: Record<string, unknown> }, body: { enabled: boolean; config: unknown },
): Promise<void> { ): Promise<void> {
const res = await fetch(`${serverBase()}/sources/${sourceId}`, { const res = await fetch(`${serverBase()}/sources/${sourceId}`, {
method: "PUT", method: "PUT",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
credentials: "include", credentials: "include",
body: JSON.stringify(body), body: JSON.stringify(body),
}) })
if (!res.ok) { if (!res.ok) {
const data = (await res.json()) as { error?: string } const data = (await res.json()) as { error?: string }
throw new Error(data.error ?? `Failed to replace source config: ${res.status}`) throw new Error(data.error ?? `Failed to replace source config: ${res.status}`)
} }
} }
export async function updateProviderConfig( export async function updateProviderConfig(
sourceId: string, sourceId: string,
body: Record<string, unknown>, body: Record<string, unknown>,
): Promise<void> { ): Promise<void> {
const res = await fetch(`${apiBase()}/${sourceId}/config`, { const res = await fetch(`${apiBase()}/${sourceId}/config`, {
method: "PUT", method: "PUT",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
credentials: "include", credentials: "include",
body: JSON.stringify(body), body: JSON.stringify(body),
}) })
if (!res.ok) { if (!res.ok) {
const data = (await res.json()) as { error?: string } const data = (await res.json()) as { error?: string }
throw new Error(data.error ?? `Failed to update provider config: ${res.status}`) throw new Error(data.error ?? `Failed to update provider config: ${res.status}`)
} }
}
export 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 = "freya-server-url" const STORAGE_KEY = "aelis-server-url"
const DEFAULT_URL = "https://3000--019cf276-6ed6-7529-a425-210182693908.eu-runner.flex.doptig.cloud" const DEFAULT_URL = "https://3000--019cf276-6ed6-7529-a425-210182693908.eu-runner.flex.doptig.cloud"
export function getServerUrl(): string { export function getServerUrl(): string {
return localStorage.getItem(STORAGE_KEY) ?? DEFAULT_URL return localStorage.getItem(STORAGE_KEY) ?? DEFAULT_URL
} }
export function setServerUrl(url: string): void { export function setServerUrl(url: string): void {
localStorage.setItem(STORAGE_KEY, url.replace(/\/+$/, "")) localStorage.setItem(STORAGE_KEY, url.replace(/\/+$/, ""))
} }

View File

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

View File

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

View File

@@ -1,15 +1,13 @@
import type { QueryClient } from "@tanstack/react-query"
import { createRootRouteWithContext, Outlet } from "@tanstack/react-router" import { createRootRouteWithContext, Outlet } from "@tanstack/react-router"
import type { QueryClient } from "@tanstack/react-query"
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,223 +1,208 @@
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 {
createRoute, Calendar,
Outlet, CalendarDays,
redirect, CircleDot,
useMatchRoute, CloudSun,
useNavigate, Loader2,
Link, TrainFront,
} from "@tanstack/react-router" LogOut,
import { MapPin,
Bell, Rss,
Calendar, Server,
CalendarDays, TriangleAlert,
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 }>> = {
"freya.location": MapPin, "aelis.location": MapPin,
"freya.weather": CloudSun, "aelis.weather": CloudSun,
"freya.caldav": CalendarDays, "aelis.caldav": CalendarDays,
"freya.google-calendar": Calendar, "aelis.google-calendar": Calendar,
"freya.google-maps": MapIcon, "aelis.tfl": TrainFront,
"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 <Button variant="ghost" size="icon" className="size-7 shrink-0" onClick={() => logoutMutation.mutate()}>
variant="ghost" <LogOut className="size-3.5" />
size="icon" </Button>
className="size-7 shrink-0" </div>
onClick={() => logoutMutation.mutate()} </SidebarHeader>
> <SidebarContent>
<LogOut className="size-3.5" /> <SidebarGroup>
</Button> <SidebarGroupLabel>General</SidebarGroupLabel>
</div> <SidebarGroupContent>
</SidebarHeader> <SidebarMenu>
<SidebarContent> <SidebarMenuItem>
<SidebarGroup> <SidebarMenuButton
<SidebarGroupLabel>General</SidebarGroupLabel> isActive={!!matchRoute({ to: "/" })}
<SidebarGroupContent> asChild
<SidebarMenu> >
<SidebarMenuItem> <Link to="/">
<SidebarMenuButton isActive={!!matchRoute({ to: "/" })} asChild> <Server className="size-4" />
<Link to="/"> <span>Server</span>
<Server className="size-4" /> </Link>
<span>Server</span> </SidebarMenuButton>
</Link> </SidebarMenuItem>
</SidebarMenuButton> <SidebarMenuItem>
</SidebarMenuItem> <SidebarMenuButton
<SidebarMenuItem> isActive={!!matchRoute({ to: "/feed" })}
<SidebarMenuButton isActive={!!matchRoute({ to: "/feed" })} asChild> asChild
<Link to="/feed"> >
<Rss className="size-4" /> <Link to="/feed">
<span>Feed</span> <Rss className="size-4" />
</Link> <span>Feed</span>
</SidebarMenuButton> </Link>
</SidebarMenuItem> </SidebarMenuButton>
</SidebarMenu> </SidebarMenuItem>
</SidebarGroupContent> </SidebarMenu>
</SidebarGroup> </SidebarGroupContent>
</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={ isActive={!!matchRoute({ to: "/sources/$sourceId", params: { sourceId: source.id } })}
!!matchRoute({ asChild
to: "/sources/$sourceId", >
params: { sourceId: source.id }, <Link to="/sources/$sourceId" params={{ sourceId: source.id }}>
}) <Icon className="size-4" />
} <span>{source.name}</span>
asChild </Link>
> </SidebarMenuButton>
<Link to="/sources/$sourceId" params={{ sourceId: source.id }}> {isEnabled && (
<Icon className="size-4" /> <SidebarMenuBadge>
<span>{source.name}</span> <CircleDot className="size-2.5 text-primary" />
</Link> </SidebarMenuBadge>
</SidebarMenuButton> )}
{isEnabled && ( </SidebarMenuItem>
<SidebarMenuBadge> )
<CircleDot className="size-2.5 text-primary" /> })}
</SidebarMenuBadge> </SidebarMenu>
)} </SidebarGroupContent>
</SidebarMenuItem> </SidebarGroup>
) </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,11 +1,10 @@
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,11 +1,10 @@
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,35 +1,34 @@
import { useQuery, useQueryClient } from "@tanstack/react-query"
import { createRoute } from "@tanstack/react-router" import { createRoute } from "@tanstack/react-router"
import { useQuery, useQueryClient } from "@tanstack/react-query"
import { SourceConfigPanel } from "@/components/source-config-panel"
import { fetchSources } from "@/lib/api" import { fetchSources } from "@/lib/api"
import { SourceConfigPanel } from "@/components/source-config-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: "/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,24 +1,22 @@
import { useQueryClient } from "@tanstack/react-query"
import { createRoute, useNavigate } from "@tanstack/react-router" import { createRoute, useNavigate } from "@tanstack/react-router"
import { useQueryClient } from "@tanstack/react-query"
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,29 +1,32 @@
{ {
"compilerOptions": { "compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022", "target": "ES2022",
"useDefineForClassFields": true, "useDefineForClassFields": true,
"lib": ["ES2022", "DOM"], "lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext", "module": "ESNext",
"types": ["vite/client"], "types": ["vite/client"],
"skipLibCheck": true, "skipLibCheck": true,
"moduleResolution": "bundler", /* Bundler mode */
"allowImportingTsExtensions": true, "moduleResolution": "bundler",
"verbatimModuleSyntax": true, "allowImportingTsExtensions": true,
"moduleDetection": "force", "verbatimModuleSyntax": true,
"noEmit": true, "moduleDetection": "force",
"jsx": "react-jsx", "noEmit": true,
"jsx": "react-jsx",
"strict": true, /* Linting */
"noUnusedLocals": true, "strict": true,
"noUnusedParameters": true, "noUnusedLocals": true,
"erasableSyntaxOnly": true, "noUnusedParameters": true,
"noFallthroughCasesInSwitch": true, "erasableSyntaxOnly": true,
"noUncheckedSideEffectImports": true, "noFallthroughCasesInSwitch": true,
"paths": { "noUncheckedSideEffectImports": true,
"@/*": ["./src/*"] "baseUrl": ".",
} "paths": {
}, "@/*": ["./src/*"]
"include": ["src"] }
},
"include": ["src"]
} }

View File

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

View File

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

View File

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

View File

@@ -5,13 +5,15 @@ 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)
CREDENTIAL_ENCRYPTION_KEY= CREDENTIALS_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,33 +49,6 @@ 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,
@@ -88,13 +61,6 @@ 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 "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"); CREATE INDEX "verification_identifier_idx" ON "verification" USING btree ("identifier");

View File

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

View File

@@ -0,0 +1,457 @@
{
"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,6 +1,6 @@
{ {
"id": "d8c59ec7-b686-41a7-a472-da29f3ab6727", "id": "d963322c-77e2-4ac9-bd3c-ca544c85ae35",
"prevId": "00000000-0000-0000-0000-000000000000", "prevId": "d8c59ec7-b686-41a7-a472-da29f3ab6727",
"version": "7", "version": "7",
"dialect": "postgresql", "dialect": "postgresql",
"tables": { "tables": {
@@ -346,7 +346,29 @@
"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",
@@ -441,296 +463,6 @@
"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": {},

View File

@@ -1,10 +1,10 @@
{ {
"name": "@freya/backend", "name": "@aelis/backend",
"version": "0.0.0", "version": "0.0.0",
"type": "module", "type": "module",
"main": "src/server.ts", "main": "src/server.ts",
"scripts": { "scripts": {
"dev": "bun run --watch --inspect=0.0.0.0:6499 src/server.ts", "dev": "bun run --watch src/server.ts",
"start": "bun run src/server.ts", "start": "bun run src/server.ts",
"test": "bun test src/", "test": "bun test src/",
"db:generate": "bunx drizzle-kit generate", "db:generate": "bunx drizzle-kit generate",
@@ -15,25 +15,18 @@
"create-admin": "bun run src/scripts/create-admin.ts" "create-admin": "bun run src/scripts/create-admin.ts"
}, },
"dependencies": { "dependencies": {
"@earendil-works/pi-coding-agent": "^0.79.1", "@aelis/core": "workspace:*",
"@freya/agent-protocol": "workspace:*", "@aelis/source-caldav": "workspace:*",
"@freya/core": "workspace:*", "@aelis/source-google-calendar": "workspace:*",
"@freya/source-caldav": "workspace:*", "@aelis/source-location": "workspace:*",
"@freya/source-google-calendar": "workspace:*", "@aelis/source-tfl": "workspace:*",
"@freya/source-google-maps": "workspace:*", "@aelis/source-weatherkit": "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", "@openrouter/sdk": "^0.9.11",
"arktype": "^2.1.29", "arktype": "^2.1.29",
"better-auth": "^1", "better-auth": "^1",
"drizzle-orm": "^0.45.1", "drizzle-orm": "^0.45.1",
"hono": "^4", "hono": "^4",
"lodash.merge": "^4.6.2", "lodash.merge": "^4.6.2"
"typebox": "^1.1.38"
}, },
"devDependencies": { "devDependencies": {
"@types/lodash.merge": "^4.6.9", "@types/lodash.merge": "^4.6.9",

View File

@@ -1,4 +1,4 @@
import type { ActionDefinition, ContextEntry, FeedItem, FeedSource } from "@freya/core" import type { ActionDefinition, ContextEntry, FeedItem, FeedSource } from "@aelis/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,20 +44,6 @@ 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,
@@ -132,9 +118,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("freya.location")]) const { app } = createApp([createStubProvider("aelis.location")])
const res = await app.request("/api/admin/freya.nonexistent/config", { const res = await app.request("/api/admin/aelis.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" }),
@@ -146,9 +132,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("freya.location")]) const { app } = createApp([createStubProvider("aelis.location")])
const res = await app.request("/api/admin/freya.location/config", { const res = await app.request("/api/admin/aelis.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" }),
@@ -160,9 +146,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("freya.weather")]) const { app } = createApp([createStubProvider("aelis.weather")])
const res = await app.request("/api/admin/freya.weather/config", { const res = await app.request("/api/admin/aelis.weather/config", {
method: "PUT", method: "PUT",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: "not json", body: "not json",
@@ -174,9 +160,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("freya.weather")]) const { app } = createApp([createStubProvider("aelis.weather")])
const res = await app.request("/api/admin/freya.weather/config", { const res = await app.request("/api/admin/aelis.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 } }),
@@ -188,11 +174,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("freya.weather")]) const { app, sessionManager } = createApp([createStubProvider("aelis.weather")])
const originalProvider = sessionManager.getProvider("freya.weather") const originalProvider = sessionManager.getProvider("aelis.weather")
const res = await app.request("/api/admin/freya.weather/config", { const res = await app.request("/api/admin/aelis.weather/config", {
method: "PUT", method: "PUT",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify(validWeatherConfig), body: JSON.stringify(validWeatherConfig),
@@ -201,9 +187,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("freya.weather") const provider = sessionManager.getProvider("aelis.weather")
expect(provider).toBeDefined() expect(provider).toBeDefined()
expect(provider!.sourceId).toBe("freya.weather") expect(provider!.sourceId).toBe("aelis.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 "freya.weather": { case "aelis.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

@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
import { Hono } from "hono" import { Hono } from "hono"
import { describe, expect, test } from "bun:test"
import type { Auth } from "./index.ts" import type { Auth } from "./index.ts"
import type { AuthSession, AuthUser } from "./session.ts" import type { AuthSession, AuthUser } from "./session.ts"

View File

@@ -5,7 +5,6 @@ import { admin } from "better-auth/plugins"
import type { Database } from "../db/index.ts" import type { Database } from "../db/index.ts"
import * as schema from "../db/schema.ts" import * as schema from "../db/schema.ts"
import { insertDefaultUserSources } from "../sources/default-sources.ts"
export function createAuth(db: Database) { export function createAuth(db: Database) {
if (!process.env.BETTER_AUTH_SECRET) { if (!process.env.BETTER_AUTH_SECRET) {
@@ -23,15 +22,6 @@ export function createAuth(db: Database) {
emailAndPassword: { emailAndPassword: {
enabled: true, enabled: true,
}, },
databaseHooks: {
user: {
create: {
async after(user, _context) {
await insertDefaultUserSources(db, user.id)
},
},
},
},
plugins: [admin()], plugins: [admin()],
}) })
} }

View File

@@ -53,6 +53,16 @@ export function createRequireSession(auth: Auth): AuthSessionMiddleware {
} }
} }
/**
* Creates a function to get session from headers. Useful for WebSocket upgrade validation.
*/
export function createGetSessionFromHeaders(auth: Auth) {
return async (headers: Headers): Promise<{ user: AuthUser; session: AuthSession } | null> => {
const session = await auth.api.getSession({ headers })
return session
}
}
/** /**
* Dev/test middleware that injects a fake user and session. * Dev/test middleware that injects a fake user and session.
* Pass userId to simulate an authenticated request, or omit to get 401. * Pass userId to simulate an authenticated request, or omit to get 401.
@@ -69,7 +79,7 @@ export function mockAuthSessionMiddleware(userId?: string): AuthSessionMiddlewar
const user: AuthUser = { const user: AuthUser = {
id: "k7Gx2mPqRvNwYs9TdLfA4bHcJeUo1iZn", id: "k7Gx2mPqRvNwYs9TdLfA4bHcJeUo1iZn",
name: "Dev User", name: "Dev User",
email: "dev@freya.local", email: "dev@aelis.local",
emailVerified: true, emailVerified: true,
image: null, image: null,
createdAt: now, createdAt: now,
@@ -86,7 +96,7 @@ export function mockAuthSessionMiddleware(userId?: string): AuthSessionMiddlewar
token: "Vb9CxNfRm2KwQs7TjPeA5dLhYg0UoZi4", token: "Vb9CxNfRm2KwQs7TjPeA5dLhYg0UoZi4",
expiresAt, expiresAt,
ipAddress: "127.0.0.1", ipAddress: "127.0.0.1",
userAgent: "freya-dev", userAgent: "aelis-dev",
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
} }

View File

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

View File

@@ -0,0 +1,62 @@
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,6 +1,6 @@
import type { ActionDefinition, ContextEntry, FeedItem, FeedSource } from "@freya/core" import type { ActionDefinition, ContextEntry, FeedItem, FeedSource } from "@aelis/core"
import { contextKey } from "@freya/core" import { contextKey } from "@aelis/core"
import { describe, expect, mock, spyOn, test } from "bun:test" import { describe, expect, mock, spyOn, test } from "bun:test"
import { Hono } from "hono" import { Hono } from "hono"
@@ -85,20 +85,6 @@ 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 }
},
}),
}))
const fakeDb = {} as Database const fakeDb = {} as Database
describe("GET /api/feed", () => { describe("GET /api/feed", () => {
@@ -258,7 +244,7 @@ describe("GET /api/feed", () => {
}) })
describe("GET /api/context", () => { describe("GET /api/context", () => {
const weatherKey = contextKey("freya.weather", "weather") const weatherKey = contextKey("aelis.weather", "weather")
const weatherData = { temperature: 20, condition: "Clear" } const weatherData = { temperature: 20, condition: "Clear" }
const contextEntries: readonly ContextEntry[] = [[weatherKey, weatherData]] const contextEntries: readonly ContextEntry[] = [[weatherKey, weatherData]]
@@ -288,7 +274,7 @@ describe("GET /api/context", () => {
const manager = new UserSessionManager({ db: fakeDb, providers: [] }) const manager = new UserSessionManager({ db: fakeDb, providers: [] })
const app = buildTestApp(manager) const app = buildTestApp(manager)
const res = await app.request('/api/context?key=["freya.weather","weather"]') const res = await app.request('/api/context?key=["aelis.weather","weather"]')
expect(res.status).toBe(401) expect(res.status).toBe(401)
}) })
@@ -346,7 +332,7 @@ describe("GET /api/context", () => {
test("returns 400 when match param is invalid", async () => { test("returns 400 when match param is invalid", async () => {
const { app } = await buildContextApp("user-1") const { app } = await buildContextApp("user-1")
const res = await app.request('/api/context?key=["freya.weather"]&match=invalid') const res = await app.request('/api/context?key=["aelis.weather"]&match=invalid')
expect(res.status).toBe(400) expect(res.status).toBe(400)
const body = (await res.json()) as { error: string } const body = (await res.json()) as { error: string }
@@ -357,7 +343,7 @@ describe("GET /api/context", () => {
const { app, session } = await buildContextApp("user-1") const { app, session } = await buildContextApp("user-1")
await session.engine.refresh() await session.engine.refresh()
const res = await app.request('/api/context?key=["freya.weather","weather"]&match=exact') const res = await app.request('/api/context?key=["aelis.weather","weather"]&match=exact')
expect(res.status).toBe(200) expect(res.status).toBe(200)
const body = (await res.json()) as { match: string; value: unknown } const body = (await res.json()) as { match: string; value: unknown }
@@ -369,7 +355,7 @@ describe("GET /api/context", () => {
const { app, session } = await buildContextApp("user-1") const { app, session } = await buildContextApp("user-1")
await session.engine.refresh() await session.engine.refresh()
const res = await app.request('/api/context?key=["freya.weather"]&match=exact') const res = await app.request('/api/context?key=["aelis.weather"]&match=exact')
expect(res.status).toBe(404) expect(res.status).toBe(404)
}) })
@@ -378,7 +364,7 @@ describe("GET /api/context", () => {
const { app, session } = await buildContextApp("user-1") const { app, session } = await buildContextApp("user-1")
await session.engine.refresh() await session.engine.refresh()
const res = await app.request('/api/context?key=["freya.weather"]&match=prefix') const res = await app.request('/api/context?key=["aelis.weather"]&match=prefix')
expect(res.status).toBe(200) expect(res.status).toBe(200)
const body = (await res.json()) as { const body = (await res.json()) as {
@@ -387,7 +373,7 @@ describe("GET /api/context", () => {
} }
expect(body.match).toBe("prefix") expect(body.match).toBe("prefix")
expect(body.entries).toHaveLength(1) expect(body.entries).toHaveLength(1)
expect(body.entries[0]!.key).toEqual(["freya.weather", "weather"]) expect(body.entries[0]!.key).toEqual(["aelis.weather", "weather"])
expect(body.entries[0]!.value).toEqual(weatherData) expect(body.entries[0]!.value).toEqual(weatherData)
}) })
@@ -395,7 +381,7 @@ describe("GET /api/context", () => {
const { app, session } = await buildContextApp("user-1") const { app, session } = await buildContextApp("user-1")
await session.engine.refresh() await session.engine.refresh()
const res = await app.request('/api/context?key=["freya.weather","weather"]') const res = await app.request('/api/context?key=["aelis.weather","weather"]')
expect(res.status).toBe(200) expect(res.status).toBe(200)
const body = (await res.json()) as { match: string; value: unknown } const body = (await res.json()) as { match: string; value: unknown }
@@ -407,7 +393,7 @@ describe("GET /api/context", () => {
const { app, session } = await buildContextApp("user-1") const { app, session } = await buildContextApp("user-1")
await session.engine.refresh() await session.engine.refresh()
const res = await app.request('/api/context?key=["freya.weather"]') const res = await app.request('/api/context?key=["aelis.weather"]')
expect(res.status).toBe(200) expect(res.status).toBe(200)
const body = (await res.json()) as { const body = (await res.json()) as {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,11 @@
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()
}
}

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