mirror of
https://github.com/kennethnym/freya
synced 2026-06-23 01:44:55 +01:00
Compare commits
69 Commits
feat/drizz
...
feat/convo
| Author | SHA1 | Date | |
|---|---|---|---|
|
57c3275cce
|
|||
|
25713ef614
|
|||
| 2e6cae4d02 | |||
| 8cf38d609b | |||
| e6af1b7851 | |||
| 769fd5c77d | |||
| 6cc0f7669a | |||
| 63e71fb828 | |||
| e9f97d6f02 | |||
| e52e057548 | |||
| d3d9def260 | |||
| 227f196d5e | |||
| e1c58cdf28 | |||
| e11051b04b | |||
| 95f6c99f19 | |||
| bdf0392a55 | |||
| fc82af3f55 | |||
| 9dc25dbc94 | |||
| 2b02c1a9d0 | |||
| fc443d967d | |||
| 9836d7499b | |||
| 6785503ff9 | |||
| 54afa3add1 | |||
| 825f67db13 | |||
| 083f6d2695 | |||
| 789b6a285b | |||
| 112d482d55 | |||
| efd7537008 | |||
| 38b21a1aa4 | |||
| ef7301ab18 | |||
| 877b955493 | |||
| 6b1db0b3d3 | |||
| 7e77870c13 | |||
| c95c730533 | |||
| 62c8dfe0b1 | |||
| e54c5d5462 | |||
| b5236e0e52 | |||
| 0a8243c55b | |||
| 400055ab8c | |||
| 98ce546eff | |||
| bfc25fa704 | |||
| 4097470656 | |||
| f549859a44 | |||
| 1483805f13 | |||
| 68932f83c3 | |||
| 4916886adf | |||
| f1c2f399f2 | |||
| 7a85990c24 | |||
| f126afc3ca | |||
| 53dbf1ca34 | |||
| e09c606649 | |||
| 21b7d299a6 | |||
| 1596f2bedf | |||
| b85109e2e2 | |||
| eb5149a500 | |||
| 02f519c29c | |||
| 59d14ee37b | |||
| 9b0ac1cd4e | |||
| 35c6371d48 | |||
| 7909211c1b | |||
| 99c097e503 | |||
| a52addebd8 | |||
| 4cef7f2ea1 | |||
| dd2b37938f | |||
| a6be7b31e7 | |||
| b24d879d31 | |||
| 7862a6d367 | |||
| 0095d9cd72 | |||
| ca2664b617 |
134
.agents/skills/upgrading-expo/SKILL.md
Normal file
134
.agents/skills/upgrading-expo/SKILL.md
Normal file
@@ -0,0 +1,134 @@
|
||||
---
|
||||
name: upgrading-expo
|
||||
description: Guidelines for upgrading Expo SDK versions and fixing dependency issues
|
||||
version: 1.0.0
|
||||
license: MIT
|
||||
---
|
||||
|
||||
## References
|
||||
|
||||
- ./references/react-19.md -- SDK +54: React 19 changes (useContext → use, Context.Provider → Context, forwardRef removal)
|
||||
- ./references/new-architecture.md -- SDK +53: New Architecture migration guide
|
||||
- ./references/react-compiler.md -- SDK +54: React Compiler setup and migration guide
|
||||
- ./references/native-tabs.md -- SDK +55: Native tabs changes (Icon/Label/Badge now accessed via NativeTabs.Trigger.\*)
|
||||
- ./references/expo-av-to-audio.md -- SDK +55: Migrate audio playback and recording from expo-av to expo-audio
|
||||
- ./references/expo-av-to-video.md -- SDK +55: Migrate video playback from expo-av to expo-video
|
||||
- ./references/react-navigation-to-expo-router.md -- SDK +56: Migrate `@react-navigation/*` imports to `expo-router` entry points (codemod + manual mapping)
|
||||
|
||||
## Beta/Preview Releases
|
||||
|
||||
Beta versions use `.preview` suffix (e.g., `55.0.0-preview.2`), published under `@next` tag.
|
||||
|
||||
Check if latest is beta: https://exp.host/--/api/v2/versions (look for `-preview` in `expoVersion`)
|
||||
|
||||
```bash
|
||||
npx expo install expo@next --fix # install beta
|
||||
```
|
||||
|
||||
## Step-by-Step Upgrade Process
|
||||
|
||||
1. Upgrade Expo and dependencies
|
||||
|
||||
```bash
|
||||
npx expo install expo@latest
|
||||
npx expo install --fix
|
||||
```
|
||||
|
||||
2. Run diagnostics: `npx expo-doctor`
|
||||
|
||||
3. Clear caches and reinstall
|
||||
|
||||
```bash
|
||||
npx expo export -p ios --clear
|
||||
rm -rf node_modules .expo
|
||||
watchman watch-del-all
|
||||
```
|
||||
|
||||
## Breaking Changes Checklist
|
||||
|
||||
- Check for removed APIs in release notes
|
||||
- Update import paths for moved modules
|
||||
- Review native module changes requiring prebuild
|
||||
- Test all camera, audio, and video features
|
||||
- Verify navigation still works correctly
|
||||
|
||||
## Prebuild for Native Changes
|
||||
|
||||
**First check if `ios/` and `android/` directories exist in the project.** If neither directory exists, the project uses Continuous Native Generation (CNG) and native projects are regenerated at build time — skip this section and "Clear caches for bare workflow" entirely.
|
||||
|
||||
If upgrading requires native changes:
|
||||
|
||||
```bash
|
||||
npx expo prebuild --clean
|
||||
```
|
||||
|
||||
This regenerates the `ios` and `android` directories. Ensure the project is not a bare workflow app before running this command.
|
||||
|
||||
## Clear caches for bare workflow
|
||||
|
||||
These steps only apply when `ios/` and/or `android/` directories exist in the project:
|
||||
|
||||
- Clear the cocoapods cache for iOS: `cd ios && pod install --repo-update`
|
||||
- Clear derived data for Xcode: `npx expo run:ios --no-build-cache`
|
||||
- Clear the Gradle cache for Android: `cd android && ./gradlew clean`
|
||||
|
||||
## Housekeeping
|
||||
|
||||
- Review release notes for the target SDK version at https://expo.dev/changelog
|
||||
- If using Expo SDK 54 or later, ensure react-native-worklets is installed — this is required for react-native-reanimated to work.
|
||||
- Enable React Compiler in SDK 54+ by adding `"experiments": { "reactCompiler": true }` to app.json — it's stable and recommended
|
||||
- Delete sdkVersion from `app.json` to let Expo manage it automatically
|
||||
- Remove implicit packages from `package.json`: `@babel/core`, `babel-preset-expo`, `expo-constants`.
|
||||
- If the babel.config.js only contains 'babel-preset-expo', delete the file
|
||||
- If the metro.config.js only contains expo defaults, delete the file
|
||||
|
||||
## Deprecated Packages
|
||||
|
||||
| Old Package | Replacement |
|
||||
| -------------------- | ---------------------------------------------------- |
|
||||
| `expo-av` | `expo-audio` and `expo-video` |
|
||||
| `expo-permissions` | Individual package permission APIs |
|
||||
| `@expo/vector-icons` | `expo-symbols` (for SF Symbols) |
|
||||
| `AsyncStorage` | `expo-sqlite/localStorage/install` |
|
||||
| `expo-app-loading` | `expo-splash-screen` |
|
||||
| expo-linear-gradient | experimental_backgroundImage + CSS gradients in View |
|
||||
|
||||
When migrating deprecated packages, update all code usage before removing the old package. For expo-av, consult the migration references to convert Audio.Sound to useAudioPlayer, Audio.Recording to useAudioRecorder, and Video components to VideoView with useVideoPlayer.
|
||||
|
||||
## expo.install.exclude
|
||||
|
||||
Check if package.json has excluded packages:
|
||||
|
||||
```json
|
||||
{
|
||||
"expo": { "install": { "exclude": ["react-native-reanimated"] } }
|
||||
}
|
||||
```
|
||||
|
||||
Exclusions are often workarounds that may no longer be needed after upgrading. Review each one.
|
||||
## Removing patches
|
||||
|
||||
Check if there are any outdated patches in the `patches/` directory. Remove them if they are no longer needed.
|
||||
|
||||
## Postcss
|
||||
|
||||
- `autoprefixer` isn't needed in SDK +53. Remove it from dependencies and check `postcss.config.js` or `postcss.config.mjs` to remove it from the plugins list.
|
||||
- Use `postcss.config.mjs` in SDK +53.
|
||||
|
||||
## Metro
|
||||
|
||||
Remove redundant metro config options:
|
||||
|
||||
- resolver.unstable_enablePackageExports is enabled by default in SDK +53.
|
||||
- `experimentalImportSupport` is enabled by default in SDK +54.
|
||||
- `EXPO_USE_FAST_RESOLVER=1` is removed in SDK +54.
|
||||
- cjs and mjs extensions are supported by default in SDK +50.
|
||||
- Expo webpack is deprecated, migrate to [Expo Router and Metro web](https://docs.expo.dev/router/migrate/from-expo-webpack/).
|
||||
|
||||
## Hermes engine v1
|
||||
|
||||
Since SDK 55, users can opt-in to use Hermes engine v1 for improved runtime performance. This requires setting `useHermesV1: true` in the `expo-build-properties` config plugin, and may require a specific version of the `hermes-compiler` npm package. Hermes v1 will become a default in some future SDK release.
|
||||
|
||||
## New Architecture
|
||||
|
||||
The new architecture is enabled by default, the app.json field `"newArchEnabled": true` is no longer needed as it's the default. Expo Go only supports the new architecture as of SDK +53.
|
||||
4
.agents/skills/upgrading-expo/agents/openai.yaml
Normal file
4
.agents/skills/upgrading-expo/agents/openai.yaml
Normal file
@@ -0,0 +1,4 @@
|
||||
interface:
|
||||
display_name: "Upgrading Expo"
|
||||
short_description: "Upgrade Expo SDKs, fix dependencies, adopt React 19 / React Compiler, and migrate deprecated Expo packages"
|
||||
default_prompt: "Use $upgrading-expo to upgrade an Expo SDK, run diagnostics, fix dependency conflicts, decide whether prebuild/cache clearing applies, and migrate away from deprecated Expo packages."
|
||||
132
.agents/skills/upgrading-expo/references/expo-av-to-audio.md
Normal file
132
.agents/skills/upgrading-expo/references/expo-av-to-audio.md
Normal file
@@ -0,0 +1,132 @@
|
||||
# Migrating from expo-av to expo-audio
|
||||
|
||||
## Imports
|
||||
|
||||
```tsx
|
||||
// Before
|
||||
import { Audio } from 'expo-av';
|
||||
|
||||
// After
|
||||
import { useAudioPlayer, useAudioRecorder, RecordingPresets, AudioModule, setAudioModeAsync } from 'expo-audio';
|
||||
```
|
||||
|
||||
## Audio Playback
|
||||
|
||||
### Before (expo-av)
|
||||
|
||||
```tsx
|
||||
const [sound, setSound] = useState<Audio.Sound>();
|
||||
|
||||
async function playSound() {
|
||||
const { sound } = await Audio.Sound.createAsync(require('./audio.mp3'));
|
||||
setSound(sound);
|
||||
await sound.playAsync();
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
return sound ? () => { sound.unloadAsync(); } : undefined;
|
||||
}, [sound]);
|
||||
```
|
||||
|
||||
### After (expo-audio)
|
||||
|
||||
```tsx
|
||||
const player = useAudioPlayer(require('./audio.mp3'));
|
||||
|
||||
// Play
|
||||
player.play();
|
||||
```
|
||||
|
||||
## Audio Recording
|
||||
|
||||
### Before (expo-av)
|
||||
|
||||
```tsx
|
||||
const [recording, setRecording] = useState<Audio.Recording>();
|
||||
|
||||
async function startRecording() {
|
||||
await Audio.requestPermissionsAsync();
|
||||
await Audio.setAudioModeAsync({ allowsRecordingIOS: true, playsInSilentModeIOS: true });
|
||||
const { recording } = await Audio.Recording.createAsync(Audio.RecordingOptionsPresets.HIGH_QUALITY);
|
||||
setRecording(recording);
|
||||
}
|
||||
|
||||
async function stopRecording() {
|
||||
await recording?.stopAndUnloadAsync();
|
||||
const uri = recording?.getURI();
|
||||
}
|
||||
```
|
||||
|
||||
### After (expo-audio)
|
||||
|
||||
```tsx
|
||||
const recorder = useAudioRecorder(RecordingPresets.HIGH_QUALITY);
|
||||
|
||||
async function startRecording() {
|
||||
await AudioModule.requestRecordingPermissionsAsync();
|
||||
await recorder.prepareToRecordAsync();
|
||||
recorder.record();
|
||||
}
|
||||
|
||||
async function stopRecording() {
|
||||
await recorder.stop();
|
||||
const uri = recorder.uri;
|
||||
}
|
||||
```
|
||||
|
||||
## Audio Mode
|
||||
|
||||
### Before (expo-av)
|
||||
|
||||
```tsx
|
||||
await Audio.setAudioModeAsync({
|
||||
allowsRecordingIOS: true,
|
||||
playsInSilentModeIOS: true,
|
||||
staysActiveInBackground: true,
|
||||
interruptionModeIOS: InterruptionModeIOS.DoNotMix,
|
||||
});
|
||||
```
|
||||
|
||||
### After (expo-audio)
|
||||
|
||||
```tsx
|
||||
await setAudioModeAsync({
|
||||
playsInSilentMode: true,
|
||||
shouldPlayInBackground: true,
|
||||
interruptionMode: 'doNotMix',
|
||||
});
|
||||
```
|
||||
|
||||
## API Mapping
|
||||
|
||||
| expo-av | expo-audio |
|
||||
|---------|------------|
|
||||
| `Audio.Sound.createAsync()` | `useAudioPlayer(source)` |
|
||||
| `sound.playAsync()` | `player.play()` |
|
||||
| `sound.pauseAsync()` | `player.pause()` |
|
||||
| `sound.setPositionAsync(ms)` | `player.seekTo(seconds)` |
|
||||
| `sound.setVolumeAsync(vol)` | `player.volume = vol` |
|
||||
| `sound.setRateAsync(rate)` | `player.playbackRate = rate` |
|
||||
| `sound.setIsLoopingAsync(loop)` | `player.loop = loop` |
|
||||
| `sound.unloadAsync()` | Automatic via hook |
|
||||
| `playbackStatus.positionMillis` | `player.currentTime` (seconds) |
|
||||
| `playbackStatus.durationMillis` | `player.duration` (seconds) |
|
||||
| `playbackStatus.isPlaying` | `player.playing` |
|
||||
| `Audio.Recording.createAsync()` | `useAudioRecorder(preset)` |
|
||||
| `Audio.RecordingOptionsPresets.*` | `RecordingPresets.*` |
|
||||
| `recording.stopAndUnloadAsync()` | `recorder.stop()` |
|
||||
| `recording.getURI()` | `recorder.uri` |
|
||||
| `Audio.requestPermissionsAsync()` | `AudioModule.requestRecordingPermissionsAsync()` |
|
||||
|
||||
## Key Differences
|
||||
|
||||
- **No auto-reset on finish**: After `play()` completes, the player stays paused at the end. To replay, call `player.seekTo(0)` then `play()`
|
||||
- **Time in seconds**: expo-audio uses seconds, not milliseconds (matching web standards)
|
||||
- **Immediate loading**: Audio loads immediately when the hook mounts—no explicit preloading needed
|
||||
- **Automatic cleanup**: No need to call `unloadAsync()`, hooks handle resource cleanup on unmount
|
||||
- **Multiple players**: Create multiple `useAudioPlayer` instances and store them—all load immediately
|
||||
- **Direct property access**: Set volume, rate, loop directly on the player object (`player.volume = 0.5`)
|
||||
|
||||
## API Reference
|
||||
|
||||
https://docs.expo.dev/versions/latest/sdk/audio/
|
||||
160
.agents/skills/upgrading-expo/references/expo-av-to-video.md
Normal file
160
.agents/skills/upgrading-expo/references/expo-av-to-video.md
Normal file
@@ -0,0 +1,160 @@
|
||||
# Migrating from expo-av to expo-video
|
||||
|
||||
## Imports
|
||||
|
||||
```tsx
|
||||
// Before
|
||||
import { Video, ResizeMode } from 'expo-av';
|
||||
|
||||
// After
|
||||
import { useVideoPlayer, VideoView, VideoSource } from 'expo-video';
|
||||
import { useEvent, useEventListener } from 'expo';
|
||||
```
|
||||
|
||||
## Video Playback
|
||||
|
||||
### Before (expo-av)
|
||||
|
||||
```tsx
|
||||
const videoRef = useRef<Video>(null);
|
||||
const [status, setStatus] = useState({});
|
||||
|
||||
<Video
|
||||
ref={videoRef}
|
||||
source={{ uri: 'https://example.com/video.mp4' }}
|
||||
style={{ width: 350, height: 200 }}
|
||||
resizeMode={ResizeMode.CONTAIN}
|
||||
isLooping
|
||||
onPlaybackStatusUpdate={setStatus}
|
||||
/>
|
||||
|
||||
// Control
|
||||
videoRef.current?.playAsync();
|
||||
videoRef.current?.pauseAsync();
|
||||
```
|
||||
|
||||
### After (expo-video)
|
||||
|
||||
```tsx
|
||||
const player = useVideoPlayer('https://example.com/video.mp4', player => {
|
||||
player.loop = true;
|
||||
});
|
||||
|
||||
const { isPlaying } = useEvent(player, 'playingChange', { isPlaying: player.playing });
|
||||
|
||||
<VideoView
|
||||
player={player}
|
||||
style={{ width: 350, height: 200 }}
|
||||
contentFit="contain"
|
||||
/>
|
||||
|
||||
// Control
|
||||
player.play();
|
||||
player.pause();
|
||||
```
|
||||
|
||||
## Status Updates
|
||||
|
||||
### Before (expo-av)
|
||||
|
||||
```tsx
|
||||
<Video
|
||||
onPlaybackStatusUpdate={status => {
|
||||
if (status.isLoaded) {
|
||||
console.log(status.positionMillis, status.durationMillis, status.isPlaying);
|
||||
if (status.didJustFinish) console.log('finished');
|
||||
}
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
### After (expo-video)
|
||||
|
||||
```tsx
|
||||
// Reactive state
|
||||
const { isPlaying } = useEvent(player, 'playingChange', { isPlaying: player.playing });
|
||||
|
||||
// Side effects
|
||||
useEventListener(player, 'playToEnd', () => console.log('finished'));
|
||||
|
||||
// Direct access
|
||||
console.log(player.currentTime, player.duration, player.playing);
|
||||
```
|
||||
|
||||
## Local Files
|
||||
|
||||
### Before (expo-av)
|
||||
|
||||
```tsx
|
||||
<Video source={require('./video.mp4')} />
|
||||
```
|
||||
|
||||
### After (expo-video)
|
||||
|
||||
```tsx
|
||||
const player = useVideoPlayer({ assetId: require('./video.mp4') });
|
||||
```
|
||||
|
||||
## Fullscreen and PiP
|
||||
|
||||
```tsx
|
||||
<VideoView
|
||||
player={player}
|
||||
allowsFullscreen
|
||||
allowsPictureInPicture
|
||||
onFullscreenEnter={() => {}}
|
||||
onFullscreenExit={() => {}}
|
||||
/>
|
||||
```
|
||||
|
||||
For PiP and background playback, add to app.json:
|
||||
|
||||
```json
|
||||
{
|
||||
"expo": {
|
||||
"plugins": [
|
||||
["expo-video", { "supportsBackgroundPlayback": true, "supportsPictureInPicture": true }]
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Mapping
|
||||
|
||||
| expo-av | expo-video |
|
||||
|---------|------------|
|
||||
| `<Video>` | `<VideoView>` |
|
||||
| `ref={videoRef}` | `player={useVideoPlayer()}` |
|
||||
| `source={{ uri }}` | Pass to `useVideoPlayer(uri)` |
|
||||
| `resizeMode={ResizeMode.CONTAIN}` | `contentFit="contain"` |
|
||||
| `isLooping` | `player.loop = true` |
|
||||
| `shouldPlay` | `player.play()` in setup |
|
||||
| `isMuted` | `player.muted = true` |
|
||||
| `useNativeControls` | `nativeControls={true}` |
|
||||
| `onPlaybackStatusUpdate` | `useEvent` / `useEventListener` |
|
||||
| `videoRef.current.playAsync()` | `player.play()` |
|
||||
| `videoRef.current.pauseAsync()` | `player.pause()` |
|
||||
| `videoRef.current.replayAsync()` | `player.replay()` |
|
||||
| `videoRef.current.setPositionAsync(ms)` | `player.currentTime = seconds` |
|
||||
| `status.positionMillis` | `player.currentTime` (seconds) |
|
||||
| `status.durationMillis` | `player.duration` (seconds) |
|
||||
| `status.didJustFinish` | `useEventListener(player, 'playToEnd')` |
|
||||
|
||||
## Key Differences
|
||||
|
||||
- **Separate player and view**: Player logic decoupled from the view—one player can be used across multiple views
|
||||
- **Time in seconds**: Uses seconds, not milliseconds
|
||||
- **Event system**: Uses `useEvent`/`useEventListener` from `expo` instead of callback props
|
||||
- **Video preloading**: Create a player without mounting a VideoView to preload for faster transitions
|
||||
- **Built-in caching**: Set `useCaching: true` in VideoSource for persistent offline caching
|
||||
|
||||
## Known Issues
|
||||
|
||||
- **Uninstall expo-av first**: On Android, having both expo-av and expo-video installed can cause VideoView to show a black screen. Uninstall expo-av before installing expo-video
|
||||
- **Android: Reusing players**: Mounting the same player in multiple VideoViews simultaneously can cause black screens on Android (works on iOS)
|
||||
- **Android: currentTime in setup**: Setting `player.currentTime` in the `useVideoPlayer` setup callback may not work on Android—set it after mount instead
|
||||
- **Changing source**: Use `player.replace(newSource)` to change videos without recreating the player
|
||||
|
||||
## API Reference
|
||||
|
||||
https://docs.expo.dev/versions/latest/sdk/video/
|
||||
124
.agents/skills/upgrading-expo/references/native-tabs.md
Normal file
124
.agents/skills/upgrading-expo/references/native-tabs.md
Normal file
@@ -0,0 +1,124 @@
|
||||
# Native Tabs Migration (SDK 55)
|
||||
|
||||
In SDK 55, `Label`, `Icon`, `Badge`, and `VectorIcon` are now accessed as static properties on `NativeTabs.Trigger` rather than separate imports.
|
||||
|
||||
## Import Changes
|
||||
|
||||
```tsx
|
||||
// SDK 53/54
|
||||
import {
|
||||
NativeTabs,
|
||||
Icon,
|
||||
Label,
|
||||
Badge,
|
||||
VectorIcon,
|
||||
} from "expo-router/unstable-native-tabs";
|
||||
|
||||
// SDK 55+
|
||||
import { NativeTabs } from "expo-router/unstable-native-tabs";
|
||||
```
|
||||
|
||||
## Component Changes
|
||||
|
||||
| SDK 53/54 | SDK 55+ |
|
||||
| ---------------- | ----------------------------------- |
|
||||
| `<Icon />` | `<NativeTabs.Trigger.Icon />` |
|
||||
| `<Label />` | `<NativeTabs.Trigger.Label />` |
|
||||
| `<Badge />` | `<NativeTabs.Trigger.Badge />` |
|
||||
| `<VectorIcon />` | `<NativeTabs.Trigger.VectorIcon />` |
|
||||
| (n/a) | `<NativeTabs.BottomAccessory />` |
|
||||
|
||||
## New in SDK 55
|
||||
|
||||
### BottomAccessory
|
||||
|
||||
New component for Apple Music-style mini players on iOS +26 that float above the tab bar:
|
||||
|
||||
```tsx
|
||||
<NativeTabs>
|
||||
<NativeTabs.BottomAccessory>
|
||||
{/* Content above tabs */}
|
||||
</NativeTabs.BottomAccessory>
|
||||
<NativeTabs.Trigger name="(index)">
|
||||
<NativeTabs.Trigger.Label>Home</NativeTabs.Trigger.Label>
|
||||
</NativeTabs.Trigger>
|
||||
</NativeTabs>
|
||||
```
|
||||
|
||||
On Android and web, this component will render as a no-op. Position a view absolutely above the tab bar instead.
|
||||
|
||||
### Icon `md` Prop
|
||||
|
||||
New `md` prop for Material icon glyphs on Android (alongside existing `drawable`):
|
||||
|
||||
```tsx
|
||||
<NativeTabs.Trigger.Icon sf="house" md="home" />
|
||||
```
|
||||
|
||||
## Full Migration Example
|
||||
|
||||
### Before (SDK 53/54)
|
||||
|
||||
```tsx
|
||||
import {
|
||||
NativeTabs,
|
||||
Icon,
|
||||
Label,
|
||||
Badge,
|
||||
} from "expo-router/unstable-native-tabs";
|
||||
|
||||
export default function TabLayout() {
|
||||
return (
|
||||
<NativeTabs minimizeBehavior="onScrollDown">
|
||||
<NativeTabs.Trigger name="(index)">
|
||||
<Label>Home</Label>
|
||||
<Icon sf="house.fill" />
|
||||
<Badge>3</Badge>
|
||||
</NativeTabs.Trigger>
|
||||
<NativeTabs.Trigger name="(settings)">
|
||||
<Label>Settings</Label>
|
||||
<Icon sf="gear" />
|
||||
</NativeTabs.Trigger>
|
||||
<NativeTabs.Trigger name="(search)" role="search">
|
||||
<Label>Search</Label>
|
||||
</NativeTabs.Trigger>
|
||||
</NativeTabs>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### After (SDK 55+)
|
||||
|
||||
```tsx
|
||||
import { NativeTabs } from "expo-router/unstable-native-tabs";
|
||||
|
||||
export default function TabLayout() {
|
||||
return (
|
||||
<NativeTabs minimizeBehavior="onScrollDown">
|
||||
<NativeTabs.Trigger name="(index)">
|
||||
<NativeTabs.Trigger.Label>Home</NativeTabs.Trigger.Label>
|
||||
<NativeTabs.Trigger.Icon sf="house.fill" md="home" />
|
||||
<NativeTabs.Trigger.Badge>3</NativeTabs.Trigger.Badge>
|
||||
</NativeTabs.Trigger>
|
||||
<NativeTabs.Trigger name="(settings)">
|
||||
<NativeTabs.Trigger.Label>Settings</NativeTabs.Trigger.Label>
|
||||
<NativeTabs.Trigger.Icon sf="gear" md="settings" />
|
||||
</NativeTabs.Trigger>
|
||||
<NativeTabs.Trigger name="(search)" role="search">
|
||||
<NativeTabs.Trigger.Label>Search</NativeTabs.Trigger.Label>
|
||||
</NativeTabs.Trigger>
|
||||
</NativeTabs>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Migration Checklist
|
||||
|
||||
1. Remove `Icon`, `Label`, `Badge`, `VectorIcon` from imports
|
||||
2. Keep only `NativeTabs` import from `expo-router/unstable-native-tabs`
|
||||
3. Replace `<Icon />` with `<NativeTabs.Trigger.Icon />`
|
||||
4. Replace `<Label />` with `<NativeTabs.Trigger.Label />`
|
||||
5. Replace `<Badge />` with `<NativeTabs.Trigger.Badge />`
|
||||
6. Replace `<VectorIcon />` with `<NativeTabs.Trigger.VectorIcon />`
|
||||
|
||||
- Read docs for more info https://docs.expo.dev/versions/v55.0.0/sdk/router-native-tabs/
|
||||
79
.agents/skills/upgrading-expo/references/new-architecture.md
Normal file
79
.agents/skills/upgrading-expo/references/new-architecture.md
Normal file
@@ -0,0 +1,79 @@
|
||||
# New Architecture
|
||||
|
||||
The New Architecture is enabled by default in Expo SDK 53+. It replaces the legacy bridge with a faster, synchronous communication layer between JavaScript and native code.
|
||||
|
||||
## Documentation
|
||||
|
||||
Full guide: https://docs.expo.dev/guides/new-architecture/
|
||||
|
||||
## What Changed
|
||||
|
||||
- **JSI (JavaScript Interface)** — Direct synchronous calls between JS and native
|
||||
- **Fabric** — New rendering system with concurrent features
|
||||
- **TurboModules** — Lazy-loaded native modules with type safety
|
||||
|
||||
## SDK Compatibility
|
||||
|
||||
| SDK Version | New Architecture Status |
|
||||
| ----------- | ----------------------- |
|
||||
| SDK 53+ | Enabled by default |
|
||||
| SDK 52 | Opt-in via app.json |
|
||||
| SDK 51- | Experimental |
|
||||
|
||||
## Configuration
|
||||
|
||||
New Architecture is enabled by default. To explicitly disable (not recommended):
|
||||
|
||||
```json
|
||||
{
|
||||
"expo": {
|
||||
"newArchEnabled": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Expo Go
|
||||
|
||||
Expo Go only supports the New Architecture as of SDK 53. Apps using the old architecture must use development builds.
|
||||
|
||||
## Common Migration Issues
|
||||
|
||||
### Native Module Compatibility
|
||||
|
||||
Some older native modules may not support the New Architecture. Check:
|
||||
|
||||
1. Module documentation for New Architecture support
|
||||
2. GitHub issues for compatibility discussions
|
||||
3. Consider alternatives if module is unmaintained
|
||||
|
||||
### Reanimated
|
||||
|
||||
React Native Reanimated requires `react-native-worklets` in SDK 54+:
|
||||
|
||||
```bash
|
||||
npx expo install react-native-worklets
|
||||
```
|
||||
|
||||
### Layout Animations
|
||||
|
||||
Some layout animations behave differently. Test thoroughly after upgrading.
|
||||
|
||||
## Verifying New Architecture
|
||||
|
||||
Check if New Architecture is active:
|
||||
|
||||
```tsx
|
||||
import { Platform } from "react-native";
|
||||
|
||||
// Returns true if Fabric is enabled
|
||||
const isNewArch = global._IS_FABRIC !== undefined;
|
||||
```
|
||||
|
||||
Verify from the command line if the currently running app uses the New Architecture: `bunx xcobra expo eval "_IS_FABRIC"` -> `true`
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
1. **Clear caches** — `npx expo start --clear`
|
||||
2. **Clean prebuild** — `npx expo prebuild --clean`
|
||||
3. **Check native modules** — Ensure all dependencies support New Architecture
|
||||
4. **Review console warnings** — Legacy modules log compatibility warnings
|
||||
79
.agents/skills/upgrading-expo/references/react-19.md
Normal file
79
.agents/skills/upgrading-expo/references/react-19.md
Normal file
@@ -0,0 +1,79 @@
|
||||
# React 19
|
||||
|
||||
React 19 is included in Expo SDK 54. This release simplifies several common patterns.
|
||||
|
||||
## Context Changes
|
||||
|
||||
### useContext → use
|
||||
|
||||
The `use` hook replaces `useContext`:
|
||||
|
||||
```tsx
|
||||
// Before (React 18)
|
||||
import { useContext } from "react";
|
||||
const value = useContext(MyContext);
|
||||
|
||||
// After (React 19)
|
||||
import { use } from "react";
|
||||
const value = use(MyContext);
|
||||
```
|
||||
|
||||
- The `use` hook can also read promises, enabling Suspense-based data fetching.
|
||||
- `use` can be called conditionally, this simplifies components that consume multiple contexts.
|
||||
|
||||
### Context.Provider → Context
|
||||
|
||||
Context providers no longer need the `.Provider` suffix:
|
||||
|
||||
```tsx
|
||||
// Before (React 18)
|
||||
<ThemeContext.Provider value={theme}>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
|
||||
// After (React 19)
|
||||
<ThemeContext value={theme}>
|
||||
{children}
|
||||
</ThemeContext>
|
||||
```
|
||||
|
||||
## ref as a Prop
|
||||
|
||||
### Removing forwardRef
|
||||
|
||||
Components can now receive `ref` as a regular prop. `forwardRef` is no longer needed:
|
||||
|
||||
```tsx
|
||||
// Before (React 18)
|
||||
import { forwardRef } from "react";
|
||||
|
||||
const Input = forwardRef<TextInput, Props>((props, ref) => {
|
||||
return <TextInput ref={ref} {...props} />;
|
||||
});
|
||||
|
||||
// After (React 19)
|
||||
function Input({ ref, ...props }: Props & { ref?: React.Ref<TextInput> }) {
|
||||
return <TextInput ref={ref} {...props} />;
|
||||
}
|
||||
```
|
||||
|
||||
### Migration Steps
|
||||
|
||||
1. Remove `forwardRef` wrapper
|
||||
2. Add `ref` to the props destructuring
|
||||
3. Update the type to include `ref?: React.Ref<T>`
|
||||
|
||||
## Other React 19 Features
|
||||
|
||||
- **Actions** — Functions that handle async transitions
|
||||
- **useOptimistic** — Optimistic UI updates
|
||||
- **useFormStatus** — Form submission state (web)
|
||||
- **Document Metadata** — Native `<title>` and `<meta>` support (web)
|
||||
|
||||
## Cleanup Checklist
|
||||
|
||||
When upgrading to SDK 54:
|
||||
|
||||
- [ ] Replace `useContext` with `use`
|
||||
- [ ] Remove `.Provider` from Context components
|
||||
- [ ] Remove `forwardRef` wrappers, use `ref` prop instead
|
||||
59
.agents/skills/upgrading-expo/references/react-compiler.md
Normal file
59
.agents/skills/upgrading-expo/references/react-compiler.md
Normal file
@@ -0,0 +1,59 @@
|
||||
# React Compiler
|
||||
|
||||
React Compiler is stable in Expo SDK 54 and later. It automatically memoizes components and hooks, eliminating the need for manual `useMemo`, `useCallback`, and `React.memo`.
|
||||
|
||||
## Enabling React Compiler
|
||||
|
||||
Add to `app.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"expo": {
|
||||
"experiments": {
|
||||
"reactCompiler": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## What React Compiler Does
|
||||
|
||||
- Automatically memoizes components and values
|
||||
- Eliminates unnecessary re-renders
|
||||
- Removes the need for manual `useMemo` and `useCallback`
|
||||
- Works with existing code without modifications
|
||||
|
||||
## Cleanup After Enabling
|
||||
|
||||
Once React Compiler is enabled, you can remove manual memoization:
|
||||
|
||||
```tsx
|
||||
// Before (manual memoization)
|
||||
const memoizedValue = useMemo(() => computeExpensive(a, b), [a, b]);
|
||||
const memoizedCallback = useCallback(() => doSomething(a), [a]);
|
||||
const MemoizedComponent = React.memo(MyComponent);
|
||||
|
||||
// After (React Compiler handles it)
|
||||
const value = computeExpensive(a, b);
|
||||
const callback = () => doSomething(a);
|
||||
// Just use MyComponent directly
|
||||
```
|
||||
|
||||
## Requirements
|
||||
|
||||
- Expo SDK 54 or later
|
||||
- New Architecture enabled (default in SDK 54+)
|
||||
|
||||
## Verifying It's Working
|
||||
|
||||
React Compiler runs at build time. Check the Metro bundler output for compilation messages. You can also use React DevTools to verify components are being optimized.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
If you encounter issues:
|
||||
|
||||
1. Ensure New Architecture is enabled
|
||||
2. Clear Metro cache: `npx expo start --clear`
|
||||
3. Check for incompatible patterns in your code (rare)
|
||||
|
||||
React Compiler is designed to work with idiomatic React code. If it can't safely optimize a component, it skips that component without breaking your app.
|
||||
@@ -0,0 +1,61 @@
|
||||
# Migrating from react-navigation to expo-router
|
||||
|
||||
In SDK 56+, application code must not import from `@react-navigation/*` directly. Repoint those imports to the matching `expo-router` entry points. Runtime API is unchanged — only the module specifiers move.
|
||||
|
||||
## Steps
|
||||
|
||||
1. Prefer the automated codemod (see below). If it is not viable, fall back to the manual mapping.
|
||||
2. Replace imports using the table. Use the entry point that matches the `@react-navigation/*` source.
|
||||
3. After rewriting, check whether any of the rewritten imports are deprecated in `expo-router` (see [Check for deprecated imports](#check-for-deprecated-imports)). If so, surface the deprecation reason and the suggested replacement to the user before continuing.
|
||||
4. Validate: search for remaining `@react-navigation/` references in source files, then run typecheck/build/start.
|
||||
5. Remove `@react-navigation/*` packages that are no longer imported from `package.json` and reinstall (delete `node_modules` if needed).
|
||||
|
||||
## Automated migration (preferred)
|
||||
|
||||
Run from the project root over your application code (replace `src` with the actual directory or glob):
|
||||
|
||||
```sh
|
||||
npx expo-codemod sdk-56-expo-router-react-navigation-replace src
|
||||
```
|
||||
|
||||
```sh
|
||||
npx expo-codemod sdk-56-expo-router-react-navigation-replace '**/*.{ts,tsx,js,jsx}'
|
||||
```
|
||||
|
||||
## Manual API mapping
|
||||
|
||||
| React Navigation source | Expo Router target |
|
||||
| ------------------------------------- | ------------------------------------------------------------------------ |
|
||||
| `@react-navigation/native` | `expo-router/react-navigation` |
|
||||
| `@react-navigation/core` | `expo-router/react-navigation` |
|
||||
| `@react-navigation/elements` | `expo-router/react-navigation` |
|
||||
| `@react-navigation/routers` | `expo-router/react-navigation` |
|
||||
| `@react-navigation/stack` | `expo-router/js-stack` |
|
||||
| `@react-navigation/bottom-tabs` | `expo-router/js-tabs` |
|
||||
| `@react-navigation/material-top-tabs` | `expo-router/js-top-tabs` |
|
||||
| `@react-navigation/native-stack` | No direct equivalent. Use the `Stack` layout from `expo-router` instead. |
|
||||
|
||||
**Stack caveat:** Do NOT rewrite `import { Stack } from "expo-router"` to `expo-router/js-stack`. The root `Stack` is the Expo Router layout component used in route files; only use `expo-router/js-stack` when replacing a `@react-navigation/stack` JS stack navigator.
|
||||
|
||||
If you encounter a symbol that has no replacement, ask the user to file an issue in the `expo/expo` repository describing what is needed and why.
|
||||
|
||||
## Check for deprecated imports
|
||||
|
||||
A successful rewrite to `expo-router/*` does not guarantee the new import is the recommended one. Some symbols are re-exported as deprecated shims and the project may need to migrate further (for example, to a different `expo-router` API or to a first-party Expo package).
|
||||
|
||||
For each symbol rewritten in step 2:
|
||||
|
||||
1. Resolve the rewritten module to its source in `node_modules` (e.g., `node_modules/expo-router/build/react-navigation.d.ts`, `js-stack`, `js-tabs`, `js-top-tabs`).
|
||||
2. Look for a `@deprecated` JSDoc tag on the named export, or a runtime deprecation warning in the implementation file.
|
||||
3. If deprecated, capture both the reason and the recommended replacement from the JSDoc/comment.
|
||||
4. Report each deprecated symbol to the user with: the import path, the symbol, the deprecation reason, and the suggested replacement. Wait for the user to confirm before mass-applying further changes.
|
||||
|
||||
## Done when
|
||||
|
||||
1. No `@react-navigation/*` imports remain in source files.
|
||||
2. No unused `@react-navigation/*` entries remain in `package.json`.
|
||||
3. Typecheck and bundler start without `@react-navigation/*` errors.
|
||||
|
||||
## Reference
|
||||
|
||||
- Official Expo Router SDK 55 → 56 migration guide: https://docs.expo.dev/router/migrate/sdk-55-to-56
|
||||
@@ -1,43 +0,0 @@
|
||||
---
|
||||
name: gpg-commit-signing
|
||||
description: Sign git commits with GPG in non-interactive environments. Use when committing code and the `GPG_PRIVATE_KEY_PASSPHRASE` environment variable is available. Triggers on "commit", "sign commit", "GPG", "git commit -S", or any git operation requiring signed commits.
|
||||
---
|
||||
|
||||
# GPG Commit Signing
|
||||
|
||||
Sign commits in headless/non-interactive environments where `/dev/tty` is unavailable.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Check whether `GPG_PRIVATE_KEY_PASSPHRASE` is set:
|
||||
|
||||
```bash
|
||||
test -n "$GPG_PRIVATE_KEY_PASSPHRASE" && echo "available" || echo "not set"
|
||||
```
|
||||
|
||||
If not set, skip signing — commit without `-S`.
|
||||
|
||||
2. Try a direct signed commit first — the environment may already have loopback pinentry configured:
|
||||
|
||||
```bash
|
||||
git commit -S -m "message"
|
||||
```
|
||||
|
||||
If this succeeds, no further steps are needed.
|
||||
|
||||
3. If step 2 fails with a `/dev/tty` error, use `--pinentry-mode loopback` via a wrapper script:
|
||||
|
||||
```bash
|
||||
printf '#!/bin/sh\ngpg --batch --pinentry-mode loopback --passphrase "$GPG_PRIVATE_KEY_PASSPHRASE" "$@"\n' > /tmp/gpg-sign.sh
|
||||
chmod +x /tmp/gpg-sign.sh
|
||||
git -c gpg.program=/tmp/gpg-sign.sh commit -S -m "message"
|
||||
rm /tmp/gpg-sign.sh
|
||||
```
|
||||
|
||||
This passes the passphrase directly to gpg on each signing invocation, bypassing the need for a configured gpg-agent.
|
||||
|
||||
## Anti-patterns
|
||||
|
||||
- Do not echo or log `GPG_PRIVATE_KEY_PASSPHRASE`.
|
||||
- Do not commit without `-S` when the passphrase is available — the project expects signed commits.
|
||||
- Do not leave wrapper scripts on disk after committing.
|
||||
@@ -11,7 +11,7 @@
|
||||
"dockerfile": "Dockerfile"
|
||||
},
|
||||
"postCreateCommand": "bun install",
|
||||
"postStartCommand": "./scripts/setup-git.sh && ./scripts/setup-nvim.sh",
|
||||
"postStartCommand": "./scripts/setup-git.sh && ./scripts/setup-nvim.sh && ./scripts/setup-tailscale.sh",
|
||||
// Features add additional features to your environment. See https://containers.dev/features
|
||||
// Beware: features are not supported on all platforms and may have unintended side-effects.
|
||||
"features": {
|
||||
|
||||
2
.github/workflows/build-waitlist-website.yml
vendored
2
.github/workflows/build-waitlist-website.yml
vendored
@@ -11,7 +11,7 @@ on:
|
||||
|
||||
env:
|
||||
REGISTRY: cr.nym.sh
|
||||
IMAGE_NAME: aelis-waitlist-website
|
||||
IMAGE_NAME: freya-waitlist-website
|
||||
|
||||
jobs:
|
||||
build:
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
services:
|
||||
expo:
|
||||
name: Expo Dev Server
|
||||
description: Expo development server for aelis-client
|
||||
triggeredBy:
|
||||
- postDevcontainerStart
|
||||
commands:
|
||||
start: cd apps/aelis-client && ./scripts/run-dev-server.sh
|
||||
|
||||
drizzle-studio:
|
||||
name: Drizzle Studio
|
||||
description: Drizzle Studio database browser for aelis-backend
|
||||
triggeredBy:
|
||||
- manual
|
||||
commands:
|
||||
start: |
|
||||
FORWARD_URL=$(gitpod environment port open 4983 --name drizzle-studio-server | sed 's|https://||')
|
||||
echo "Drizzle Studio: https://local.drizzle.studio/?host=${FORWARD_URL}&port=443"
|
||||
cd apps/aelis-backend && bunx drizzle-kit studio --host 0.0.0.0 --port 4983
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"$schema": "./node_modules/oxfmt/configuration_schema.json",
|
||||
"useTabs": true,
|
||||
"semi": false,
|
||||
"trailingComma": "all",
|
||||
"experimentalSortImports": {
|
||||
"order": "asc",
|
||||
"ignoreCase": true,
|
||||
"newlinesBetween": true
|
||||
},
|
||||
"ignorePatterns": [".claude", "fixtures"]
|
||||
"$schema": "./node_modules/oxfmt/configuration_schema.json",
|
||||
"useTabs": true,
|
||||
"semi": false,
|
||||
"trailingComma": "all",
|
||||
"experimentalSortImports": {
|
||||
"order": "asc",
|
||||
"ignoreCase": true,
|
||||
"newlinesBetween": true
|
||||
},
|
||||
"ignorePatterns": [".claude", ".ona", "drizzle", "fixtures"]
|
||||
}
|
||||
|
||||
3
.vscode/settings.json
vendored
Normal file
3
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"js/ts.experimental.useTsgo": true
|
||||
}
|
||||
20
.zed/settings.json
Normal file
20
.zed/settings.json
Normal file
@@ -0,0 +1,20 @@
|
||||
// Folder-specific settings
|
||||
//
|
||||
// For a full list of overridable settings, and general information on folder-specific settings,
|
||||
// see the documentation: https://zed.dev/docs/configuring-zed#settings-files
|
||||
{
|
||||
"languages": {
|
||||
"TypeScript": {
|
||||
"formatter": { "language_server": { "name": "oxfmt" } }
|
||||
},
|
||||
"TSX": {
|
||||
"formatter": { "language_server": { "name": "oxfmt" } }
|
||||
},
|
||||
"JavaScript": {
|
||||
"formatter": { "language_server": { "name": "oxfmt" } }
|
||||
},
|
||||
"JSX": {
|
||||
"formatter": { "language_server": { "name": "oxfmt" } }
|
||||
}
|
||||
}
|
||||
}
|
||||
13
AGENTS.md
13
AGENTS.md
@@ -2,7 +2,7 @@
|
||||
|
||||
## Project
|
||||
|
||||
AELIS is an AI-powered personal assistant that aggregates data from various sources into a contextual feed. Monorepo with `packages/` (shared libraries) and `apps/` (applications).
|
||||
FREYA is an AI-powered personal assistant that aggregates data from various sources into a contextual feed. Monorepo with `packages/` (shared libraries) and `apps/` (applications).
|
||||
|
||||
## Commands
|
||||
|
||||
@@ -39,4 +39,13 @@ Use Bun exclusively. Do not use npm or yarn.
|
||||
|
||||
- Branch: `feat/<task>`, `fix/<task>`, `ci/<task>`, etc.
|
||||
- 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>`.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# aelis
|
||||
# freya
|
||||
|
||||
To install dependencies:
|
||||
|
||||
@@ -8,14 +8,14 @@ bun install
|
||||
|
||||
## Packages
|
||||
|
||||
### @aelis/source-tfl
|
||||
### @freya/source-tfl
|
||||
|
||||
TfL (Transport for London) feed source for tube, overground, and Elizabeth line alerts.
|
||||
|
||||
#### Testing
|
||||
|
||||
```bash
|
||||
cd packages/aelis-source-tfl
|
||||
cd packages/freya-source-tfl
|
||||
bun run test
|
||||
```
|
||||
|
||||
|
||||
24
apps/admin-dashboard/.gitignore
vendored
Normal file
24
apps/admin-dashboard/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
21
apps/admin-dashboard/README.md
Normal file
21
apps/admin-dashboard/README.md
Normal file
@@ -0,0 +1,21 @@
|
||||
# React + TypeScript + Vite + shadcn/ui
|
||||
|
||||
This is a template for a new Vite project with React, TypeScript, and shadcn/ui.
|
||||
|
||||
## Adding components
|
||||
|
||||
To add components to your app, run the following command:
|
||||
|
||||
```bash
|
||||
npx shadcn@latest add button
|
||||
```
|
||||
|
||||
This will place the ui components in the `src/components` directory.
|
||||
|
||||
## Using components
|
||||
|
||||
To use the components in your app, import them as follows:
|
||||
|
||||
```tsx
|
||||
import { Button } from "@/components/ui/button"
|
||||
```
|
||||
25
apps/admin-dashboard/components.json
Normal file
25
apps/admin-dashboard/components.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "radix-mira",
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "src/index.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"iconLibrary": "lucide",
|
||||
"rtl": false,
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"menuColor": "default",
|
||||
"menuAccent": "subtle",
|
||||
"registries": {}
|
||||
}
|
||||
13
apps/admin-dashboard/index.html
Normal file
13
apps/admin-dashboard/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>vite-app</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
40
apps/admin-dashboard/package.json
Normal file
40
apps/admin-dashboard/package.json
Normal file
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"name": "admin-dashboard",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "oxlint .",
|
||||
"format": "oxfmt --write .",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource-variable/inter": "^5.2.8",
|
||||
"@tailwindcss/vite": "^4.1.17",
|
||||
"@tanstack/react-query": "^5.95.0",
|
||||
"@tanstack/react-router": "^1.168.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.577.0",
|
||||
"next-themes": "^0.4.6",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
"shadcn": "^4.0.8",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tailwindcss": "^4.1.17",
|
||||
"tw-animate-css": "^1.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.10.1",
|
||||
"@types/react": "^19.2.5",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.1.1",
|
||||
"typescript": "^6",
|
||||
"vite": "^7.2.4"
|
||||
}
|
||||
}
|
||||
1
apps/admin-dashboard/public/vite.svg
Normal file
1
apps/admin-dashboard/public/vite.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
25
apps/admin-dashboard/src/App.tsx
Normal file
25
apps/admin-dashboard/src/App.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { useQueryClient, type QueryClient } from "@tanstack/react-query"
|
||||
import { createRouter, RouterProvider } from "@tanstack/react-router"
|
||||
|
||||
import { routeTree } from "./route-tree.gen"
|
||||
|
||||
const router = createRouter({
|
||||
routeTree,
|
||||
defaultPreload: "intent",
|
||||
context: {
|
||||
queryClient: undefined! as QueryClient,
|
||||
},
|
||||
})
|
||||
|
||||
declare module "@tanstack/react-router" {
|
||||
interface Register {
|
||||
router: typeof router
|
||||
}
|
||||
}
|
||||
|
||||
export function App() {
|
||||
const queryClient = useQueryClient()
|
||||
return <RouterProvider router={router} context={{ queryClient }} />
|
||||
}
|
||||
|
||||
export default App
|
||||
1
apps/admin-dashboard/src/assets/react.svg
Normal file
1
apps/admin-dashboard/src/assets/react.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
144
apps/admin-dashboard/src/components/feed-panel.tsx
Normal file
144
apps/admin-dashboard/src/components/feed-panel.tsx
Normal file
@@ -0,0 +1,144 @@
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { Loader2, RefreshCw, TriangleAlert } from "lucide-react"
|
||||
import { useState } from "react"
|
||||
|
||||
import type { FeedItem } from "@/lib/api"
|
||||
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { fetchFeed } from "@/lib/api"
|
||||
|
||||
export function FeedPanel() {
|
||||
const {
|
||||
data: feed,
|
||||
error: feedError,
|
||||
isFetching,
|
||||
refetch,
|
||||
} = useQuery({
|
||||
queryKey: ["feed"],
|
||||
queryFn: fetchFeed,
|
||||
enabled: false,
|
||||
})
|
||||
|
||||
const error = feedError?.message ?? null
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-2xl space-y-6">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-lg font-semibold tracking-tight">Feed</h2>
|
||||
<p className="text-sm text-muted-foreground">Query the feed as the current user.</p>
|
||||
</div>
|
||||
<Button onClick={() => refetch()} disabled={isFetching} size="sm">
|
||||
{isFetching ? (
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="size-3.5" />
|
||||
)}
|
||||
{feed ? "Refresh" : "Fetch"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<Card className="-mx-4 border-destructive">
|
||||
<CardContent className="flex items-center gap-2 text-sm text-destructive">
|
||||
<TriangleAlert className="size-4 shrink-0" />
|
||||
{error}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{feed && feed.errors.length > 0 && (
|
||||
<Card className="-mx-4">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm">Source Errors</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{feed.errors.map((e) => (
|
||||
<div key={e.sourceId} className="flex items-start gap-2 text-sm">
|
||||
<Badge variant="outline" className="shrink-0 font-mono text-xs">
|
||||
{e.sourceId}
|
||||
</Badge>
|
||||
<span className="select-text text-muted-foreground">{e.error}</span>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{feed && (
|
||||
<div className="space-y-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{feed.items.length} {feed.items.length === 1 ? "item" : "items"}
|
||||
</p>
|
||||
{feed.items.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">No items in feed.</p>
|
||||
)}
|
||||
{feed.items.map((item) => (
|
||||
<FeedItemCard key={item.id} item={item} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FeedItemCard({ item }: { item: FeedItem }) {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
|
||||
return (
|
||||
<Card className="-mx-4">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<CardTitle className="text-sm">{item.type}</CardTitle>
|
||||
<Badge variant="secondary" className="font-mono text-xs">
|
||||
{item.sourceId}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{item.signals?.timeRelevance && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{item.signals.timeRelevance}
|
||||
</Badge>
|
||||
)}
|
||||
{item.signals?.urgency !== undefined && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
urgency: {item.signals.urgency}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<p className="select-text font-mono text-xs text-muted-foreground">{item.id}</p>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{item.slots && Object.keys(item.slots).length > 0 && (
|
||||
<div className="space-y-1.5">
|
||||
{Object.entries(item.slots).map(([name, slot]) => (
|
||||
<div key={name} className="text-sm">
|
||||
<span className="font-medium">{name}: </span>
|
||||
<span className="select-text text-muted-foreground">
|
||||
{slot.content ?? <span className="italic">pending</span>}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-auto px-0 text-xs text-muted-foreground"
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
>
|
||||
{expanded ? "Hide" : "Show"} raw data
|
||||
</Button>
|
||||
{expanded && (
|
||||
<pre className="select-text overflow-auto rounded-md bg-muted p-3 font-mono text-xs">
|
||||
{JSON.stringify(item.data, null, 2)}
|
||||
</pre>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { CircleCheck, CircleX, Loader2 } from "lucide-react"
|
||||
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { getServerUrl } from "@/lib/server-url"
|
||||
|
||||
async function checkHealth(serverUrl: string): Promise<boolean> {
|
||||
const res = await fetch(`${serverUrl}/health`)
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
const data = (await res.json()) as { status: string }
|
||||
if (data.status !== "ok") throw new Error("Unexpected response")
|
||||
return true
|
||||
}
|
||||
|
||||
export function GeneralSettingsPanel() {
|
||||
const serverUrl = getServerUrl()
|
||||
|
||||
const { isLoading, isError, error } = useQuery({
|
||||
queryKey: ["health", serverUrl],
|
||||
queryFn: () => checkHealth(serverUrl),
|
||||
})
|
||||
|
||||
const status = isLoading ? "checking" : isError ? "error" : "ok"
|
||||
const errorMsg = error instanceof Error ? error.message : null
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-xl space-y-6">
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-lg font-semibold tracking-tight">General</h2>
|
||||
<p className="text-sm text-muted-foreground">Backend server information.</p>
|
||||
</div>
|
||||
|
||||
<Card className="-mx-4">
|
||||
<CardHeader className="pb-4">
|
||||
<CardTitle className="text-sm">Server</CardTitle>
|
||||
<CardDescription>Connected backend instance.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3 text-sm">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<span className="shrink-0 text-muted-foreground">URL</span>
|
||||
<span className="select-text truncate font-mono text-xs">{serverUrl}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-muted-foreground">Status</span>
|
||||
{status === "checking" && (
|
||||
<span className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
Checking…
|
||||
</span>
|
||||
)}
|
||||
{status === "ok" && (
|
||||
<span className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<CircleCheck className="size-3.5 text-primary" />
|
||||
Connected
|
||||
</span>
|
||||
)}
|
||||
{status === "error" && (
|
||||
<span className="flex items-center gap-1.5 text-xs text-destructive">
|
||||
<CircleX className="size-3.5" />
|
||||
{errorMsg ?? "Unreachable"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
98
apps/admin-dashboard/src/components/login-page.tsx
Normal file
98
apps/admin-dashboard/src/components/login-page.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
import { useMutation } from "@tanstack/react-query"
|
||||
import { Loader2, Settings2 } from "lucide-react"
|
||||
import { useState } from "react"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import type { AuthSession } from "@/lib/auth"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { signIn } from "@/lib/auth"
|
||||
import { getServerUrl, setServerUrl } from "@/lib/server-url"
|
||||
|
||||
interface LoginPageProps {
|
||||
onLogin: (session: AuthSession) => void
|
||||
}
|
||||
|
||||
export function LoginPage({ onLogin }: LoginPageProps) {
|
||||
const [serverUrlInput, setServerUrlInput] = useState(getServerUrl)
|
||||
const [email, setEmail] = useState("")
|
||||
const [password, setPassword] = useState("")
|
||||
|
||||
const loginMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
setServerUrl(serverUrlInput)
|
||||
return signIn(email, password)
|
||||
},
|
||||
onSuccess(session) {
|
||||
onLogin(session)
|
||||
},
|
||||
onError(err) {
|
||||
toast.error(err.message)
|
||||
},
|
||||
})
|
||||
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
loginMutation.mutate()
|
||||
}
|
||||
|
||||
const loading = loginMutation.isPending
|
||||
|
||||
return (
|
||||
<div className="flex min-h-svh items-center justify-center bg-background p-4">
|
||||
<Card className="w-full max-w-sm">
|
||||
<CardHeader className="text-center">
|
||||
<div className="mx-auto mb-2 flex size-10 items-center justify-center rounded-lg bg-primary/10">
|
||||
<Settings2 className="size-5 text-primary" />
|
||||
</div>
|
||||
<CardTitle>Admin Dashboard</CardTitle>
|
||||
<CardDescription>Sign in to manage source configuration.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="server-url">Server URL</Label>
|
||||
<Input
|
||||
id="server-url"
|
||||
type="url"
|
||||
value={serverUrlInput}
|
||||
onChange={(e) => setServerUrlInput(e.target.value)}
|
||||
placeholder="http://localhost:3000"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="admin@freya.local"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button type="submit" className="w-full" disabled={loading}>
|
||||
{loading && <Loader2 className="size-4 animate-spin" />}
|
||||
{loading ? "Signing in…" : "Sign in"}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
639
apps/admin-dashboard/src/components/reminder-crud-panel.tsx
Normal file
639
apps/admin-dashboard/src/components/reminder-crud-panel.tsx
Normal file
@@ -0,0 +1,639 @@
|
||||
import type { Dispatch, FormEvent, SetStateAction } from "react"
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
||||
import { Check, Loader2, Pencil, Plus, RefreshCw, RotateCcw, Save, Trash2, X } from "lucide-react"
|
||||
import { useMemo, useState } from "react"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import type { FeedItem } from "@/lib/api"
|
||||
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { executeSourceAction, fetchFeed } from "@/lib/api"
|
||||
|
||||
const REMINDER_SOURCE_ID = "freya.reminders"
|
||||
|
||||
type ReminderPriority = "low" | "normal" | "high"
|
||||
type ReminderFrequency = "daily" | "weekly" | "monthly" | "yearly"
|
||||
type ReminderEditScope = "this-occurrence" | "this-and-future" | "entire-series"
|
||||
|
||||
interface ReminderRecurrence {
|
||||
frequency: ReminderFrequency
|
||||
interval: number
|
||||
count?: number
|
||||
until?: string
|
||||
}
|
||||
|
||||
interface ReminderFeedData extends Record<string, unknown> {
|
||||
reminderId: string
|
||||
occurrenceId: string
|
||||
title: string
|
||||
notes: string | null
|
||||
originalDueAt: string
|
||||
dueAt: string
|
||||
timeZone: string
|
||||
recurrence: ReminderRecurrence | null
|
||||
priority: ReminderPriority
|
||||
completedAt: string | null
|
||||
}
|
||||
|
||||
interface ReminderFormState {
|
||||
title: string
|
||||
notes: string
|
||||
dueAt: string
|
||||
priority: ReminderPriority
|
||||
scope: ReminderEditScope
|
||||
recurs: boolean
|
||||
frequency: ReminderFrequency
|
||||
interval: string
|
||||
count: string
|
||||
until: string
|
||||
}
|
||||
|
||||
const emptyForm: ReminderFormState = {
|
||||
title: "",
|
||||
notes: "",
|
||||
dueAt: toLocalInput(new Date()),
|
||||
priority: "normal",
|
||||
scope: "entire-series",
|
||||
recurs: false,
|
||||
frequency: "daily",
|
||||
interval: "1",
|
||||
count: "",
|
||||
until: "",
|
||||
}
|
||||
|
||||
export function ReminderCrudPanel() {
|
||||
const queryClient = useQueryClient()
|
||||
const [form, setForm] = useState<ReminderFormState>(emptyForm)
|
||||
const [editing, setEditing] = useState<ReminderFeedData | null>(null)
|
||||
const [deleteScopes, setDeleteScopes] = useState<Record<string, ReminderEditScope>>({})
|
||||
|
||||
const {
|
||||
data: feed,
|
||||
isFetching,
|
||||
refetch,
|
||||
} = useQuery({
|
||||
queryKey: ["feed"],
|
||||
queryFn: fetchFeed,
|
||||
})
|
||||
|
||||
const reminders = useMemo(
|
||||
() => (feed?.items ?? []).filter(isReminderItem).map((item) => item.data),
|
||||
[feed],
|
||||
)
|
||||
|
||||
const actionMutation = useMutation({
|
||||
mutationFn: (input: { actionId: string; params: unknown }) =>
|
||||
executeSourceAction(REMINDER_SOURCE_ID, input.actionId, input.params),
|
||||
})
|
||||
|
||||
const busy = actionMutation.isPending
|
||||
const canConfigureRecurrence = !editing || form.scope !== "this-occurrence"
|
||||
|
||||
async function runAction(actionId: string, params: unknown, success: string): Promise<boolean> {
|
||||
try {
|
||||
await actionMutation.mutateAsync({ actionId, params })
|
||||
await queryClient.invalidateQueries({ queryKey: ["feed"] })
|
||||
toast.success(success)
|
||||
return true
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : String(err))
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault()
|
||||
|
||||
if (editing) {
|
||||
const patch = formToPatch(formFromReminder(editing), form)
|
||||
if (Object.keys(patch).length === 0) {
|
||||
toast.info("No changes to save")
|
||||
return
|
||||
}
|
||||
|
||||
const saved = await runAction(
|
||||
"update-reminder",
|
||||
{
|
||||
reminderId: editing.reminderId,
|
||||
scope: form.scope,
|
||||
occurrenceDueAt: editing.originalDueAt,
|
||||
patch,
|
||||
},
|
||||
"Reminder updated",
|
||||
)
|
||||
if (saved) resetForm()
|
||||
return
|
||||
} else {
|
||||
const created = await runAction(
|
||||
"create-reminder",
|
||||
formToCreatePayload(form),
|
||||
"Reminder created",
|
||||
)
|
||||
if (created) resetForm()
|
||||
}
|
||||
}
|
||||
|
||||
function startEdit(reminder: ReminderFeedData) {
|
||||
setEditing(reminder)
|
||||
setForm(formFromReminder(reminder))
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
setEditing(null)
|
||||
setForm({ ...emptyForm, dueAt: toLocalInput(new Date()) })
|
||||
}
|
||||
|
||||
function getDeleteScope(reminder: ReminderFeedData): ReminderEditScope {
|
||||
return (
|
||||
deleteScopes[reminderKey(reminder)] ??
|
||||
(reminder.recurrence ? "this-occurrence" : "entire-series")
|
||||
)
|
||||
}
|
||||
|
||||
function setDeleteScope(reminder: ReminderFeedData, scope: ReminderEditScope) {
|
||||
setDeleteScopes((prev) => ({ ...prev, [reminderKey(reminder)]: scope }))
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="-mx-4">
|
||||
<CardHeader className="pb-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<CardTitle className="text-sm">Reminders</CardTitle>
|
||||
<Button size="sm" variant="outline" onClick={() => refetch()} disabled={isFetching}>
|
||||
{isFetching ? (
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="size-3.5" />
|
||||
)}
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-5">
|
||||
<form className="grid gap-4" onSubmit={handleSubmit}>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="space-y-2 sm:col-span-2">
|
||||
<Label htmlFor="reminder-title" className="text-xs font-medium">
|
||||
Title
|
||||
</Label>
|
||||
<Input
|
||||
id="reminder-title"
|
||||
value={form.title}
|
||||
onChange={(event) => setFormField(setForm, "title", event.target.value)}
|
||||
disabled={busy}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 sm:col-span-2">
|
||||
<Label htmlFor="reminder-notes" className="text-xs font-medium">
|
||||
Notes
|
||||
</Label>
|
||||
<Input
|
||||
id="reminder-notes"
|
||||
value={form.notes}
|
||||
onChange={(event) => setFormField(setForm, "notes", event.target.value)}
|
||||
disabled={busy}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="reminder-due-at" className="text-xs font-medium">
|
||||
Due
|
||||
</Label>
|
||||
<Input
|
||||
id="reminder-due-at"
|
||||
type="datetime-local"
|
||||
value={form.dueAt}
|
||||
onChange={(event) => setFormField(setForm, "dueAt", event.target.value)}
|
||||
disabled={busy}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="reminder-priority" className="text-xs font-medium">
|
||||
Priority
|
||||
</Label>
|
||||
<Select
|
||||
value={form.priority}
|
||||
onValueChange={(value) =>
|
||||
setFormField(setForm, "priority", value as ReminderPriority)
|
||||
}
|
||||
disabled={busy}
|
||||
>
|
||||
<SelectTrigger id="reminder-priority">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="low">Low</SelectItem>
|
||||
<SelectItem value="normal">Normal</SelectItem>
|
||||
<SelectItem value="high">High</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{editing?.recurrence && (
|
||||
<div className="space-y-2 sm:col-span-2">
|
||||
<Label htmlFor="reminder-edit-scope" className="text-xs font-medium">
|
||||
Edit scope
|
||||
</Label>
|
||||
<Select
|
||||
value={form.scope}
|
||||
onValueChange={(value) =>
|
||||
setFormField(setForm, "scope", value as ReminderEditScope)
|
||||
}
|
||||
disabled={busy}
|
||||
>
|
||||
<SelectTrigger id="reminder-edit-scope">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="this-occurrence">This occurrence</SelectItem>
|
||||
<SelectItem value="this-and-future">This and future</SelectItem>
|
||||
<SelectItem value="entire-series">Entire series</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{canConfigureRecurrence && (
|
||||
<div className="grid gap-3 rounded-md border p-3 sm:grid-cols-4">
|
||||
<div className="flex items-center justify-between gap-3 sm:col-span-4">
|
||||
<Label htmlFor="reminder-recurs" className="text-xs font-medium">
|
||||
Recurring
|
||||
</Label>
|
||||
<Switch
|
||||
id="reminder-recurs"
|
||||
checked={form.recurs}
|
||||
onCheckedChange={(checked) => setFormField(setForm, "recurs", checked)}
|
||||
disabled={busy}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{form.recurs && (
|
||||
<>
|
||||
<div className="space-y-2 sm:col-span-2">
|
||||
<Label htmlFor="reminder-frequency" className="text-xs font-medium">
|
||||
Frequency
|
||||
</Label>
|
||||
<Select
|
||||
value={form.frequency}
|
||||
onValueChange={(value) =>
|
||||
setFormField(setForm, "frequency", value as ReminderFrequency)
|
||||
}
|
||||
disabled={busy}
|
||||
>
|
||||
<SelectTrigger id="reminder-frequency">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="daily">Daily</SelectItem>
|
||||
<SelectItem value="weekly">Weekly</SelectItem>
|
||||
<SelectItem value="monthly">Monthly</SelectItem>
|
||||
<SelectItem value="yearly">Yearly</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="reminder-interval" className="text-xs font-medium">
|
||||
Interval
|
||||
</Label>
|
||||
<Input
|
||||
id="reminder-interval"
|
||||
type="number"
|
||||
min={1}
|
||||
value={form.interval}
|
||||
onChange={(event) => setFormField(setForm, "interval", event.target.value)}
|
||||
disabled={busy}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="reminder-count" className="text-xs font-medium">
|
||||
Count
|
||||
</Label>
|
||||
<Input
|
||||
id="reminder-count"
|
||||
type="number"
|
||||
min={1}
|
||||
value={form.count}
|
||||
onChange={(event) => setFormField(setForm, "count", event.target.value)}
|
||||
disabled={busy}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 sm:col-span-4">
|
||||
<Label htmlFor="reminder-until" className="text-xs font-medium">
|
||||
Until
|
||||
</Label>
|
||||
<Input
|
||||
id="reminder-until"
|
||||
type="datetime-local"
|
||||
value={form.until}
|
||||
onChange={(event) => setFormField(setForm, "until", event.target.value)}
|
||||
disabled={busy}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
{editing && (
|
||||
<Button type="button" variant="outline" onClick={resetForm} disabled={busy}>
|
||||
<X className="size-3.5" />
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
<Button type="submit" disabled={busy || !form.title || !form.dueAt}>
|
||||
{busy ? <Loader2 className="size-3.5 animate-spin" /> : <Save className="size-3.5" />}
|
||||
{editing ? "Update" : "Create"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>
|
||||
{reminders.length} {reminders.length === 1 ? "occurrence" : "occurrences"}
|
||||
</span>
|
||||
{!editing && (
|
||||
<Button size="sm" variant="ghost" onClick={resetForm} disabled={busy}>
|
||||
<Plus className="size-3.5" />
|
||||
New
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{reminders.length === 0 && (
|
||||
<div className="rounded-md border border-dashed px-3 py-6 text-center text-sm text-muted-foreground">
|
||||
No reminders in the current feed.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{reminders.map((reminder) => {
|
||||
const deleteScope = getDeleteScope(reminder)
|
||||
return (
|
||||
<ReminderRow
|
||||
key={reminderKey(reminder)}
|
||||
reminder={reminder}
|
||||
busy={busy}
|
||||
deleteScope={deleteScope}
|
||||
onDeleteScopeChange={(scope) => setDeleteScope(reminder, scope)}
|
||||
onEdit={() => startEdit(reminder)}
|
||||
onComplete={() =>
|
||||
runAction(
|
||||
reminder.completedAt ? "uncomplete-reminder" : "complete-reminder",
|
||||
{
|
||||
reminderId: reminder.reminderId,
|
||||
occurrenceDueAt: reminder.originalDueAt,
|
||||
},
|
||||
reminder.completedAt ? "Reminder reopened" : "Reminder completed",
|
||||
)
|
||||
}
|
||||
onDelete={() => {
|
||||
if (
|
||||
!confirm(
|
||||
`Delete ${formatScope(deleteScope).toLowerCase()} for "${reminder.title}"?`,
|
||||
)
|
||||
) {
|
||||
return
|
||||
}
|
||||
void runAction(
|
||||
"delete-reminder",
|
||||
{
|
||||
reminderId: reminder.reminderId,
|
||||
scope: deleteScope,
|
||||
occurrenceDueAt: reminder.originalDueAt,
|
||||
},
|
||||
"Reminder deleted",
|
||||
).then((deleted) => {
|
||||
if (deleted && editing?.reminderId === reminder.reminderId) resetForm()
|
||||
})
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function ReminderRow({
|
||||
reminder,
|
||||
busy,
|
||||
deleteScope,
|
||||
onDeleteScopeChange,
|
||||
onEdit,
|
||||
onComplete,
|
||||
onDelete,
|
||||
}: {
|
||||
reminder: ReminderFeedData
|
||||
busy: boolean
|
||||
deleteScope: ReminderEditScope
|
||||
onDeleteScopeChange: (scope: ReminderEditScope) => void
|
||||
onEdit: () => void
|
||||
onComplete: () => void
|
||||
onDelete: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-start justify-between gap-3 rounded-md border px-3 py-2">
|
||||
<div className="min-w-0 space-y-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="truncate text-sm font-medium">{reminder.title}</span>
|
||||
<Badge variant={reminder.completedAt ? "secondary" : "outline"} className="text-xs">
|
||||
{reminder.completedAt ? "Done" : reminder.priority}
|
||||
</Badge>
|
||||
{reminder.recurrence && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{formatRecurrence(reminder.recurrence)}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">{formatDate(reminder.dueAt)}</div>
|
||||
{reminder.notes && <div className="text-xs text-muted-foreground">{reminder.notes}</div>}
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-wrap items-center justify-end gap-1">
|
||||
{reminder.recurrence && (
|
||||
<Select
|
||||
value={deleteScope}
|
||||
onValueChange={(value) => onDeleteScopeChange(value as ReminderEditScope)}
|
||||
disabled={busy}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-[86px] text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="this-occurrence">This</SelectItem>
|
||||
<SelectItem value="this-and-future">Future</SelectItem>
|
||||
<SelectItem value="entire-series">All</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
<Button size="sm" variant="ghost" onClick={onComplete} disabled={busy}>
|
||||
{reminder.completedAt ? (
|
||||
<RotateCcw className="size-3.5" />
|
||||
) : (
|
||||
<Check className="size-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={onEdit} disabled={busy}>
|
||||
<Pencil className="size-3.5" />
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={onDelete} disabled={busy}>
|
||||
<Trash2 className="size-3.5 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function formToCreatePayload(form: ReminderFormState): Record<string, unknown> {
|
||||
return {
|
||||
title: form.title.trim(),
|
||||
notes: form.notes.trim() || null,
|
||||
dueAt: toIsoString(form.dueAt),
|
||||
timeZone: localTimeZone(),
|
||||
priority: form.priority,
|
||||
recurrence: recurrenceValueFromForm(form),
|
||||
}
|
||||
}
|
||||
|
||||
function formToPatch(initial: ReminderFormState, form: ReminderFormState): Record<string, unknown> {
|
||||
const patch: Record<string, unknown> = {}
|
||||
const title = form.title.trim()
|
||||
const notes = form.notes.trim() || null
|
||||
const initialNotes = initial.notes.trim() || null
|
||||
|
||||
if (title !== initial.title.trim()) patch.title = title
|
||||
if (notes !== initialNotes) patch.notes = notes
|
||||
if (form.dueAt !== initial.dueAt) {
|
||||
patch.dueAt = toIsoString(form.dueAt)
|
||||
patch.timeZone = localTimeZone()
|
||||
}
|
||||
if (form.priority !== initial.priority) patch.priority = form.priority
|
||||
if (form.scope !== "this-occurrence" && recurrenceChanged(initial, form)) {
|
||||
patch.recurrence = recurrenceValueFromForm(form)
|
||||
}
|
||||
|
||||
return patch
|
||||
}
|
||||
|
||||
function recurrenceValueFromForm(form: ReminderFormState): ReminderRecurrence | null {
|
||||
return form.recurs ? recurrenceFromForm(form) : null
|
||||
}
|
||||
|
||||
function recurrenceFromForm(form: ReminderFormState): ReminderRecurrence {
|
||||
const recurrence: ReminderRecurrence = {
|
||||
frequency: form.frequency,
|
||||
interval: Math.max(1, Number(form.interval) || 1),
|
||||
}
|
||||
|
||||
const count = Number(form.count)
|
||||
if (Number.isInteger(count) && count > 0) recurrence.count = count
|
||||
if (form.until) recurrence.until = toIsoString(form.until)
|
||||
|
||||
return recurrence
|
||||
}
|
||||
|
||||
function formFromReminder(reminder: ReminderFeedData): ReminderFormState {
|
||||
return {
|
||||
title: reminder.title,
|
||||
notes: reminder.notes ?? "",
|
||||
dueAt: toLocalInput(new Date(reminder.dueAt)),
|
||||
priority: reminder.priority,
|
||||
scope: reminder.recurrence ? "this-occurrence" : "entire-series",
|
||||
recurs: reminder.recurrence !== null,
|
||||
frequency: reminder.recurrence?.frequency ?? "daily",
|
||||
interval: String(reminder.recurrence?.interval ?? 1),
|
||||
count: reminder.recurrence?.count ? String(reminder.recurrence.count) : "",
|
||||
until: reminder.recurrence?.until ? toLocalInput(new Date(reminder.recurrence.until)) : "",
|
||||
}
|
||||
}
|
||||
|
||||
function setFormField<TKey extends keyof ReminderFormState>(
|
||||
setForm: Dispatch<SetStateAction<ReminderFormState>>,
|
||||
key: TKey,
|
||||
value: ReminderFormState[TKey],
|
||||
) {
|
||||
setForm((prev) => ({ ...prev, [key]: value }))
|
||||
}
|
||||
|
||||
function recurrenceChanged(initial: ReminderFormState, form: ReminderFormState): boolean {
|
||||
return (
|
||||
JSON.stringify(recurrenceValueFromForm(initial)) !==
|
||||
JSON.stringify(recurrenceValueFromForm(form))
|
||||
)
|
||||
}
|
||||
|
||||
function reminderKey(reminder: ReminderFeedData): string {
|
||||
return `${reminder.reminderId}:${reminder.occurrenceId}`
|
||||
}
|
||||
|
||||
function isReminderItem(item: FeedItem): item is FeedItem & { data: ReminderFeedData } {
|
||||
return (
|
||||
item.sourceId === REMINDER_SOURCE_ID &&
|
||||
typeof item.data.reminderId === "string" &&
|
||||
typeof item.data.occurrenceId === "string" &&
|
||||
typeof item.data.title === "string" &&
|
||||
typeof item.data.originalDueAt === "string" &&
|
||||
typeof item.data.dueAt === "string"
|
||||
)
|
||||
}
|
||||
|
||||
function toLocalInput(date: Date): string {
|
||||
const offsetMs = date.getTimezoneOffset() * 60 * 1000
|
||||
return new Date(date.getTime() - offsetMs).toISOString().slice(0, 16)
|
||||
}
|
||||
|
||||
function toIsoString(value: string): string {
|
||||
return new Date(value).toISOString()
|
||||
}
|
||||
|
||||
function localTimeZone(): string {
|
||||
return Intl.DateTimeFormat().resolvedOptions().timeZone
|
||||
}
|
||||
|
||||
function formatDate(value: string): string {
|
||||
return new Date(value).toLocaleString(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
})
|
||||
}
|
||||
|
||||
function formatRecurrence(recurrence: ReminderRecurrence): string {
|
||||
return recurrence.interval === 1
|
||||
? recurrence.frequency
|
||||
: `${recurrence.frequency} / ${recurrence.interval}`
|
||||
}
|
||||
|
||||
function formatScope(scope: ReminderEditScope): string {
|
||||
switch (scope) {
|
||||
case "this-occurrence":
|
||||
return "this occurrence"
|
||||
case "this-and-future":
|
||||
return "this and future"
|
||||
case "entire-series":
|
||||
return "entire series"
|
||||
}
|
||||
}
|
||||
530
apps/admin-dashboard/src/components/source-config-panel.tsx
Normal file
530
apps/admin-dashboard/src/components/source-config-panel.tsx
Normal file
@@ -0,0 +1,530 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
||||
import { Info, Loader2, MapPin, Trash2 } from "lucide-react"
|
||||
import { useState } from "react"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import type { ConfigFieldDef, SourceDefinition } from "@/lib/api"
|
||||
|
||||
import { ReminderCrudPanel } from "@/components/reminder-crud-panel"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
|
||||
import { fetchSourceConfig, pushLocation, replaceSource, updateProviderConfig } from "@/lib/api"
|
||||
|
||||
interface SourceConfigPanelProps {
|
||||
source: SourceDefinition
|
||||
onUpdate: () => void
|
||||
}
|
||||
|
||||
export function SourceConfigPanel({ source, onUpdate }: SourceConfigPanelProps) {
|
||||
const queryClient = useQueryClient()
|
||||
const [dirty, setDirty] = useState<Record<string, unknown>>({})
|
||||
|
||||
const { data: serverConfig, isLoading } = useQuery({
|
||||
queryKey: ["sourceConfig", source.id],
|
||||
queryFn: () => fetchSourceConfig(source.id),
|
||||
})
|
||||
|
||||
const enabled = serverConfig?.enabled ?? false
|
||||
const serverValues = buildInitialValues(source.fields, serverConfig?.config)
|
||||
const formValues = { ...serverValues, ...dirty }
|
||||
|
||||
function isCredentialField(field: ConfigFieldDef): boolean {
|
||||
return !!(field.secret && field.required)
|
||||
}
|
||||
|
||||
function getUserConfig(): Record<string, unknown> {
|
||||
const result: Record<string, unknown> = {}
|
||||
for (const [name, value] of Object.entries(formValues)) {
|
||||
const field = source.fields[name]
|
||||
if (field && !isCredentialField(field)) {
|
||||
result[name] = value
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function getCredentialFields(): Record<string, unknown> {
|
||||
const creds: Record<string, unknown> = {}
|
||||
for (const [name, value] of Object.entries(formValues)) {
|
||||
const field = source.fields[name]
|
||||
if (field && isCredentialField(field)) {
|
||||
creds[name] = value
|
||||
}
|
||||
}
|
||||
return creds
|
||||
}
|
||||
|
||||
function hasUserConfigFields(): boolean {
|
||||
return Object.values(source.fields).some((field) => !isCredentialField(field))
|
||||
}
|
||||
|
||||
function buildReplaceBody(enabledValue: boolean): Parameters<typeof replaceSource>[1] {
|
||||
const body: Parameters<typeof replaceSource>[1] = { enabled: enabledValue }
|
||||
|
||||
if (hasUserConfigFields()) {
|
||||
body.config = getUserConfig()
|
||||
}
|
||||
|
||||
return body
|
||||
}
|
||||
|
||||
function invalidate() {
|
||||
queryClient.invalidateQueries({ queryKey: ["sourceConfig", source.id] })
|
||||
queryClient.invalidateQueries({ queryKey: ["configs"] })
|
||||
onUpdate()
|
||||
}
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const credentialFields = getCredentialFields()
|
||||
const hasCredentials = Object.values(credentialFields).some(
|
||||
(v) => typeof v === "string" && v.length > 0,
|
||||
)
|
||||
|
||||
const body = buildReplaceBody(enabled)
|
||||
if (hasCredentials && source.perUserCredentials) {
|
||||
body.credentials = credentialFields
|
||||
}
|
||||
await replaceSource(source.id, body)
|
||||
|
||||
// For non-per-user credentials (provider-level), still use the admin endpoint.
|
||||
if (hasCredentials && !source.perUserCredentials) {
|
||||
await updateProviderConfig(source.id, { credentials: credentialFields })
|
||||
}
|
||||
},
|
||||
onSuccess() {
|
||||
setDirty({})
|
||||
invalidate()
|
||||
toast.success("Configuration saved")
|
||||
},
|
||||
onError(err) {
|
||||
toast.error(err.message)
|
||||
},
|
||||
})
|
||||
|
||||
const toggleMutation = useMutation({
|
||||
mutationFn: (checked: boolean) => replaceSource(source.id, buildReplaceBody(checked)),
|
||||
onSuccess(_data, checked) {
|
||||
invalidate()
|
||||
toast.success(`Source ${checked ? "enabled" : "disabled"}`)
|
||||
},
|
||||
onError(err) {
|
||||
toast.error(err.message)
|
||||
},
|
||||
})
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: () => replaceSource(source.id, buildReplaceBody(false)),
|
||||
onSuccess() {
|
||||
setDirty({})
|
||||
invalidate()
|
||||
toast.success("Configuration deleted")
|
||||
},
|
||||
onError(err) {
|
||||
toast.error(err.message)
|
||||
},
|
||||
})
|
||||
|
||||
function handleFieldChange(fieldName: string, value: unknown) {
|
||||
setDirty((prev) => ({ ...prev, [fieldName]: value }))
|
||||
}
|
||||
|
||||
const fieldEntries = Object.entries(source.fields)
|
||||
const hasFields = fieldEntries.length > 0
|
||||
const busy = saveMutation.isPending || toggleMutation.isPending || deleteMutation.isPending
|
||||
|
||||
const requiredFields = fieldEntries.filter(([, f]) => f.required)
|
||||
const optionalFields = fieldEntries.filter(([, f]) => !f.required)
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="size-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-xl space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-3">
|
||||
<h2 className="text-lg font-semibold tracking-tight">{source.name}</h2>
|
||||
{source.alwaysEnabled ? (
|
||||
<Badge variant="secondary">Always on</Badge>
|
||||
) : enabled ? (
|
||||
<Badge className="bg-primary/10 text-primary">Enabled</Badge>
|
||||
) : (
|
||||
<Badge variant="outline">Disabled</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">{source.description}</p>
|
||||
</div>
|
||||
{!source.alwaysEnabled && (
|
||||
<Switch
|
||||
checked={enabled}
|
||||
onCheckedChange={(checked) => toggleMutation.mutate(checked)}
|
||||
disabled={busy}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Config form */}
|
||||
{hasFields && !source.alwaysEnabled && (
|
||||
<>
|
||||
{/* Required fields */}
|
||||
{requiredFields.length > 0 && (
|
||||
<Card className="-mx-4">
|
||||
<CardHeader className="pb-4">
|
||||
<CardTitle className="text-sm">Credentials</CardTitle>
|
||||
<CardDescription>Required fields to connect this source.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{requiredFields.map(([name, field]) => (
|
||||
<FieldInput
|
||||
key={name}
|
||||
name={name}
|
||||
field={field}
|
||||
value={formValues[name]}
|
||||
onChange={(v) => handleFieldChange(name, v)}
|
||||
disabled={busy}
|
||||
/>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Optional fields */}
|
||||
{optionalFields.length > 0 && (
|
||||
<Card className="-mx-4">
|
||||
<CardHeader className="pb-4">
|
||||
<CardTitle className="text-sm">Options</CardTitle>
|
||||
<CardDescription>Optional configuration for this source.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className={`grid gap-4 ${optionalFields.length > 1 ? "grid-cols-2" : ""}`}>
|
||||
{optionalFields.map(([name, field]) => (
|
||||
<FieldInput
|
||||
key={name}
|
||||
name={name}
|
||||
field={field}
|
||||
value={formValues[name]}
|
||||
onChange={(v) => handleFieldChange(name, v)}
|
||||
disabled={busy}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center justify-end gap-3">
|
||||
{serverConfig && (
|
||||
<Button
|
||||
onClick={() => deleteMutation.mutate()}
|
||||
disabled={busy}
|
||||
variant="outline"
|
||||
className="text-destructive hover:text-destructive"
|
||||
>
|
||||
{deleteMutation.isPending ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="size-4" />
|
||||
)}
|
||||
{deleteMutation.isPending ? "Deleting…" : "Delete configuration"}
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={() => saveMutation.mutate()} disabled={busy}>
|
||||
{saveMutation.isPending && <Loader2 className="size-4 animate-spin" />}
|
||||
{saveMutation.isPending ? "Saving…" : "Save configuration"}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Always-on sources */}
|
||||
{source.alwaysEnabled && source.id !== "freya.location" && (
|
||||
<>
|
||||
<Separator />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
This source is always enabled and requires no configuration.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
{source.id === "freya.location" && <LocationCard />}
|
||||
|
||||
{source.id === "freya.reminders" && enabled && <ReminderCrudPanel />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function LocationCard() {
|
||||
const [lat, setLat] = useState("")
|
||||
const [lng, setLng] = useState("")
|
||||
|
||||
const locationMutation = useMutation({
|
||||
mutationFn: (coords: { lat: number; lng: number }) =>
|
||||
pushLocation({ lat: coords.lat, lng: coords.lng, accuracy: 10 }),
|
||||
onSuccess() {
|
||||
toast.success("Location updated")
|
||||
},
|
||||
onError(err) {
|
||||
toast.error(err.message)
|
||||
},
|
||||
})
|
||||
|
||||
function handlePush() {
|
||||
const latNum = parseFloat(lat)
|
||||
const lngNum = parseFloat(lng)
|
||||
if (isNaN(latNum) || isNaN(lngNum)) return
|
||||
locationMutation.mutate({ lat: latNum, lng: lngNum })
|
||||
}
|
||||
|
||||
function handleUseDevice() {
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(pos) => {
|
||||
setLat(String(pos.coords.latitude))
|
||||
setLng(String(pos.coords.longitude))
|
||||
locationMutation.mutate({
|
||||
lat: pos.coords.latitude,
|
||||
lng: pos.coords.longitude,
|
||||
})
|
||||
},
|
||||
(err) => {
|
||||
locationMutation.reset()
|
||||
alert(`Geolocation error: ${err.message}`)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="-mx-4">
|
||||
<CardHeader className="pb-4">
|
||||
<CardTitle className="text-sm">Push Location</CardTitle>
|
||||
<CardDescription>Send a location update to the backend.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="loc-lat" className="text-xs font-medium">
|
||||
Latitude
|
||||
</Label>
|
||||
<Input
|
||||
id="loc-lat"
|
||||
type="number"
|
||||
step="any"
|
||||
value={lat}
|
||||
onChange={(e) => setLat(e.target.value)}
|
||||
placeholder="51.5074"
|
||||
disabled={locationMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="loc-lng" className="text-xs font-medium">
|
||||
Longitude
|
||||
</Label>
|
||||
<Input
|
||||
id="loc-lng"
|
||||
type="number"
|
||||
step="any"
|
||||
value={lng}
|
||||
onChange={(e) => setLng(e.target.value)}
|
||||
placeholder="-0.1278"
|
||||
disabled={locationMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleUseDevice}
|
||||
disabled={locationMutation.isPending}
|
||||
>
|
||||
<MapPin className="size-3.5" />
|
||||
Use device location
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handlePush}
|
||||
disabled={locationMutation.isPending || !lat || !lng}
|
||||
>
|
||||
{locationMutation.isPending && <Loader2 className="size-3.5 animate-spin" />}
|
||||
Push
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldInput({
|
||||
name,
|
||||
field,
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
}: {
|
||||
name: string
|
||||
field: ConfigFieldDef
|
||||
value: unknown
|
||||
onChange: (value: unknown) => void
|
||||
disabled?: boolean
|
||||
}) {
|
||||
const labelContent = (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span>{field.label}</span>
|
||||
{field.required && <span className="text-destructive">*</span>}
|
||||
{field.description && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Info className="size-3 text-muted-foreground cursor-help" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="max-w-xs text-xs">
|
||||
{field.description}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
if (field.type === "select" && field.options) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={name} className="text-xs font-medium">
|
||||
{labelContent}
|
||||
</Label>
|
||||
<Select value={String(value ?? "")} onValueChange={onChange} disabled={disabled}>
|
||||
<SelectTrigger id={name}>
|
||||
<SelectValue placeholder={`Select ${field.label.toLowerCase()}`} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{field.options.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (field.type === "multiselect" && field.options) {
|
||||
const selected = Array.isArray(value) ? (value as string[]) : []
|
||||
|
||||
function toggle(optValue: string) {
|
||||
const next = selected.includes(optValue)
|
||||
? selected.filter((v) => v !== optValue)
|
||||
: [...selected, optValue]
|
||||
onChange(next)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium">{labelContent}</Label>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{field.options!.map((opt) => {
|
||||
const isSelected = selected.includes(opt.value)
|
||||
return (
|
||||
<Badge
|
||||
key={opt.value}
|
||||
variant={isSelected ? "default" : "outline"}
|
||||
className={`cursor-pointer select-none ${isSelected ? "" : "opacity-60 hover:opacity-100"}`}
|
||||
onClick={() => !disabled && toggle(opt.value)}
|
||||
>
|
||||
{opt.label}
|
||||
</Badge>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (field.type === "number") {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={name} className="text-xs font-medium">
|
||||
{labelContent}
|
||||
</Label>
|
||||
<Input
|
||||
id={name}
|
||||
type="number"
|
||||
value={value === undefined || value === null ? "" : String(value)}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value
|
||||
onChange(v === "" ? undefined : Number(v))
|
||||
}}
|
||||
placeholder={field.defaultValue !== undefined ? String(field.defaultValue) : undefined}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (field.type === "boolean") {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-3 rounded-md border px-3 py-2">
|
||||
<Label htmlFor={name} className="text-xs font-medium">
|
||||
{labelContent}
|
||||
</Label>
|
||||
<Switch id={name} checked={value === true} onCheckedChange={onChange} disabled={disabled} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={name} className="text-xs font-medium">
|
||||
{labelContent}
|
||||
</Label>
|
||||
<Input
|
||||
id={name}
|
||||
type={field.secret ? "password" : "text"}
|
||||
value={String(value ?? "")}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={field.defaultValue !== undefined ? String(field.defaultValue) : undefined}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function buildInitialValues(
|
||||
fields: Record<string, ConfigFieldDef>,
|
||||
saved: Record<string, unknown> | undefined,
|
||||
): Record<string, unknown> {
|
||||
const values: Record<string, unknown> = {}
|
||||
for (const [name, field] of Object.entries(fields)) {
|
||||
if (saved && name in saved) {
|
||||
values[name] = saved[name]
|
||||
} else if (field.defaultValue !== undefined) {
|
||||
values[name] = field.defaultValue
|
||||
} else if (field.type === "boolean") {
|
||||
values[name] = false
|
||||
} else if (field.type === "multiselect") {
|
||||
values[name] = []
|
||||
} else {
|
||||
values[name] = field.type === "number" ? undefined : ""
|
||||
}
|
||||
}
|
||||
return values
|
||||
}
|
||||
223
apps/admin-dashboard/src/components/theme-provider.tsx
Normal file
223
apps/admin-dashboard/src/components/theme-provider.tsx
Normal file
@@ -0,0 +1,223 @@
|
||||
/* eslint-disable react-refresh/only-export-components */
|
||||
import * as React from "react"
|
||||
|
||||
type Theme = "dark" | "light" | "system"
|
||||
type ResolvedTheme = "dark" | "light"
|
||||
|
||||
type ThemeProviderProps = {
|
||||
children: React.ReactNode
|
||||
defaultTheme?: Theme
|
||||
storageKey?: string
|
||||
disableTransitionOnChange?: boolean
|
||||
}
|
||||
|
||||
type ThemeProviderState = {
|
||||
theme: Theme
|
||||
setTheme: (theme: Theme) => void
|
||||
}
|
||||
|
||||
const COLOR_SCHEME_QUERY = "(prefers-color-scheme: dark)"
|
||||
const THEME_VALUES: Theme[] = ["dark", "light", "system"]
|
||||
|
||||
const ThemeProviderContext = React.createContext<ThemeProviderState | undefined>(undefined)
|
||||
|
||||
function isTheme(value: string | null): value is Theme {
|
||||
if (value === null) {
|
||||
return false
|
||||
}
|
||||
|
||||
return THEME_VALUES.includes(value as Theme)
|
||||
}
|
||||
|
||||
function getSystemTheme(): ResolvedTheme {
|
||||
if (window.matchMedia(COLOR_SCHEME_QUERY).matches) {
|
||||
return "dark"
|
||||
}
|
||||
|
||||
return "light"
|
||||
}
|
||||
|
||||
function disableTransitionsTemporarily() {
|
||||
const style = document.createElement("style")
|
||||
style.appendChild(
|
||||
document.createTextNode(
|
||||
"*,*::before,*::after{-webkit-transition:none!important;transition:none!important}",
|
||||
),
|
||||
)
|
||||
document.head.appendChild(style)
|
||||
|
||||
return () => {
|
||||
window.getComputedStyle(document.body)
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
style.remove()
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function isEditableTarget(target: EventTarget | null) {
|
||||
if (!(target instanceof HTMLElement)) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (target.isContentEditable) {
|
||||
return true
|
||||
}
|
||||
|
||||
const editableParent = target.closest("input, textarea, select, [contenteditable='true']")
|
||||
if (editableParent) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
export function ThemeProvider({
|
||||
children,
|
||||
defaultTheme = "system",
|
||||
storageKey = "theme",
|
||||
disableTransitionOnChange = true,
|
||||
...props
|
||||
}: ThemeProviderProps) {
|
||||
const [theme, setThemeState] = React.useState<Theme>(() => {
|
||||
const storedTheme = localStorage.getItem(storageKey)
|
||||
if (isTheme(storedTheme)) {
|
||||
return storedTheme
|
||||
}
|
||||
|
||||
return defaultTheme
|
||||
})
|
||||
|
||||
const setTheme = React.useCallback(
|
||||
(nextTheme: Theme) => {
|
||||
localStorage.setItem(storageKey, nextTheme)
|
||||
setThemeState(nextTheme)
|
||||
},
|
||||
[storageKey],
|
||||
)
|
||||
|
||||
const applyTheme = React.useCallback(
|
||||
(nextTheme: Theme) => {
|
||||
const root = document.documentElement
|
||||
const resolvedTheme = nextTheme === "system" ? getSystemTheme() : nextTheme
|
||||
const restoreTransitions = disableTransitionOnChange ? disableTransitionsTemporarily() : null
|
||||
|
||||
root.classList.remove("light", "dark")
|
||||
root.classList.add(resolvedTheme)
|
||||
|
||||
if (restoreTransitions) {
|
||||
restoreTransitions()
|
||||
}
|
||||
},
|
||||
[disableTransitionOnChange],
|
||||
)
|
||||
|
||||
React.useEffect(() => {
|
||||
applyTheme(theme)
|
||||
|
||||
if (theme !== "system") {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const mediaQuery = window.matchMedia(COLOR_SCHEME_QUERY)
|
||||
const handleChange = () => {
|
||||
applyTheme("system")
|
||||
}
|
||||
|
||||
mediaQuery.addEventListener("change", handleChange)
|
||||
|
||||
return () => {
|
||||
mediaQuery.removeEventListener("change", handleChange)
|
||||
}
|
||||
}, [theme, applyTheme])
|
||||
|
||||
React.useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.repeat) {
|
||||
return
|
||||
}
|
||||
|
||||
if (event.metaKey || event.ctrlKey || event.altKey) {
|
||||
return
|
||||
}
|
||||
|
||||
if (isEditableTarget(event.target)) {
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key.toLowerCase() !== "d") {
|
||||
return
|
||||
}
|
||||
|
||||
setThemeState((currentTheme) => {
|
||||
const nextTheme =
|
||||
currentTheme === "dark"
|
||||
? "light"
|
||||
: currentTheme === "light"
|
||||
? "dark"
|
||||
: getSystemTheme() === "dark"
|
||||
? "light"
|
||||
: "dark"
|
||||
|
||||
localStorage.setItem(storageKey, nextTheme)
|
||||
return nextTheme
|
||||
})
|
||||
}
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handleKeyDown)
|
||||
}
|
||||
}, [storageKey])
|
||||
|
||||
React.useEffect(() => {
|
||||
const handleStorageChange = (event: StorageEvent) => {
|
||||
if (event.storageArea !== localStorage) {
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key !== storageKey) {
|
||||
return
|
||||
}
|
||||
|
||||
if (isTheme(event.newValue)) {
|
||||
setThemeState(event.newValue)
|
||||
return
|
||||
}
|
||||
|
||||
setThemeState(defaultTheme)
|
||||
}
|
||||
|
||||
window.addEventListener("storage", handleStorageChange)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("storage", handleStorageChange)
|
||||
}
|
||||
}, [defaultTheme, storageKey])
|
||||
|
||||
const value = React.useMemo(
|
||||
() => ({
|
||||
theme,
|
||||
setTheme,
|
||||
}),
|
||||
[theme, setTheme],
|
||||
)
|
||||
|
||||
return (
|
||||
<ThemeProviderContext.Provider {...props} value={value}>
|
||||
{children}
|
||||
</ThemeProviderContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export const useTheme = () => {
|
||||
const context = React.useContext(ThemeProviderContext)
|
||||
|
||||
if (context === undefined) {
|
||||
throw new Error("useTheme must be used within a ThemeProvider")
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
84
apps/admin-dashboard/src/components/ui/accordion.tsx
Normal file
84
apps/admin-dashboard/src/components/ui/accordion.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
"use client"
|
||||
|
||||
import { ChevronDownIcon, ChevronUpIcon } from "lucide-react"
|
||||
import { Accordion as AccordionPrimitive } from "radix-ui"
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Accordion({ className, ...props }: React.ComponentProps<typeof AccordionPrimitive.Root>) {
|
||||
return (
|
||||
<AccordionPrimitive.Root
|
||||
data-slot="accordion"
|
||||
className={cn("flex w-full flex-col overflow-hidden rounded-md border", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AccordionItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Item>) {
|
||||
return (
|
||||
<AccordionPrimitive.Item
|
||||
data-slot="accordion-item"
|
||||
className={cn("not-last:border-b data-open:bg-muted/50", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AccordionTrigger({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
|
||||
return (
|
||||
<AccordionPrimitive.Header className="flex">
|
||||
<AccordionPrimitive.Trigger
|
||||
data-slot="accordion-trigger"
|
||||
className={cn(
|
||||
"group/accordion-trigger relative flex flex-1 items-start justify-between gap-6 border border-transparent p-2 text-left text-xs/relaxed font-medium transition-all outline-none hover:underline disabled:pointer-events-none disabled:opacity-50 **:data-[slot=accordion-trigger-icon]:ml-auto **:data-[slot=accordion-trigger-icon]:size-4 **:data-[slot=accordion-trigger-icon]:text-muted-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronDownIcon
|
||||
data-slot="accordion-trigger-icon"
|
||||
className="pointer-events-none shrink-0 group-aria-expanded/accordion-trigger:hidden"
|
||||
/>
|
||||
<ChevronUpIcon
|
||||
data-slot="accordion-trigger-icon"
|
||||
className="pointer-events-none hidden shrink-0 group-aria-expanded/accordion-trigger:inline"
|
||||
/>
|
||||
</AccordionPrimitive.Trigger>
|
||||
</AccordionPrimitive.Header>
|
||||
)
|
||||
}
|
||||
|
||||
function AccordionContent({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Content>) {
|
||||
return (
|
||||
<AccordionPrimitive.Content
|
||||
data-slot="accordion-content"
|
||||
className="overflow-hidden px-2 text-xs/relaxed data-open:animate-accordion-down data-closed:animate-accordion-up"
|
||||
{...props}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"h-(--radix-accordion-content-height) pt-0 pb-4 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground [&_p:not(:last-child)]:mb-4",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</AccordionPrimitive.Content>
|
||||
)
|
||||
}
|
||||
|
||||
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
|
||||
73
apps/admin-dashboard/src/components/ui/alert.tsx
Normal file
73
apps/admin-dashboard/src/components/ui/alert.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const alertVariants = cva(
|
||||
"group/alert relative grid w-full gap-0.5 rounded-lg border px-2 py-1.5 text-left text-xs/relaxed has-data-[slot=alert-action]:relative has-data-[slot=alert-action]:pr-18 has-[>svg]:grid-cols-[auto_1fr] has-[>svg]:gap-x-1.5 *:[svg]:row-span-2 *:[svg]:translate-y-0.5 *:[svg]:text-current *:[svg:not([class*='size-'])]:size-3.5",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-card text-card-foreground",
|
||||
destructive:
|
||||
"bg-card text-destructive *:data-[slot=alert-description]:text-destructive/90 *:[svg]:text-current",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
function Alert({
|
||||
className,
|
||||
variant,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert"
|
||||
role="alert"
|
||||
className={cn(alertVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-title"
|
||||
className={cn(
|
||||
"font-medium group-has-[>svg]/alert:col-start-2 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-description"
|
||||
className={cn(
|
||||
"text-xs/relaxed text-balance text-muted-foreground md:text-pretty [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground [&_p:not(:last-child)]:mb-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-action"
|
||||
className={cn("absolute top-1.5 right-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Alert, AlertTitle, AlertDescription, AlertAction }
|
||||
46
apps/admin-dashboard/src/components/ui/badge.tsx
Normal file
46
apps/admin-dashboard/src/components/ui/badge.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { Slot } from "radix-ui"
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border border-transparent px-2 py-0.5 text-[0.625rem] font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-2.5!",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
|
||||
secondary: "bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
|
||||
destructive:
|
||||
"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
|
||||
outline:
|
||||
"border-border bg-input/20 text-foreground dark:bg-input/30 [a]:hover:bg-muted [a]:hover:text-muted-foreground",
|
||||
ghost: "hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
function Badge({
|
||||
className,
|
||||
variant = "default",
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"span"> & VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot.Root : "span"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="badge"
|
||||
data-variant={variant}
|
||||
className={cn(badgeVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
65
apps/admin-dashboard/src/components/ui/button.tsx
Normal file
65
apps/admin-dashboard/src/components/ui/button.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { Slot } from "radix-ui"
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"group/button inline-flex shrink-0 items-center justify-center rounded-md border border-transparent bg-clip-padding text-xs/relaxed font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/30 active:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-2 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/80",
|
||||
outline:
|
||||
"border-border hover:bg-input/50 hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:bg-input/30",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
|
||||
ghost:
|
||||
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
|
||||
destructive:
|
||||
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default:
|
||||
"h-7 gap-1 px-2 text-xs/relaxed has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
|
||||
xs: "h-5 gap-1 rounded-sm px-2 text-[0.625rem] has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-2.5",
|
||||
sm: "h-6 gap-1 px-2 text-xs/relaxed has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
|
||||
lg: "h-8 gap-1 px-2.5 text-xs/relaxed has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2 [&_svg:not([class*='size-'])]:size-4",
|
||||
icon: "size-7 [&_svg:not([class*='size-'])]:size-3.5",
|
||||
"icon-xs": "size-5 rounded-sm [&_svg:not([class*='size-'])]:size-2.5",
|
||||
"icon-sm": "size-6 [&_svg:not([class*='size-'])]:size-3",
|
||||
"icon-lg": "size-8 [&_svg:not([class*='size-'])]:size-4",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean
|
||||
}) {
|
||||
const Comp = asChild ? Slot.Root : "button"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="button"
|
||||
data-variant={variant}
|
||||
data-size={size}
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Button, buttonVariants }
|
||||
83
apps/admin-dashboard/src/components/ui/card.tsx
Normal file
83
apps/admin-dashboard/src/components/ui/card.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Card({
|
||||
className,
|
||||
size = "default",
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"group/card flex flex-col gap-4 overflow-hidden rounded-lg bg-card py-4 text-xs/relaxed text-card-foreground ring-1 ring-foreground/10 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 *:[img:first-child]:rounded-t-lg *:[img:last-child]:rounded-b-lg",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-header"
|
||||
className={cn(
|
||||
"group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-lg px-4 group-data-[size=sm]/card:px-3 has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-4 group-data-[size=sm]/card:[.border-b]:pb-3",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return <div data-slot="card-title" className={cn("text-sm font-medium", className)} {...props} />
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-description"
|
||||
className={cn("text-xs/relaxed text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-action"
|
||||
className={cn("col-start-2 row-span-2 row-start-1 self-start justify-self-end", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-content"
|
||||
className={cn("px-4 group-data-[size=sm]/card:px-3", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-footer"
|
||||
className={cn(
|
||||
"flex items-center rounded-b-lg px-4 group-data-[size=sm]/card:px-3 [.border-t]:pt-4 group-data-[size=sm]/card:[.border-t]:pt-3",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Card, CardHeader, CardFooter, CardTitle, CardAction, CardDescription, CardContent }
|
||||
21
apps/admin-dashboard/src/components/ui/collapsible.tsx
Normal file
21
apps/admin-dashboard/src/components/ui/collapsible.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
"use client"
|
||||
|
||||
import { Collapsible as CollapsiblePrimitive } from "radix-ui"
|
||||
|
||||
function Collapsible({ ...props }: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
|
||||
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
|
||||
}
|
||||
|
||||
function CollapsibleTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
|
||||
return <CollapsiblePrimitive.CollapsibleTrigger data-slot="collapsible-trigger" {...props} />
|
||||
}
|
||||
|
||||
function CollapsibleContent({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
|
||||
return <CollapsiblePrimitive.CollapsibleContent data-slot="collapsible-content" {...props} />
|
||||
}
|
||||
|
||||
export { Collapsible, CollapsibleTrigger, CollapsibleContent }
|
||||
19
apps/admin-dashboard/src/components/ui/input.tsx
Normal file
19
apps/admin-dashboard/src/components/ui/input.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"h-7 w-full min-w-0 rounded-md border border-input bg-input/20 px-2 py-0.5 text-sm transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-xs/relaxed file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/30 disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-2 aria-invalid:ring-destructive/20 md:text-xs/relaxed dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Input }
|
||||
19
apps/admin-dashboard/src/components/ui/label.tsx
Normal file
19
apps/admin-dashboard/src/components/ui/label.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import { Label as LabelPrimitive } from "radix-ui"
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Label({ className, ...props }: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||
return (
|
||||
<LabelPrimitive.Root
|
||||
data-slot="label"
|
||||
className={cn(
|
||||
"flex items-center gap-2 text-xs/relaxed leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Label }
|
||||
183
apps/admin-dashboard/src/components/ui/select.tsx
Normal file
183
apps/admin-dashboard/src/components/ui/select.tsx
Normal file
@@ -0,0 +1,183 @@
|
||||
import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react"
|
||||
import { Select as SelectPrimitive } from "radix-ui"
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Select({ ...props }: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
||||
return <SelectPrimitive.Root data-slot="select" {...props} />
|
||||
}
|
||||
|
||||
function SelectGroup({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
||||
return (
|
||||
<SelectPrimitive.Group
|
||||
data-slot="select-group"
|
||||
className={cn("scroll-my-1 p-1", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectValue({ ...props }: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
||||
return <SelectPrimitive.Value data-slot="select-value" {...props} />
|
||||
}
|
||||
|
||||
function SelectTrigger({
|
||||
className,
|
||||
size = "default",
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
|
||||
size?: "sm" | "default"
|
||||
}) {
|
||||
return (
|
||||
<SelectPrimitive.Trigger
|
||||
data-slot="select-trigger"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"flex w-fit items-center justify-between gap-1.5 rounded-md border border-input bg-input/20 px-2 py-1.5 text-xs/relaxed whitespace-nowrap transition-colors outline-none focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/30 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-2 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-7 data-[size=sm]:h-6 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDownIcon className="pointer-events-none size-3.5 text-muted-foreground" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectContent({
|
||||
className,
|
||||
children,
|
||||
position = "item-aligned",
|
||||
align = "center",
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
|
||||
return (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
data-slot="select-content"
|
||||
data-align-trigger={position === "item-aligned"}
|
||||
className={cn(
|
||||
"relative z-50 max-h-(--radix-select-content-available-height) min-w-32 origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className,
|
||||
)}
|
||||
position={position}
|
||||
align={align}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
data-position={position}
|
||||
className={cn(
|
||||
"data-[position=popper]:h-(--radix-select-trigger-height) data-[position=popper]:w-full data-[position=popper]:min-w-(--radix-select-trigger-width)",
|
||||
position === "popper" && "",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectLabel({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
||||
return (
|
||||
<SelectPrimitive.Label
|
||||
data-slot="select-label"
|
||||
className={cn("px-2 py-1.5 text-xs text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
|
||||
return (
|
||||
<SelectPrimitive.Item
|
||||
data-slot="select-item"
|
||||
className={cn(
|
||||
"relative flex min-h-7 w-full cursor-default items-center gap-2 rounded-md px-2 py-1 text-xs/relaxed outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute right-2 flex items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<CheckIcon className="pointer-events-none" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
|
||||
return (
|
||||
<SelectPrimitive.Separator
|
||||
data-slot="select-separator"
|
||||
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border/50", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectScrollUpButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
data-slot="select-scroll-up-button"
|
||||
className={cn(
|
||||
"z-10 flex cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-3.5",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUpIcon />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectScrollDownButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
data-slot="select-scroll-down-button"
|
||||
className={cn(
|
||||
"z-10 flex cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-3.5",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDownIcon />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectScrollDownButton,
|
||||
SelectScrollUpButton,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
}
|
||||
26
apps/admin-dashboard/src/components/ui/separator.tsx
Normal file
26
apps/admin-dashboard/src/components/ui/separator.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { Separator as SeparatorPrimitive } from "radix-ui"
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Separator({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
decorative = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
|
||||
return (
|
||||
<SeparatorPrimitive.Root
|
||||
data-slot="separator"
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Separator }
|
||||
128
apps/admin-dashboard/src/components/ui/sheet.tsx
Normal file
128
apps/admin-dashboard/src/components/ui/sheet.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
import { XIcon } from "lucide-react"
|
||||
import { Dialog as SheetPrimitive } from "radix-ui"
|
||||
import * as React from "react"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
|
||||
return <SheetPrimitive.Root data-slot="sheet" {...props} />
|
||||
}
|
||||
|
||||
function SheetTrigger({ ...props }: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
|
||||
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
|
||||
}
|
||||
|
||||
function SheetClose({ ...props }: React.ComponentProps<typeof SheetPrimitive.Close>) {
|
||||
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
|
||||
}
|
||||
|
||||
function SheetPortal({ ...props }: React.ComponentProps<typeof SheetPrimitive.Portal>) {
|
||||
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
|
||||
}
|
||||
|
||||
function SheetOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
|
||||
return (
|
||||
<SheetPrimitive.Overlay
|
||||
data-slot="sheet-overlay"
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/80 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetContent({
|
||||
className,
|
||||
children,
|
||||
side = "right",
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
|
||||
side?: "top" | "right" | "bottom" | "left"
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
<SheetPrimitive.Content
|
||||
data-slot="sheet-content"
|
||||
data-side={side}
|
||||
className={cn(
|
||||
"fixed z-50 flex flex-col bg-background bg-clip-padding text-xs/relaxed shadow-lg transition duration-200 ease-in-out data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-[side=bottom]:data-open:slide-in-from-bottom-10 data-[side=left]:data-open:slide-in-from-left-10 data-[side=right]:data-open:slide-in-from-right-10 data-[side=top]:data-open:slide-in-from-top-10 data-closed:animate-out data-closed:fade-out-0 data-[side=bottom]:data-closed:slide-out-to-bottom-10 data-[side=left]:data-closed:slide-out-to-left-10 data-[side=right]:data-closed:slide-out-to-right-10 data-[side=top]:data-closed:slide-out-to-top-10",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<SheetPrimitive.Close data-slot="sheet-close" asChild>
|
||||
<Button variant="ghost" className="absolute top-4 right-4" size="icon-sm">
|
||||
<XIcon />
|
||||
<span className="sr-only">Close</span>
|
||||
</Button>
|
||||
</SheetPrimitive.Close>
|
||||
)}
|
||||
</SheetPrimitive.Content>
|
||||
</SheetPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sheet-header"
|
||||
className={cn("flex flex-col gap-1.5 p-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sheet-footer"
|
||||
className={cn("mt-auto flex flex-col gap-2 p-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetTitle({ className, ...props }: React.ComponentProps<typeof SheetPrimitive.Title>) {
|
||||
return (
|
||||
<SheetPrimitive.Title
|
||||
data-slot="sheet-title"
|
||||
className={cn("text-sm font-medium text-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
|
||||
return (
|
||||
<SheetPrimitive.Description
|
||||
data-slot="sheet-description"
|
||||
className={cn("text-xs/relaxed text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Sheet,
|
||||
SheetTrigger,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetFooter,
|
||||
SheetTitle,
|
||||
SheetDescription,
|
||||
}
|
||||
671
apps/admin-dashboard/src/components/ui/sidebar.tsx
Normal file
671
apps/admin-dashboard/src/components/ui/sidebar.tsx
Normal file
@@ -0,0 +1,671 @@
|
||||
"use client"
|
||||
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { PanelLeftIcon } from "lucide-react"
|
||||
import { Slot } from "radix-ui"
|
||||
import * as React from "react"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@/components/ui/sheet"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
|
||||
import { useIsMobile } from "@/hooks/use-mobile"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const SIDEBAR_COOKIE_NAME = "sidebar_state"
|
||||
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
|
||||
const SIDEBAR_WIDTH = "16rem"
|
||||
const SIDEBAR_WIDTH_MOBILE = "18rem"
|
||||
const SIDEBAR_WIDTH_ICON = "3rem"
|
||||
const SIDEBAR_KEYBOARD_SHORTCUT = "b"
|
||||
|
||||
type SidebarContextProps = {
|
||||
state: "expanded" | "collapsed"
|
||||
open: boolean
|
||||
setOpen: (open: boolean) => void
|
||||
openMobile: boolean
|
||||
setOpenMobile: (open: boolean) => void
|
||||
isMobile: boolean
|
||||
toggleSidebar: () => void
|
||||
}
|
||||
|
||||
const SidebarContext = React.createContext<SidebarContextProps | null>(null)
|
||||
|
||||
function useSidebar() {
|
||||
const context = React.useContext(SidebarContext)
|
||||
if (!context) {
|
||||
throw new Error("useSidebar must be used within a SidebarProvider.")
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
|
||||
function SidebarProvider({
|
||||
defaultOpen = true,
|
||||
open: openProp,
|
||||
onOpenChange: setOpenProp,
|
||||
className,
|
||||
style,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
defaultOpen?: boolean
|
||||
open?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
}) {
|
||||
const isMobile = useIsMobile()
|
||||
const [openMobile, setOpenMobile] = React.useState(false)
|
||||
|
||||
// This is the internal state of the sidebar.
|
||||
// We use openProp and setOpenProp for control from outside the component.
|
||||
const [_open, _setOpen] = React.useState(defaultOpen)
|
||||
const open = openProp ?? _open
|
||||
const setOpen = React.useCallback(
|
||||
(value: boolean | ((value: boolean) => boolean)) => {
|
||||
const openState = typeof value === "function" ? value(open) : value
|
||||
if (setOpenProp) {
|
||||
setOpenProp(openState)
|
||||
} else {
|
||||
_setOpen(openState)
|
||||
}
|
||||
|
||||
// This sets the cookie to keep the sidebar state.
|
||||
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`
|
||||
},
|
||||
[setOpenProp, open],
|
||||
)
|
||||
|
||||
// Helper to toggle the sidebar.
|
||||
const toggleSidebar = React.useCallback(() => {
|
||||
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open)
|
||||
}, [isMobile, setOpen, setOpenMobile])
|
||||
|
||||
// Adds a keyboard shortcut to toggle the sidebar.
|
||||
React.useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === SIDEBAR_KEYBOARD_SHORTCUT && (event.metaKey || event.ctrlKey)) {
|
||||
event.preventDefault()
|
||||
toggleSidebar()
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown)
|
||||
return () => window.removeEventListener("keydown", handleKeyDown)
|
||||
}, [toggleSidebar])
|
||||
|
||||
// We add a state so that we can do data-state="expanded" or "collapsed".
|
||||
// This makes it easier to style the sidebar with Tailwind classes.
|
||||
const state = open ? "expanded" : "collapsed"
|
||||
|
||||
const contextValue = React.useMemo<SidebarContextProps>(
|
||||
() => ({
|
||||
state,
|
||||
open,
|
||||
setOpen,
|
||||
isMobile,
|
||||
openMobile,
|
||||
setOpenMobile,
|
||||
toggleSidebar,
|
||||
}),
|
||||
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar],
|
||||
)
|
||||
|
||||
return (
|
||||
<SidebarContext.Provider value={contextValue}>
|
||||
<div
|
||||
data-slot="sidebar-wrapper"
|
||||
style={
|
||||
{
|
||||
"--sidebar-width": SIDEBAR_WIDTH,
|
||||
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
|
||||
...style,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
className={cn(
|
||||
"group/sidebar-wrapper flex min-h-svh w-full has-data-[variant=inset]:bg-sidebar",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</SidebarContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
function Sidebar({
|
||||
side = "left",
|
||||
variant = "sidebar",
|
||||
collapsible = "offcanvas",
|
||||
className,
|
||||
children,
|
||||
dir,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
side?: "left" | "right"
|
||||
variant?: "sidebar" | "floating" | "inset"
|
||||
collapsible?: "offcanvas" | "icon" | "none"
|
||||
}) {
|
||||
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
|
||||
|
||||
if (collapsible === "none") {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar"
|
||||
className={cn(
|
||||
"flex h-full w-(--sidebar-width) flex-col bg-sidebar text-sidebar-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
|
||||
<SheetContent
|
||||
dir={dir}
|
||||
data-sidebar="sidebar"
|
||||
data-slot="sidebar"
|
||||
data-mobile="true"
|
||||
className="w-(--sidebar-width) bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden"
|
||||
style={
|
||||
{
|
||||
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
side={side}
|
||||
>
|
||||
<SheetHeader className="sr-only">
|
||||
<SheetTitle>Sidebar</SheetTitle>
|
||||
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div className="flex h-full w-full flex-col">{children}</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="group peer hidden text-sidebar-foreground md:block"
|
||||
data-state={state}
|
||||
data-collapsible={state === "collapsed" ? collapsible : ""}
|
||||
data-variant={variant}
|
||||
data-side={side}
|
||||
data-slot="sidebar"
|
||||
>
|
||||
{/* This is what handles the sidebar gap on desktop */}
|
||||
<div
|
||||
data-slot="sidebar-gap"
|
||||
className={cn(
|
||||
"relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear",
|
||||
"group-data-[collapsible=offcanvas]:w-0",
|
||||
"group-data-[side=right]:rotate-180",
|
||||
variant === "floating" || variant === "inset"
|
||||
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]"
|
||||
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)",
|
||||
)}
|
||||
/>
|
||||
<div
|
||||
data-slot="sidebar-container"
|
||||
data-side={side}
|
||||
className={cn(
|
||||
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear data-[side=left]:left-0 data-[side=left]:group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)] data-[side=right]:right-0 data-[side=right]:group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)] md:flex",
|
||||
// Adjust the padding for floating and inset variants.
|
||||
variant === "floating" || variant === "inset"
|
||||
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
|
||||
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div
|
||||
data-sidebar="sidebar"
|
||||
data-slot="sidebar-inner"
|
||||
className="flex size-full flex-col bg-sidebar group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:shadow-sm group-data-[variant=floating]:ring-1 group-data-[variant=floating]:ring-sidebar-border"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarTrigger({ className, onClick, ...props }: React.ComponentProps<typeof Button>) {
|
||||
const { toggleSidebar } = useSidebar()
|
||||
|
||||
return (
|
||||
<Button
|
||||
data-sidebar="trigger"
|
||||
data-slot="sidebar-trigger"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className={cn(className)}
|
||||
onClick={(event) => {
|
||||
onClick?.(event)
|
||||
toggleSidebar()
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
<PanelLeftIcon />
|
||||
<span className="sr-only">Toggle Sidebar</span>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
|
||||
const { toggleSidebar } = useSidebar()
|
||||
|
||||
return (
|
||||
<button
|
||||
data-sidebar="rail"
|
||||
data-slot="sidebar-rail"
|
||||
aria-label="Toggle Sidebar"
|
||||
tabIndex={-1}
|
||||
onClick={toggleSidebar}
|
||||
title="Toggle Sidebar"
|
||||
className={cn(
|
||||
"absolute inset-y-0 z-20 hidden w-4 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:start-1/2 after:w-[2px] hover:after:bg-sidebar-border sm:flex ltr:-translate-x-1/2 rtl:-translate-x-1/2",
|
||||
"in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
|
||||
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
|
||||
"group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full hover:group-data-[collapsible=offcanvas]:bg-sidebar",
|
||||
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
|
||||
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
|
||||
return (
|
||||
<main
|
||||
data-slot="sidebar-inset"
|
||||
className={cn(
|
||||
"relative flex w-full flex-1 flex-col bg-background md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarInput({ className, ...props }: React.ComponentProps<typeof Input>) {
|
||||
return (
|
||||
<Input
|
||||
data-slot="sidebar-input"
|
||||
data-sidebar="input"
|
||||
className={cn("h-8 w-full border-input bg-muted/20 dark:bg-muted/30", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-header"
|
||||
data-sidebar="header"
|
||||
className={cn("flex flex-col gap-2 p-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-footer"
|
||||
data-sidebar="footer"
|
||||
className={cn("flex flex-col gap-2 p-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarSeparator({ className, ...props }: React.ComponentProps<typeof Separator>) {
|
||||
return (
|
||||
<Separator
|
||||
data-slot="sidebar-separator"
|
||||
data-sidebar="separator"
|
||||
className={cn("mx-2 w-auto bg-sidebar-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-content"
|
||||
data-sidebar="content"
|
||||
className={cn(
|
||||
"no-scrollbar flex min-h-0 flex-1 flex-col gap-0 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-group"
|
||||
data-sidebar="group"
|
||||
className={cn("relative flex w-full min-w-0 flex-col px-2 py-1", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarGroupLabel({
|
||||
className,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot.Root : "div"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="sidebar-group-label"
|
||||
data-sidebar="group-label"
|
||||
className={cn(
|
||||
"flex h-8 shrink-0 items-center rounded-md px-2 text-xs text-sidebar-foreground/70 ring-sidebar-ring outline-hidden transition-[margin,opacity] duration-200 ease-linear group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0 focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarGroupAction({
|
||||
className,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot.Root : "button"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="sidebar-group-action"
|
||||
data-sidebar="group-action"
|
||||
className={cn(
|
||||
"absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground ring-sidebar-ring outline-hidden transition-transform group-data-[collapsible=icon]:hidden after:absolute after:-inset-2 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarGroupContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-group-content"
|
||||
data-sidebar="group-content"
|
||||
className={cn("w-full text-xs", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
|
||||
return (
|
||||
<ul
|
||||
data-slot="sidebar-menu"
|
||||
data-sidebar="menu"
|
||||
className={cn("flex w-full min-w-0 flex-col gap-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
|
||||
return (
|
||||
<li
|
||||
data-slot="sidebar-menu-item"
|
||||
data-sidebar="menu-item"
|
||||
className={cn("group/menu-item relative", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const sidebarMenuButtonVariants = cva(
|
||||
"peer/menu-button group/menu-button flex w-full items-center gap-2 overflow-hidden rounded-[calc(var(--radius-sm)+2px)] p-2 text-left text-xs ring-sidebar-ring outline-hidden transition-[width,height,padding] group-has-data-[sidebar=menu-action]/menu-item:pr-8 group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-open:hover:bg-sidebar-accent data-open:hover:text-sidebar-accent-foreground data-active:bg-sidebar-accent data-active:font-medium data-active:text-sidebar-accent-foreground [&_svg]:size-4 [&_svg]:shrink-0 [&>span:last-child]:truncate",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
|
||||
outline:
|
||||
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
|
||||
},
|
||||
size: {
|
||||
default: "h-8 text-xs",
|
||||
sm: "h-7 text-xs",
|
||||
lg: "h-12 text-xs group-data-[collapsible=icon]:p-0!",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
function SidebarMenuButton({
|
||||
asChild = false,
|
||||
isActive = false,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
tooltip,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> & {
|
||||
asChild?: boolean
|
||||
isActive?: boolean
|
||||
tooltip?: string | React.ComponentProps<typeof TooltipContent>
|
||||
} & VariantProps<typeof sidebarMenuButtonVariants>) {
|
||||
const Comp = asChild ? Slot.Root : "button"
|
||||
const { isMobile, state } = useSidebar()
|
||||
|
||||
const button = (
|
||||
<Comp
|
||||
data-slot="sidebar-menu-button"
|
||||
data-sidebar="menu-button"
|
||||
data-size={size}
|
||||
data-active={isActive}
|
||||
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
|
||||
if (!tooltip) {
|
||||
return button
|
||||
}
|
||||
|
||||
if (typeof tooltip === "string") {
|
||||
tooltip = {
|
||||
children: tooltip,
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{button}</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="right"
|
||||
align="center"
|
||||
hidden={state !== "collapsed" || isMobile}
|
||||
{...tooltip}
|
||||
/>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenuAction({
|
||||
className,
|
||||
asChild = false,
|
||||
showOnHover = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> & {
|
||||
asChild?: boolean
|
||||
showOnHover?: boolean
|
||||
}) {
|
||||
const Comp = asChild ? Slot.Root : "button"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="sidebar-menu-action"
|
||||
data-sidebar="menu-action"
|
||||
className={cn(
|
||||
"absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-[calc(var(--radius-sm)-2px)] p-0 text-sidebar-foreground ring-sidebar-ring outline-hidden transition-transform group-data-[collapsible=icon]:hidden peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[size=default]/menu-button:top-1.5 peer-data-[size=lg]/menu-button:top-2.5 peer-data-[size=sm]/menu-button:top-1 after:absolute after:-inset-2 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
showOnHover &&
|
||||
"group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 peer-data-active/menu-button:text-sidebar-accent-foreground aria-expanded:opacity-100 md:opacity-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenuBadge({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-menu-badge"
|
||||
data-sidebar="menu-badge"
|
||||
className={cn(
|
||||
"pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-[calc(var(--radius-sm)-2px)] px-1 text-xs font-medium text-sidebar-foreground tabular-nums select-none group-data-[collapsible=icon]:hidden peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[size=default]/menu-button:top-1.5 peer-data-[size=lg]/menu-button:top-2.5 peer-data-[size=sm]/menu-button:top-1 peer-data-active/menu-button:text-sidebar-accent-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenuSkeleton({
|
||||
className,
|
||||
showIcon = false,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
showIcon?: boolean
|
||||
}) {
|
||||
// Random width between 50 to 90%.
|
||||
const [width] = React.useState(() => {
|
||||
return `${Math.floor(Math.random() * 40) + 50}%`
|
||||
})
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-menu-skeleton"
|
||||
data-sidebar="menu-skeleton"
|
||||
className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)}
|
||||
{...props}
|
||||
>
|
||||
{showIcon && <Skeleton className="size-4 rounded-md" data-sidebar="menu-skeleton-icon" />}
|
||||
<Skeleton
|
||||
className="h-4 max-w-(--skeleton-width) flex-1"
|
||||
data-sidebar="menu-skeleton-text"
|
||||
style={
|
||||
{
|
||||
"--skeleton-width": width,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
|
||||
return (
|
||||
<ul
|
||||
data-slot="sidebar-menu-sub"
|
||||
data-sidebar="menu-sub"
|
||||
className={cn(
|
||||
"mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l border-sidebar-border px-2.5 py-0.5 group-data-[collapsible=icon]:hidden",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenuSubItem({ className, ...props }: React.ComponentProps<"li">) {
|
||||
return (
|
||||
<li
|
||||
data-slot="sidebar-menu-sub-item"
|
||||
data-sidebar="menu-sub-item"
|
||||
className={cn("group/menu-sub-item relative", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenuSubButton({
|
||||
asChild = false,
|
||||
size = "md",
|
||||
isActive = false,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"a"> & {
|
||||
asChild?: boolean
|
||||
size?: "sm" | "md"
|
||||
isActive?: boolean
|
||||
}) {
|
||||
const Comp = asChild ? Slot.Root : "a"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="sidebar-menu-sub-button"
|
||||
data-sidebar="menu-sub-button"
|
||||
data-size={size}
|
||||
data-active={isActive}
|
||||
className={cn(
|
||||
"flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground ring-sidebar-ring outline-hidden group-data-[collapsible=icon]:hidden hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[size=md]:text-xs data-[size=sm]:text-xs data-active:bg-sidebar-accent data-active:text-sidebar-accent-foreground [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarGroup,
|
||||
SidebarGroupAction,
|
||||
SidebarGroupContent,
|
||||
SidebarGroupLabel,
|
||||
SidebarHeader,
|
||||
SidebarInput,
|
||||
SidebarInset,
|
||||
SidebarMenu,
|
||||
SidebarMenuAction,
|
||||
SidebarMenuBadge,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarMenuSkeleton,
|
||||
SidebarMenuSub,
|
||||
SidebarMenuSubButton,
|
||||
SidebarMenuSubItem,
|
||||
SidebarProvider,
|
||||
SidebarRail,
|
||||
SidebarSeparator,
|
||||
SidebarTrigger,
|
||||
useSidebar,
|
||||
}
|
||||
13
apps/admin-dashboard/src/components/ui/skeleton.tsx
Normal file
13
apps/admin-dashboard/src/components/ui/skeleton.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="skeleton"
|
||||
className={cn("animate-pulse rounded-md bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Skeleton }
|
||||
46
apps/admin-dashboard/src/components/ui/sonner.tsx
Normal file
46
apps/admin-dashboard/src/components/ui/sonner.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
CircleCheckIcon,
|
||||
InfoIcon,
|
||||
TriangleAlertIcon,
|
||||
OctagonXIcon,
|
||||
Loader2Icon,
|
||||
} from "lucide-react"
|
||||
import { Toaster as Sonner, type ToasterProps } from "sonner"
|
||||
|
||||
import { useTheme } from "@/components/theme-provider"
|
||||
|
||||
const Toaster = ({ ...props }: ToasterProps) => {
|
||||
const { theme = "system" } = useTheme()
|
||||
|
||||
return (
|
||||
<Sonner
|
||||
theme={theme as ToasterProps["theme"]}
|
||||
className="toaster group"
|
||||
icons={{
|
||||
success: <CircleCheckIcon className="size-4" />,
|
||||
info: <InfoIcon className="size-4" />,
|
||||
warning: <TriangleAlertIcon className="size-4" />,
|
||||
error: <OctagonXIcon className="size-4" />,
|
||||
loading: <Loader2Icon className="size-4 animate-spin" />,
|
||||
}}
|
||||
style={
|
||||
{
|
||||
"--normal-bg": "var(--popover)",
|
||||
"--normal-text": "var(--popover-foreground)",
|
||||
"--normal-border": "var(--border)",
|
||||
"--border-radius": "var(--radius)",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
toastOptions={{
|
||||
classNames: {
|
||||
toast: "cn-toast",
|
||||
},
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Toaster }
|
||||
33
apps/admin-dashboard/src/components/ui/switch.tsx
Normal file
33
apps/admin-dashboard/src/components/ui/switch.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
"use client"
|
||||
|
||||
import { Switch as SwitchPrimitive } from "radix-ui"
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Switch({
|
||||
className,
|
||||
size = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof SwitchPrimitive.Root> & {
|
||||
size?: "sm" | "default"
|
||||
}) {
|
||||
return (
|
||||
<SwitchPrimitive.Root
|
||||
data-slot="switch"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"peer group/switch relative inline-flex shrink-0 items-center rounded-full border border-transparent transition-all outline-none after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/30 aria-invalid:border-destructive aria-invalid:ring-2 aria-invalid:ring-destructive/20 data-[size=default]:h-[16.6px] data-[size=default]:w-[28px] data-[size=sm]:h-[14px] data-[size=sm]:w-[24px] dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:bg-primary data-unchecked:bg-input dark:data-unchecked:bg-input/80 data-disabled:cursor-not-allowed data-disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SwitchPrimitive.Thumb
|
||||
data-slot="switch-thumb"
|
||||
className="pointer-events-none block rounded-full bg-background ring-0 transition-transform group-data-[size=default]/switch:size-3.5 group-data-[size=sm]/switch:size-3 group-data-[size=default]/switch:data-checked:translate-x-[calc(100%-2px)] group-data-[size=sm]/switch:data-checked:translate-x-[calc(100%-2px)] dark:data-checked:bg-primary-foreground group-data-[size=default]/switch:data-unchecked:translate-x-0 group-data-[size=sm]/switch:data-unchecked:translate-x-0 dark:data-unchecked:bg-foreground"
|
||||
/>
|
||||
</SwitchPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
export { Switch }
|
||||
53
apps/admin-dashboard/src/components/ui/tooltip.tsx
Normal file
53
apps/admin-dashboard/src/components/ui/tooltip.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
"use client"
|
||||
|
||||
import { Tooltip as TooltipPrimitive } from "radix-ui"
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function TooltipProvider({
|
||||
delayDuration = 0,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
|
||||
return (
|
||||
<TooltipPrimitive.Provider
|
||||
data-slot="tooltip-provider"
|
||||
delayDuration={delayDuration}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function Tooltip({ ...props }: React.ComponentProps<typeof TooltipPrimitive.Root>) {
|
||||
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
|
||||
}
|
||||
|
||||
function TooltipTrigger({ ...props }: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
|
||||
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
|
||||
}
|
||||
|
||||
function TooltipContent({
|
||||
className,
|
||||
sideOffset = 0,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
|
||||
return (
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Content
|
||||
data-slot="tooltip-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 inline-flex w-fit max-w-xs origin-(--radix-tooltip-content-transform-origin) items-center gap-1.5 rounded-md bg-foreground px-3 py-1.5 text-xs text-background has-data-[slot=kbd]:pr-1.5 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-sm data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground" />
|
||||
</TooltipPrimitive.Content>
|
||||
</TooltipPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger }
|
||||
19
apps/admin-dashboard/src/hooks/use-mobile.ts
Normal file
19
apps/admin-dashboard/src/hooks/use-mobile.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import * as React from "react"
|
||||
|
||||
const MOBILE_BREAKPOINT = 768
|
||||
|
||||
export function useIsMobile() {
|
||||
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
|
||||
|
||||
React.useEffect(() => {
|
||||
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
|
||||
const onChange = () => {
|
||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
|
||||
}
|
||||
mql.addEventListener("change", onChange)
|
||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
|
||||
return () => mql.removeEventListener("change", onChange)
|
||||
}, [])
|
||||
|
||||
return !!isMobile
|
||||
}
|
||||
129
apps/admin-dashboard/src/index.css
Normal file
129
apps/admin-dashboard/src/index.css
Normal file
@@ -0,0 +1,129 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
@import "shadcn/tailwind.css";
|
||||
@import "@fontsource-variable/inter";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
:root {
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.511 0.096 186.391);
|
||||
--primary-foreground: oklch(0.984 0.014 180.72);
|
||||
--secondary: oklch(0.967 0.001 286.375);
|
||||
--secondary-foreground: oklch(0.21 0.006 285.885);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--accent: oklch(0.97 0 0);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
--chart-1: oklch(0.855 0.138 181.071);
|
||||
--chart-2: oklch(0.704 0.14 182.503);
|
||||
--chart-3: oklch(0.6 0.118 184.704);
|
||||
--chart-4: oklch(0.511 0.096 186.391);
|
||||
--chart-5: oklch(0.437 0.078 188.216);
|
||||
--radius: 0.625rem;
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: oklch(0.6 0.118 184.704);
|
||||
--sidebar-primary-foreground: oklch(0.984 0.014 180.72);
|
||||
--sidebar-accent: oklch(0.97 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.145 0 0);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.205 0 0);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.205 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.437 0.078 188.216);
|
||||
--primary-foreground: oklch(0.984 0.014 180.72);
|
||||
--secondary: oklch(0.274 0.006 286.033);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.269 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.556 0 0);
|
||||
--chart-1: oklch(0.855 0.138 181.071);
|
||||
--chart-2: oklch(0.704 0.14 182.503);
|
||||
--chart-3: oklch(0.6 0.118 184.704);
|
||||
--chart-4: oklch(0.511 0.096 186.391);
|
||||
--chart-5: oklch(0.437 0.078 188.216);
|
||||
--sidebar: oklch(0.205 0 0);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.704 0.14 182.503);
|
||||
--sidebar-primary-foreground: oklch(0.277 0.046 192.524);
|
||||
--sidebar-accent: oklch(0.269 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.556 0 0);
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--font-sans: "Inter Variable", sans-serif;
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-ring: var(--ring);
|
||||
--color-input: var(--input);
|
||||
--color-border: var(--border);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-card: var(--card);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-background: var(--background);
|
||||
--radius-sm: calc(var(--radius) * 0.6);
|
||||
--radius-md: calc(var(--radius) * 0.8);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) * 1.4);
|
||||
--radius-2xl: calc(var(--radius) * 1.8);
|
||||
--radius-3xl: calc(var(--radius) * 2.2);
|
||||
--radius-4xl: calc(var(--radius) * 2.6);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground select-none;
|
||||
}
|
||||
html {
|
||||
@apply font-sans;
|
||||
}
|
||||
}
|
||||
335
apps/admin-dashboard/src/lib/api.ts
Normal file
335
apps/admin-dashboard/src/lib/api.ts
Normal file
@@ -0,0 +1,335 @@
|
||||
import { getServerUrl } from "./server-url"
|
||||
|
||||
function apiBase() {
|
||||
return `${getServerUrl()}/api/admin`
|
||||
}
|
||||
|
||||
function serverBase() {
|
||||
return `${getServerUrl()}/api`
|
||||
}
|
||||
|
||||
export interface ConfigFieldDef {
|
||||
type: "string" | "number" | "select" | "multiselect" | "boolean"
|
||||
label: string
|
||||
required?: boolean
|
||||
description?: string
|
||||
secret?: boolean
|
||||
defaultValue?: string | number | string[] | boolean
|
||||
options?: { label: string; value: string }[]
|
||||
}
|
||||
|
||||
export interface SourceDefinition {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
alwaysEnabled?: boolean
|
||||
/** When true, secret fields are stored as per-user credentials via /api/sources/:id/credentials. */
|
||||
perUserCredentials?: boolean
|
||||
fields: Record<string, ConfigFieldDef>
|
||||
}
|
||||
|
||||
export interface SourceConfig {
|
||||
sourceId: string
|
||||
enabled: boolean
|
||||
config: Record<string, unknown>
|
||||
}
|
||||
|
||||
const sourceDefinitions: SourceDefinition[] = [
|
||||
{
|
||||
id: "freya.location",
|
||||
name: "Location",
|
||||
description: "Device location provider. Always enabled as a dependency for other sources.",
|
||||
alwaysEnabled: true,
|
||||
fields: {},
|
||||
},
|
||||
{
|
||||
id: "freya.weather",
|
||||
name: "WeatherKit",
|
||||
description: "Apple WeatherKit weather data. Requires Apple Developer credentials.",
|
||||
fields: {
|
||||
privateKey: {
|
||||
type: "string",
|
||||
label: "Private Key",
|
||||
required: true,
|
||||
secret: true,
|
||||
description: "Apple WeatherKit private key (PEM format)",
|
||||
},
|
||||
keyId: { type: "string", label: "Key ID", required: true, secret: true },
|
||||
teamId: { type: "string", label: "Team ID", required: true, secret: true },
|
||||
serviceId: { type: "string", label: "Service ID", required: true, secret: true },
|
||||
units: {
|
||||
type: "select",
|
||||
label: "Units",
|
||||
options: [
|
||||
{ label: "Metric", value: "metric" },
|
||||
{ label: "Imperial", value: "imperial" },
|
||||
],
|
||||
defaultValue: "metric",
|
||||
},
|
||||
hourlyLimit: {
|
||||
type: "number",
|
||||
label: "Hourly Forecast Limit",
|
||||
defaultValue: 12,
|
||||
description: "Number of hourly forecasts to include",
|
||||
},
|
||||
dailyLimit: {
|
||||
type: "number",
|
||||
label: "Daily Forecast Limit",
|
||||
defaultValue: 7,
|
||||
description: "Number of daily forecasts to include",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "freya.caldav",
|
||||
name: "CalDAV",
|
||||
description: "Calendar events from any CalDAV server (Nextcloud, Radicale, Baikal, etc.).",
|
||||
perUserCredentials: true,
|
||||
fields: {
|
||||
serverUrl: {
|
||||
type: "string",
|
||||
label: "Server URL",
|
||||
required: true,
|
||||
secret: false,
|
||||
description: "CalDAV server URL (e.g. https://nextcloud.example.com/remote.php/dav)",
|
||||
},
|
||||
username: {
|
||||
type: "string",
|
||||
label: "Username",
|
||||
required: true,
|
||||
secret: false,
|
||||
},
|
||||
password: {
|
||||
type: "string",
|
||||
label: "Password",
|
||||
required: true,
|
||||
secret: true,
|
||||
},
|
||||
lookAheadDays: {
|
||||
type: "number",
|
||||
label: "Look-ahead Days",
|
||||
defaultValue: 0,
|
||||
description: "Number of additional days beyond today to fetch events for",
|
||||
},
|
||||
timeZone: {
|
||||
type: "string",
|
||||
label: "Timezone",
|
||||
description: 'IANA timezone for determining "today" (e.g. Europe/London). Defaults to UTC.',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "freya.tfl",
|
||||
name: "TfL",
|
||||
description: "Transport for London tube line status alerts.",
|
||||
fields: {
|
||||
lines: {
|
||||
type: "multiselect",
|
||||
label: "Lines",
|
||||
description: "Lines to monitor. Leave empty for all lines.",
|
||||
defaultValue: [],
|
||||
options: [
|
||||
{ label: "Bakerloo", value: "bakerloo" },
|
||||
{ label: "Central", value: "central" },
|
||||
{ label: "Circle", value: "circle" },
|
||||
{ label: "District", value: "district" },
|
||||
{ label: "Hammersmith & City", value: "hammersmith-city" },
|
||||
{ label: "Jubilee", value: "jubilee" },
|
||||
{ label: "Metropolitan", value: "metropolitan" },
|
||||
{ label: "Northern", value: "northern" },
|
||||
{ label: "Piccadilly", value: "piccadilly" },
|
||||
{ label: "Victoria", value: "victoria" },
|
||||
{ label: "Waterloo & City", value: "waterloo-city" },
|
||||
{ label: "Lioness", value: "lioness" },
|
||||
{ label: "Mildmay", value: "mildmay" },
|
||||
{ label: "Windrush", value: "windrush" },
|
||||
{ label: "Weaver", value: "weaver" },
|
||||
{ label: "Suffragette", value: "suffragette" },
|
||||
{ label: "Liberty", value: "liberty" },
|
||||
{ label: "Elizabeth", value: "elizabeth" },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
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[]> {
|
||||
return Promise.resolve(sourceDefinitions)
|
||||
}
|
||||
|
||||
export async function fetchSourceConfig(sourceId: string): Promise<SourceConfig | null> {
|
||||
const res = await fetch(`${serverBase()}/sources/${sourceId}`, {
|
||||
credentials: "include",
|
||||
})
|
||||
if (res.status === 404) return null
|
||||
if (!res.ok) throw new Error(`Failed to fetch source config: ${res.status}`)
|
||||
const data = (await res.json()) as { enabled: boolean; config: Record<string, unknown> }
|
||||
return { sourceId, enabled: data.enabled, config: data.config }
|
||||
}
|
||||
|
||||
export async function fetchConfigs(): Promise<SourceConfig[]> {
|
||||
const results = await Promise.all(sourceDefinitions.map((s) => fetchSourceConfig(s.id)))
|
||||
return results.filter((c): c is SourceConfig => c !== null)
|
||||
}
|
||||
|
||||
export async function replaceSource(
|
||||
sourceId: string,
|
||||
body: { enabled: boolean; config?: unknown; credentials?: Record<string, unknown> },
|
||||
): Promise<void> {
|
||||
const res = await fetch(`${serverBase()}/sources/${sourceId}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const data = (await res.json()) as { error?: string }
|
||||
throw new Error(data.error ?? `Failed to replace source config: ${res.status}`)
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateProviderConfig(
|
||||
sourceId: string,
|
||||
body: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
const res = await fetch(`${apiBase()}/${sourceId}/config`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const data = (await res.json()) as { error?: string }
|
||||
throw new Error(data.error ?? `Failed to update provider config: ${res.status}`)
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateSourceCredentials(
|
||||
sourceId: string,
|
||||
credentials: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
const res = await fetch(`${serverBase()}/sources/${sourceId}/credentials`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify(credentials),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const data = (await res.json()) as { error?: string }
|
||||
throw new Error(data.error ?? `Failed to update credentials: ${res.status}`)
|
||||
}
|
||||
}
|
||||
|
||||
export 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 {
|
||||
lat: number
|
||||
lng: number
|
||||
accuracy: number
|
||||
}
|
||||
|
||||
export async function pushLocation(location: LocationInput): Promise<void> {
|
||||
const res = await fetch(`${serverBase()}/location`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({
|
||||
...location,
|
||||
timestamp: new Date().toISOString(),
|
||||
}),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const data = (await res.json()) as { error?: string }
|
||||
throw new Error(data.error ?? `Failed to push location: ${res.status}`)
|
||||
}
|
||||
}
|
||||
|
||||
export interface FeedItemSlot {
|
||||
description: string
|
||||
content: string | null
|
||||
}
|
||||
|
||||
export interface FeedItem {
|
||||
id: string
|
||||
sourceId: string
|
||||
type: string
|
||||
timestamp: string
|
||||
data: Record<string, unknown>
|
||||
signals?: {
|
||||
urgency?: number
|
||||
timeRelevance?: string
|
||||
}
|
||||
slots?: Record<string, FeedItemSlot>
|
||||
ui?: unknown
|
||||
}
|
||||
|
||||
export interface FeedResponse {
|
||||
items: FeedItem[]
|
||||
errors: { sourceId: string; error: string }[]
|
||||
}
|
||||
|
||||
export async function fetchFeed(): Promise<FeedResponse> {
|
||||
const res = await fetch(`${serverBase()}/feed`, { credentials: "include" })
|
||||
if (!res.ok) throw new Error(`Failed to fetch feed: ${res.status}`)
|
||||
return res.json() as Promise<FeedResponse>
|
||||
}
|
||||
47
apps/admin-dashboard/src/lib/auth.ts
Normal file
47
apps/admin-dashboard/src/lib/auth.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { getServerUrl } from "./server-url"
|
||||
|
||||
function authBase() {
|
||||
return `${getServerUrl()}/api/auth`
|
||||
}
|
||||
|
||||
export interface AuthUser {
|
||||
id: string
|
||||
name: string
|
||||
email: string
|
||||
image: string | null
|
||||
}
|
||||
|
||||
export interface AuthSession {
|
||||
user: AuthUser
|
||||
session: { id: string; token: string }
|
||||
}
|
||||
|
||||
export async function getSession(): Promise<AuthSession | null> {
|
||||
const res = await fetch(`${authBase()}/get-session`, {
|
||||
credentials: "include",
|
||||
})
|
||||
if (!res.ok) return null
|
||||
const data = (await res.json()) as AuthSession | null
|
||||
return data
|
||||
}
|
||||
|
||||
export async function signIn(email: string, password: string): Promise<AuthSession> {
|
||||
const res = await fetch(`${authBase()}/sign-in/email`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({ email, password }),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const data = (await res.json()) as { message?: string }
|
||||
throw new Error(data.message ?? `Sign in failed: ${res.status}`)
|
||||
}
|
||||
return (await res.json()) as AuthSession
|
||||
}
|
||||
|
||||
export async function signOut(): Promise<void> {
|
||||
await fetch(`${authBase()}/sign-out`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
})
|
||||
}
|
||||
10
apps/admin-dashboard/src/lib/server-url.ts
Normal file
10
apps/admin-dashboard/src/lib/server-url.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
const STORAGE_KEY = "freya-server-url"
|
||||
const DEFAULT_URL = "https://3000--019cf276-6ed6-7529-a425-210182693908.eu-runner.flex.doptig.cloud"
|
||||
|
||||
export function getServerUrl(): string {
|
||||
return localStorage.getItem(STORAGE_KEY) ?? DEFAULT_URL
|
||||
}
|
||||
|
||||
export function setServerUrl(url: string): void {
|
||||
localStorage.setItem(STORAGE_KEY, url.replace(/\/+$/, ""))
|
||||
}
|
||||
6
apps/admin-dashboard/src/lib/utils.ts
Normal file
6
apps/admin-dashboard/src/lib/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
29
apps/admin-dashboard/src/main.tsx
Normal file
29
apps/admin-dashboard/src/main.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
|
||||
import { StrictMode } from "react"
|
||||
import { createRoot } from "react-dom/client"
|
||||
|
||||
import "./index.css"
|
||||
import { ThemeProvider } from "@/components/theme-provider.tsx"
|
||||
import { Toaster } from "@/components/ui/sonner.tsx"
|
||||
|
||||
import App from "./App.tsx"
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ThemeProvider>
|
||||
<App />
|
||||
<Toaster />
|
||||
</ThemeProvider>
|
||||
</QueryClientProvider>
|
||||
</StrictMode>,
|
||||
)
|
||||
11
apps/admin-dashboard/src/route-tree.gen.ts
Normal file
11
apps/admin-dashboard/src/route-tree.gen.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Route as rootRoute } from "./routes/__root"
|
||||
import { Route as dashboardRoute } from "./routes/_dashboard"
|
||||
import { Route as dashboardFeedRoute } from "./routes/_dashboard/feed"
|
||||
import { Route as dashboardIndexRoute } from "./routes/_dashboard/index"
|
||||
import { Route as dashboardSourceRoute } from "./routes/_dashboard/sources.$sourceId"
|
||||
import { Route as loginRoute } from "./routes/login"
|
||||
|
||||
export const routeTree = rootRoute.addChildren([
|
||||
loginRoute,
|
||||
dashboardRoute.addChildren([dashboardIndexRoute, dashboardFeedRoute, dashboardSourceRoute]),
|
||||
])
|
||||
15
apps/admin-dashboard/src/routes/__root.tsx
Normal file
15
apps/admin-dashboard/src/routes/__root.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import type { QueryClient } from "@tanstack/react-query"
|
||||
|
||||
import { createRootRouteWithContext, Outlet } from "@tanstack/react-router"
|
||||
|
||||
import { TooltipProvider } from "@/components/ui/tooltip"
|
||||
|
||||
export const Route = createRootRouteWithContext<{ queryClient: QueryClient }>()({
|
||||
component: function RootLayout() {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Outlet />
|
||||
</TooltipProvider>
|
||||
)
|
||||
},
|
||||
})
|
||||
223
apps/admin-dashboard/src/routes/_dashboard.tsx
Normal file
223
apps/admin-dashboard/src/routes/_dashboard.tsx
Normal file
@@ -0,0 +1,223 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
||||
import {
|
||||
createRoute,
|
||||
Outlet,
|
||||
redirect,
|
||||
useMatchRoute,
|
||||
useNavigate,
|
||||
Link,
|
||||
} from "@tanstack/react-router"
|
||||
import {
|
||||
Bell,
|
||||
Calendar,
|
||||
CalendarDays,
|
||||
CircleDot,
|
||||
CloudSun,
|
||||
Loader2,
|
||||
TrainFront,
|
||||
LogOut,
|
||||
Map as MapIcon,
|
||||
MapPin,
|
||||
Rss,
|
||||
Server,
|
||||
TriangleAlert,
|
||||
} from "lucide-react"
|
||||
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
SidebarGroupLabel,
|
||||
SidebarHeader,
|
||||
SidebarInset,
|
||||
SidebarMenu,
|
||||
SidebarMenuBadge,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarProvider,
|
||||
SidebarTrigger,
|
||||
} from "@/components/ui/sidebar"
|
||||
import { fetchConfigs, fetchSources } from "@/lib/api"
|
||||
import { getSession, signOut } from "@/lib/auth"
|
||||
|
||||
import { Route as rootRoute } from "./__root"
|
||||
|
||||
const SOURCE_ICONS: Record<string, React.ComponentType<{ className?: string }>> = {
|
||||
"freya.location": MapPin,
|
||||
"freya.weather": CloudSun,
|
||||
"freya.caldav": CalendarDays,
|
||||
"freya.google-calendar": Calendar,
|
||||
"freya.google-maps": MapIcon,
|
||||
"freya.reminders": Bell,
|
||||
"freya.tfl": TrainFront,
|
||||
}
|
||||
|
||||
export const Route = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
id: "dashboard",
|
||||
beforeLoad: async ({ context }) => {
|
||||
let session: Awaited<ReturnType<typeof getSession>> | null = null
|
||||
try {
|
||||
session = await context.queryClient.ensureQueryData({
|
||||
queryKey: ["session"],
|
||||
queryFn: getSession,
|
||||
})
|
||||
} catch {
|
||||
throw redirect({ to: "/login" })
|
||||
}
|
||||
if (!session?.user) {
|
||||
throw redirect({ to: "/login" })
|
||||
}
|
||||
return { user: session.user }
|
||||
},
|
||||
component: DashboardLayout,
|
||||
pendingComponent: () => (
|
||||
<div className="flex min-h-svh items-center justify-center">
|
||||
<Loader2 className="size-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
),
|
||||
})
|
||||
|
||||
function DashboardLayout() {
|
||||
const { user } = Route.useRouteContext()
|
||||
const navigate = useNavigate()
|
||||
const queryClient = useQueryClient()
|
||||
const matchRoute = useMatchRoute()
|
||||
|
||||
const { data: sources = [] } = useQuery({
|
||||
queryKey: ["sources"],
|
||||
queryFn: fetchSources,
|
||||
})
|
||||
|
||||
const {
|
||||
data: configs = [],
|
||||
error: configsError,
|
||||
refetch: refetchConfigs,
|
||||
} = useQuery({
|
||||
queryKey: ["configs"],
|
||||
queryFn: fetchConfigs,
|
||||
})
|
||||
|
||||
const logoutMutation = useMutation({
|
||||
mutationFn: signOut,
|
||||
onSuccess() {
|
||||
queryClient.setQueryData(["session"], null)
|
||||
queryClient.clear()
|
||||
navigate({ to: "/login" })
|
||||
},
|
||||
})
|
||||
|
||||
const error = configsError?.message ?? null
|
||||
const configMap = new Map(configs.map((c) => [c.sourceId, c]))
|
||||
|
||||
return (
|
||||
<SidebarProvider>
|
||||
<Sidebar>
|
||||
<SidebarHeader>
|
||||
<div className="flex items-center justify-between px-2 py-1">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-medium">{user.name}</p>
|
||||
<p className="truncate text-xs text-muted-foreground">{user.email}</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7 shrink-0"
|
||||
onClick={() => logoutMutation.mutate()}
|
||||
>
|
||||
<LogOut className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</SidebarHeader>
|
||||
<SidebarContent>
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>General</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton isActive={!!matchRoute({ to: "/" })} asChild>
|
||||
<Link to="/">
|
||||
<Server className="size-4" />
|
||||
<span>Server</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton isActive={!!matchRoute({ to: "/feed" })} asChild>
|
||||
<Link to="/feed">
|
||||
<Rss className="size-4" />
|
||||
<span>Feed</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>Sources</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{sources.map((source) => {
|
||||
const Icon = SOURCE_ICONS[source.id] ?? CircleDot
|
||||
const cfg = configMap.get(source.id)
|
||||
const isEnabled = source.alwaysEnabled || cfg?.enabled
|
||||
return (
|
||||
<SidebarMenuItem key={source.id}>
|
||||
<SidebarMenuButton
|
||||
isActive={
|
||||
!!matchRoute({
|
||||
to: "/sources/$sourceId",
|
||||
params: { sourceId: source.id },
|
||||
})
|
||||
}
|
||||
asChild
|
||||
>
|
||||
<Link to="/sources/$sourceId" params={{ sourceId: source.id }}>
|
||||
<Icon className="size-4" />
|
||||
<span>{source.name}</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
{isEnabled && (
|
||||
<SidebarMenuBadge>
|
||||
<CircleDot className="size-2.5 text-primary" />
|
||||
</SidebarMenuBadge>
|
||||
)}
|
||||
</SidebarMenuItem>
|
||||
)
|
||||
})}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
</SidebarContent>
|
||||
</Sidebar>
|
||||
|
||||
<SidebarInset>
|
||||
<header className="flex h-12 items-center gap-2 border-b px-4">
|
||||
<SidebarTrigger className="-ml-1" />
|
||||
<Separator orientation="vertical" className="mr-2 !h-4" />
|
||||
</header>
|
||||
|
||||
<main className="flex-1 p-6">
|
||||
{error && (
|
||||
<Alert variant="destructive" className="mb-6">
|
||||
<TriangleAlert className="size-4" />
|
||||
<AlertDescription className="flex items-center justify-between">
|
||||
<span>{error}</span>
|
||||
<Button variant="ghost" size="sm" onClick={() => refetchConfigs()}>
|
||||
Retry
|
||||
</Button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Outlet />
|
||||
</main>
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
)
|
||||
}
|
||||
11
apps/admin-dashboard/src/routes/_dashboard/feed.tsx
Normal file
11
apps/admin-dashboard/src/routes/_dashboard/feed.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import { createRoute } from "@tanstack/react-router"
|
||||
|
||||
import { FeedPanel } from "@/components/feed-panel"
|
||||
|
||||
import { Route as dashboardRoute } from "../_dashboard"
|
||||
|
||||
export const Route = createRoute({
|
||||
getParentRoute: () => dashboardRoute,
|
||||
path: "/feed",
|
||||
component: FeedPanel,
|
||||
})
|
||||
11
apps/admin-dashboard/src/routes/_dashboard/index.tsx
Normal file
11
apps/admin-dashboard/src/routes/_dashboard/index.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import { createRoute } from "@tanstack/react-router"
|
||||
|
||||
import { GeneralSettingsPanel } from "@/components/general-settings-panel"
|
||||
|
||||
import { Route as dashboardRoute } from "../_dashboard"
|
||||
|
||||
export const Route = createRoute({
|
||||
getParentRoute: () => dashboardRoute,
|
||||
path: "/",
|
||||
component: GeneralSettingsPanel,
|
||||
})
|
||||
@@ -0,0 +1,35 @@
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query"
|
||||
import { createRoute } from "@tanstack/react-router"
|
||||
|
||||
import { SourceConfigPanel } from "@/components/source-config-panel"
|
||||
import { fetchSources } from "@/lib/api"
|
||||
|
||||
import { Route as dashboardRoute } from "../_dashboard"
|
||||
|
||||
export const Route = createRoute({
|
||||
getParentRoute: () => dashboardRoute,
|
||||
path: "/sources/$sourceId",
|
||||
component: SourceRoute,
|
||||
})
|
||||
|
||||
function SourceRoute() {
|
||||
const { sourceId } = Route.useParams()
|
||||
const queryClient = useQueryClient()
|
||||
const { data: sources = [] } = useQuery({
|
||||
queryKey: ["sources"],
|
||||
queryFn: fetchSources,
|
||||
})
|
||||
const source = sources.find((s) => s.id === sourceId)
|
||||
|
||||
if (!source) {
|
||||
return <p className="text-sm text-muted-foreground">Source not found.</p>
|
||||
}
|
||||
|
||||
return (
|
||||
<SourceConfigPanel
|
||||
key={source.id}
|
||||
source={source}
|
||||
onUpdate={() => queryClient.invalidateQueries({ queryKey: ["configs"] })}
|
||||
/>
|
||||
)
|
||||
}
|
||||
24
apps/admin-dashboard/src/routes/login.tsx
Normal file
24
apps/admin-dashboard/src/routes/login.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { useQueryClient } from "@tanstack/react-query"
|
||||
import { createRoute, useNavigate } from "@tanstack/react-router"
|
||||
|
||||
import type { AuthSession } from "@/lib/auth"
|
||||
|
||||
import { LoginPage } from "@/components/login-page"
|
||||
|
||||
import { Route as rootRoute } from "./__root"
|
||||
|
||||
export const Route = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: "/login",
|
||||
component: function LoginRoute() {
|
||||
const navigate = useNavigate()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
function handleLogin(session: AuthSession) {
|
||||
queryClient.setQueryData(["session"], session)
|
||||
navigate({ to: "/" })
|
||||
}
|
||||
|
||||
return <LoginPage onLogin={handleLogin} />
|
||||
},
|
||||
})
|
||||
29
apps/admin-dashboard/tsconfig.app.json
Normal file
29
apps/admin-dashboard/tsconfig.app.json
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM"],
|
||||
"module": "ESNext",
|
||||
"types": ["vite/client"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true,
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
9
apps/admin-dashboard/tsconfig.json
Normal file
9
apps/admin-dashboard/tsconfig.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }],
|
||||
"compilerOptions": {
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
24
apps/admin-dashboard/tsconfig.node.json
Normal file
24
apps/admin-dashboard/tsconfig.node.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "ES2023",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"types": ["node"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
19
apps/admin-dashboard/vite.config.ts
Normal file
19
apps/admin-dashboard/vite.config.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import tailwindcss from "@tailwindcss/vite"
|
||||
import react from "@vitejs/plugin-react"
|
||||
import path from "path"
|
||||
import { defineConfig } from "vite"
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "./src"),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
host: "0.0.0.0",
|
||||
port: 5174,
|
||||
allowedHosts: true,
|
||||
},
|
||||
})
|
||||
@@ -1 +0,0 @@
|
||||
CREATE INDEX "user_sources_user_id_enabled_idx" ON "user_sources" USING btree ("user_id","enabled");
|
||||
@@ -1,457 +0,0 @@
|
||||
{
|
||||
"id": "d8c59ec7-b686-41a7-a472-da29f3ab6727",
|
||||
"prevId": "00000000-0000-0000-0000-000000000000",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"tables": {
|
||||
"public.account": {
|
||||
"name": "account",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"account_id": {
|
||||
"name": "account_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"provider_id": {
|
||||
"name": "provider_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"access_token": {
|
||||
"name": "access_token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"refresh_token": {
|
||||
"name": "refresh_token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"id_token": {
|
||||
"name": "id_token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"access_token_expires_at": {
|
||||
"name": "access_token_expires_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"refresh_token_expires_at": {
|
||||
"name": "refresh_token_expires_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"scope": {
|
||||
"name": "scope",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"password": {
|
||||
"name": "password",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"account_userId_idx": {
|
||||
"name": "account_userId_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "user_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"account_user_id_user_id_fk": {
|
||||
"name": "account_user_id_user_id_fk",
|
||||
"tableFrom": "account",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.session": {
|
||||
"name": "session",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"token": {
|
||||
"name": "token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"ip_address": {
|
||||
"name": "ip_address",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"user_agent": {
|
||||
"name": "user_agent",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"session_userId_idx": {
|
||||
"name": "session_userId_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "user_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"session_user_id_user_id_fk": {
|
||||
"name": "session_user_id_user_id_fk",
|
||||
"tableFrom": "session",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"session_token_unique": {
|
||||
"name": "session_token_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"token"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.user": {
|
||||
"name": "user",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"email": {
|
||||
"name": "email",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"email_verified": {
|
||||
"name": "email_verified",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": false
|
||||
},
|
||||
"image": {
|
||||
"name": "image",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"user_email_unique": {
|
||||
"name": "user_email_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"email"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.user_sources": {
|
||||
"name": "user_sources",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"source_id": {
|
||||
"name": "source_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"enabled": {
|
||||
"name": "enabled",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": true
|
||||
},
|
||||
"config": {
|
||||
"name": "config",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": "'{}'::jsonb"
|
||||
},
|
||||
"credentials": {
|
||||
"name": "credentials",
|
||||
"type": "bytea",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"user_sources_user_id_user_id_fk": {
|
||||
"name": "user_sources_user_id_user_id_fk",
|
||||
"tableFrom": "user_sources",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"user_sources_user_id_source_id_unique": {
|
||||
"name": "user_sources_user_id_source_id_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"user_id",
|
||||
"source_id"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.verification": {
|
||||
"name": "verification",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"identifier": {
|
||||
"name": "identifier",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"value": {
|
||||
"name": "value",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"verification_identifier_idx": {
|
||||
"name": "verification_identifier_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "identifier",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
}
|
||||
},
|
||||
"enums": {},
|
||||
"schemas": {},
|
||||
"sequences": {},
|
||||
"roles": {},
|
||||
"policies": {},
|
||||
"views": {},
|
||||
"_meta": {
|
||||
"columns": {},
|
||||
"schemas": {},
|
||||
"tables": {}
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
{
|
||||
"name": "@aelis/backend",
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"main": "src/server.ts",
|
||||
"scripts": {
|
||||
"dev": "bun run --watch src/server.ts",
|
||||
"start": "bun run src/server.ts",
|
||||
"test": "bun test src/",
|
||||
"db:generate": "bunx drizzle-kit generate",
|
||||
"db:generate-auth": "bunx --bun auth@latest generate --config auth.ts --output src/db/auth-schema.ts -y",
|
||||
"db:push": "bunx drizzle-kit push",
|
||||
"db:migrate": "bunx drizzle-kit migrate",
|
||||
"db:studio": "bunx drizzle-kit studio",
|
||||
"create-admin": "bun run src/scripts/create-admin.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aelis/core": "workspace:*",
|
||||
"@aelis/source-caldav": "workspace:*",
|
||||
"@aelis/source-google-calendar": "workspace:*",
|
||||
"@aelis/source-location": "workspace:*",
|
||||
"@aelis/source-tfl": "workspace:*",
|
||||
"@aelis/source-weatherkit": "workspace:*",
|
||||
"@openrouter/sdk": "^0.9.11",
|
||||
"arktype": "^2.1.29",
|
||||
"better-auth": "^1",
|
||||
"drizzle-orm": "^0.45.1",
|
||||
"hono": "^4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"drizzle-kit": "^0.31.9"
|
||||
}
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
import {
|
||||
boolean,
|
||||
customType,
|
||||
index,
|
||||
jsonb,
|
||||
pgTable,
|
||||
text,
|
||||
timestamp,
|
||||
unique,
|
||||
uuid,
|
||||
} from "drizzle-orm/pg-core"
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Better Auth core tables
|
||||
// Re-exported from CLI-generated schema.
|
||||
// Regenerate with: bunx --bun auth@latest generate --config auth.ts --output src/db/auth-schema.ts
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export {
|
||||
user,
|
||||
session,
|
||||
account,
|
||||
verification,
|
||||
userRelations,
|
||||
sessionRelations,
|
||||
accountRelations,
|
||||
} from "./auth-schema.ts"
|
||||
|
||||
import { user } from "./auth-schema.ts"
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// AELIS — per-user source configuration
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const bytea = customType<{ data: Buffer }>({
|
||||
dataType() {
|
||||
return "bytea"
|
||||
},
|
||||
})
|
||||
|
||||
export const userSources = pgTable(
|
||||
"user_sources",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
sourceId: text("source_id").notNull(),
|
||||
enabled: boolean("enabled").notNull().default(true),
|
||||
config: jsonb("config").default({}),
|
||||
credentials: bytea("credentials"),
|
||||
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at")
|
||||
.notNull()
|
||||
.defaultNow()
|
||||
.$onUpdate(() => new Date()),
|
||||
},
|
||||
(t) => [
|
||||
unique("user_sources_user_id_source_id_unique").on(t.userId, t.sourceId),
|
||||
index("user_sources_user_id_enabled_idx").on(t.userId, t.enabled),
|
||||
],
|
||||
)
|
||||
@@ -1,25 +0,0 @@
|
||||
import { LocationSource } from "@aelis/source-location"
|
||||
|
||||
import type { Database } from "../db/index.ts"
|
||||
import type { FeedSourceProvider } from "../session/feed-source-provider.ts"
|
||||
|
||||
import { SourceDisabledError } from "../sources/errors.ts"
|
||||
import { sources } from "../sources/user-sources.ts"
|
||||
|
||||
export class LocationSourceProvider implements FeedSourceProvider {
|
||||
private readonly db: Database
|
||||
|
||||
constructor(db: Database) {
|
||||
this.db = db
|
||||
}
|
||||
|
||||
async feedSourceForUser(userId: string): Promise<LocationSource> {
|
||||
const row = await sources(this.db, userId).find("aelis.location")
|
||||
|
||||
if (!row || !row.enabled) {
|
||||
throw new SourceDisabledError("aelis.location", userId)
|
||||
}
|
||||
|
||||
return new LocationSource()
|
||||
}
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
import { Hono } from "hono"
|
||||
|
||||
import { registerAuthHandlers } from "./auth/http.ts"
|
||||
import { createAuth } from "./auth/index.ts"
|
||||
import { createRequireSession } from "./auth/session-middleware.ts"
|
||||
import { createDatabase } from "./db/index.ts"
|
||||
import { registerFeedHttpHandlers } from "./engine/http.ts"
|
||||
import { createFeedEnhancer } from "./enhancement/enhance-feed.ts"
|
||||
import { createLlmClient } from "./enhancement/llm-client.ts"
|
||||
import { registerLocationHttpHandlers } from "./location/http.ts"
|
||||
import { LocationSourceProvider } from "./location/provider.ts"
|
||||
import { UserSessionManager } from "./session/index.ts"
|
||||
import { WeatherSourceProvider } from "./weather/provider.ts"
|
||||
|
||||
function main() {
|
||||
const { db, close: closeDb } = createDatabase(process.env.DATABASE_URL!)
|
||||
const auth = createAuth(db)
|
||||
|
||||
const openrouterApiKey = process.env.OPENROUTER_API_KEY
|
||||
const feedEnhancer = openrouterApiKey
|
||||
? createFeedEnhancer({
|
||||
client: createLlmClient({
|
||||
apiKey: openrouterApiKey,
|
||||
model: process.env.OPENROUTER_MODEL || undefined,
|
||||
}),
|
||||
})
|
||||
: null
|
||||
if (!feedEnhancer) {
|
||||
console.warn("[enhancement] OPENROUTER_API_KEY not set — feed enhancement disabled")
|
||||
}
|
||||
|
||||
const sessionManager = new UserSessionManager({
|
||||
providers: [
|
||||
new LocationSourceProvider(db),
|
||||
new WeatherSourceProvider({
|
||||
db,
|
||||
credentials: {
|
||||
privateKey: process.env.WEATHERKIT_PRIVATE_KEY!,
|
||||
keyId: process.env.WEATHERKIT_KEY_ID!,
|
||||
teamId: process.env.WEATHERKIT_TEAM_ID!,
|
||||
serviceId: process.env.WEATHERKIT_SERVICE_ID!,
|
||||
},
|
||||
}),
|
||||
],
|
||||
feedEnhancer,
|
||||
})
|
||||
|
||||
const app = new Hono()
|
||||
|
||||
app.get("/health", (c) => c.json({ status: "ok" }))
|
||||
|
||||
const authSessionMiddleware = createRequireSession(auth)
|
||||
|
||||
registerAuthHandlers(app, auth)
|
||||
|
||||
registerFeedHttpHandlers(app, {
|
||||
sessionManager,
|
||||
authSessionMiddleware,
|
||||
})
|
||||
registerLocationHttpHandlers(app, { sessionManager, authSessionMiddleware })
|
||||
|
||||
process.on("SIGTERM", async () => {
|
||||
await closeDb()
|
||||
process.exit(0)
|
||||
})
|
||||
|
||||
return app
|
||||
}
|
||||
|
||||
const app = main()
|
||||
|
||||
export default {
|
||||
port: 3000,
|
||||
fetch: app.fetch,
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
import type { FeedSource } from "@aelis/core"
|
||||
|
||||
export interface FeedSourceProvider {
|
||||
feedSourceForUser(userId: string): Promise<FeedSource>
|
||||
}
|
||||
|
||||
export type FeedSourceProviderFn = (userId: string) => Promise<FeedSource>
|
||||
|
||||
export type FeedSourceProviderInput = FeedSourceProvider | FeedSourceProviderFn
|
||||
@@ -1,7 +0,0 @@
|
||||
export type {
|
||||
FeedSourceProvider,
|
||||
FeedSourceProviderFn,
|
||||
FeedSourceProviderInput,
|
||||
} from "./feed-source-provider.ts"
|
||||
export { UserSession } from "./user-session.ts"
|
||||
export { UserSessionManager } from "./user-session-manager.ts"
|
||||
@@ -1,254 +0,0 @@
|
||||
import { LocationSource } from "@aelis/source-location"
|
||||
import { WeatherSource } from "@aelis/source-weatherkit"
|
||||
import { describe, expect, mock, spyOn, test } from "bun:test"
|
||||
|
||||
import { UserSessionManager } from "./user-session-manager.ts"
|
||||
|
||||
const mockWeatherProvider = async () =>
|
||||
new WeatherSource({ client: { fetch: async () => ({}) as never } })
|
||||
|
||||
describe("UserSessionManager", () => {
|
||||
test("getOrCreate creates session on first call", async () => {
|
||||
const manager = new UserSessionManager({ providers: [async () => new LocationSource()] })
|
||||
|
||||
const session = await manager.getOrCreate("user-1")
|
||||
|
||||
expect(session).toBeDefined()
|
||||
expect(session.engine).toBeDefined()
|
||||
})
|
||||
|
||||
test("getOrCreate returns same session for same user", async () => {
|
||||
const manager = new UserSessionManager({ providers: [async () => new LocationSource()] })
|
||||
|
||||
const session1 = await manager.getOrCreate("user-1")
|
||||
const session2 = await manager.getOrCreate("user-1")
|
||||
|
||||
expect(session1).toBe(session2)
|
||||
})
|
||||
|
||||
test("getOrCreate returns different sessions for different users", async () => {
|
||||
const manager = new UserSessionManager({ providers: [async () => new LocationSource()] })
|
||||
|
||||
const session1 = await manager.getOrCreate("user-1")
|
||||
const session2 = await manager.getOrCreate("user-2")
|
||||
|
||||
expect(session1).not.toBe(session2)
|
||||
})
|
||||
|
||||
test("each user gets independent source instances", async () => {
|
||||
const manager = new UserSessionManager({ providers: [async () => new LocationSource()] })
|
||||
|
||||
const session1 = await manager.getOrCreate("user-1")
|
||||
const session2 = await manager.getOrCreate("user-2")
|
||||
|
||||
const source1 = session1.getSource<LocationSource>("aelis.location")
|
||||
const source2 = session2.getSource<LocationSource>("aelis.location")
|
||||
|
||||
expect(source1).not.toBe(source2)
|
||||
})
|
||||
|
||||
test("remove destroys session and allows re-creation", async () => {
|
||||
const manager = new UserSessionManager({ providers: [async () => new LocationSource()] })
|
||||
|
||||
const session1 = await manager.getOrCreate("user-1")
|
||||
manager.remove("user-1")
|
||||
const session2 = await manager.getOrCreate("user-1")
|
||||
|
||||
expect(session1).not.toBe(session2)
|
||||
})
|
||||
|
||||
test("remove is no-op for unknown user", () => {
|
||||
const manager = new UserSessionManager({ providers: [async () => new LocationSource()] })
|
||||
|
||||
expect(() => manager.remove("unknown")).not.toThrow()
|
||||
})
|
||||
|
||||
test("accepts function providers", async () => {
|
||||
const manager = new UserSessionManager({ providers: [async () => new LocationSource()] })
|
||||
|
||||
const session = await manager.getOrCreate("user-1")
|
||||
const result = await session.engine.refresh()
|
||||
|
||||
expect(result.errors).toHaveLength(0)
|
||||
})
|
||||
|
||||
test("accepts object providers", async () => {
|
||||
const manager = new UserSessionManager({
|
||||
providers: [async () => new LocationSource(), mockWeatherProvider],
|
||||
})
|
||||
|
||||
const session = await manager.getOrCreate("user-1")
|
||||
|
||||
expect(session.getSource("aelis.weather")).toBeDefined()
|
||||
})
|
||||
|
||||
test("accepts mixed providers", async () => {
|
||||
const manager = new UserSessionManager({
|
||||
providers: [async () => new LocationSource(), mockWeatherProvider],
|
||||
})
|
||||
|
||||
const session = await manager.getOrCreate("user-1")
|
||||
|
||||
expect(session.getSource("aelis.location")).toBeDefined()
|
||||
expect(session.getSource("aelis.weather")).toBeDefined()
|
||||
})
|
||||
|
||||
test("refresh returns feed result through session", async () => {
|
||||
const manager = new UserSessionManager({ providers: [async () => new LocationSource()] })
|
||||
|
||||
const session = await manager.getOrCreate("user-1")
|
||||
const result = await session.engine.refresh()
|
||||
|
||||
expect(result).toHaveProperty("context")
|
||||
expect(result).toHaveProperty("items")
|
||||
expect(result).toHaveProperty("errors")
|
||||
expect(result.context.time).toBeInstanceOf(Date)
|
||||
})
|
||||
|
||||
test("location update via executeAction works", async () => {
|
||||
const manager = new UserSessionManager({ providers: [async () => new LocationSource()] })
|
||||
|
||||
const session = await manager.getOrCreate("user-1")
|
||||
await session.engine.executeAction("aelis.location", "update-location", {
|
||||
lat: 51.5074,
|
||||
lng: -0.1278,
|
||||
accuracy: 10,
|
||||
timestamp: new Date(),
|
||||
})
|
||||
|
||||
const source = session.getSource<LocationSource>("aelis.location")
|
||||
expect(source?.lastLocation?.lat).toBe(51.5074)
|
||||
})
|
||||
|
||||
test("subscribe receives updates after location push", async () => {
|
||||
const manager = new UserSessionManager({ providers: [async () => new LocationSource()] })
|
||||
const callback = mock()
|
||||
|
||||
const session = await manager.getOrCreate("user-1")
|
||||
session.engine.subscribe(callback)
|
||||
|
||||
await session.engine.executeAction("aelis.location", "update-location", {
|
||||
lat: 51.5074,
|
||||
lng: -0.1278,
|
||||
accuracy: 10,
|
||||
timestamp: new Date(),
|
||||
})
|
||||
|
||||
// Wait for async update propagation
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
|
||||
expect(callback).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test("remove stops reactive updates", async () => {
|
||||
const manager = new UserSessionManager({ providers: [async () => new LocationSource()] })
|
||||
const callback = mock()
|
||||
|
||||
const session = await manager.getOrCreate("user-1")
|
||||
session.engine.subscribe(callback)
|
||||
|
||||
manager.remove("user-1")
|
||||
|
||||
// Create new session and push location — old callback should not fire
|
||||
const session2 = await manager.getOrCreate("user-1")
|
||||
await session2.engine.executeAction("aelis.location", "update-location", {
|
||||
lat: 51.5074,
|
||||
lng: -0.1278,
|
||||
accuracy: 10,
|
||||
timestamp: new Date(),
|
||||
})
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
|
||||
expect(callback).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test("creates session with successful providers when some fail", async () => {
|
||||
const manager = new UserSessionManager({
|
||||
providers: [
|
||||
async () => new LocationSource(),
|
||||
async () => {
|
||||
throw new Error("provider failed")
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const spy = spyOn(console, "error").mockImplementation(() => {})
|
||||
|
||||
const session = await manager.getOrCreate("user-1")
|
||||
|
||||
expect(session).toBeDefined()
|
||||
expect(session.getSource("aelis.location")).toBeDefined()
|
||||
expect(spy).toHaveBeenCalled()
|
||||
|
||||
spy.mockRestore()
|
||||
})
|
||||
|
||||
test("throws AggregateError when all providers fail", async () => {
|
||||
const manager = new UserSessionManager({
|
||||
providers: [
|
||||
async () => {
|
||||
throw new Error("first failed")
|
||||
},
|
||||
async () => {
|
||||
throw new Error("second failed")
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
await expect(manager.getOrCreate("user-1")).rejects.toBeInstanceOf(AggregateError)
|
||||
})
|
||||
|
||||
test("concurrent getOrCreate for same user returns same session", async () => {
|
||||
let callCount = 0
|
||||
const manager = new UserSessionManager({
|
||||
providers: [
|
||||
async () => {
|
||||
callCount++
|
||||
// Simulate async work to widen the race window
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
return new LocationSource()
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const [session1, session2] = await Promise.all([
|
||||
manager.getOrCreate("user-1"),
|
||||
manager.getOrCreate("user-1"),
|
||||
])
|
||||
|
||||
expect(session1).toBe(session2)
|
||||
expect(callCount).toBe(1)
|
||||
})
|
||||
|
||||
test("remove during in-flight getOrCreate prevents session from being stored", async () => {
|
||||
let resolveProvider: () => void
|
||||
const providerGate = new Promise<void>((r) => {
|
||||
resolveProvider = r
|
||||
})
|
||||
|
||||
const manager = new UserSessionManager({
|
||||
providers: [
|
||||
async () => {
|
||||
await providerGate
|
||||
return new LocationSource()
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const sessionPromise = manager.getOrCreate("user-1")
|
||||
|
||||
// remove() while provider is still resolving
|
||||
manager.remove("user-1")
|
||||
|
||||
// Let the provider finish
|
||||
resolveProvider!()
|
||||
|
||||
await expect(sessionPromise).rejects.toThrow("removed during creation")
|
||||
|
||||
// A fresh getOrCreate should produce a new session, not the cancelled one
|
||||
const freshSession = await manager.getOrCreate("user-1")
|
||||
expect(freshSession).toBeDefined()
|
||||
expect(freshSession.engine).toBeDefined()
|
||||
})
|
||||
})
|
||||
@@ -1,86 +0,0 @@
|
||||
import type { FeedSource } from "@aelis/core"
|
||||
|
||||
import type { FeedEnhancer } from "../enhancement/enhance-feed.ts"
|
||||
import type { FeedSourceProviderInput } from "./feed-source-provider.ts"
|
||||
|
||||
import { UserSession } from "./user-session.ts"
|
||||
|
||||
export interface UserSessionManagerConfig {
|
||||
providers: FeedSourceProviderInput[]
|
||||
feedEnhancer?: FeedEnhancer | null
|
||||
}
|
||||
|
||||
export class UserSessionManager {
|
||||
private sessions = new Map<string, UserSession>()
|
||||
private pending = new Map<string, Promise<UserSession>>()
|
||||
private readonly providers: FeedSourceProviderInput[]
|
||||
private readonly feedEnhancer: FeedEnhancer | null
|
||||
|
||||
constructor(config: UserSessionManagerConfig) {
|
||||
this.providers = config.providers
|
||||
this.feedEnhancer = config.feedEnhancer ?? null
|
||||
}
|
||||
|
||||
async getOrCreate(userId: string): Promise<UserSession> {
|
||||
const existing = this.sessions.get(userId)
|
||||
if (existing) return existing
|
||||
|
||||
const inflight = this.pending.get(userId)
|
||||
if (inflight) return inflight
|
||||
|
||||
const promise = this.createSession(userId)
|
||||
this.pending.set(userId, promise)
|
||||
try {
|
||||
const session = await promise
|
||||
// If remove() was called while we were awaiting, it clears the
|
||||
// pending entry. Detect that and destroy the session immediately.
|
||||
if (!this.pending.has(userId)) {
|
||||
session.destroy()
|
||||
throw new Error(`Session for user ${userId} was removed during creation`)
|
||||
}
|
||||
this.sessions.set(userId, session)
|
||||
return session
|
||||
} finally {
|
||||
this.pending.delete(userId)
|
||||
}
|
||||
}
|
||||
|
||||
remove(userId: string): void {
|
||||
const session = this.sessions.get(userId)
|
||||
if (session) {
|
||||
session.destroy()
|
||||
this.sessions.delete(userId)
|
||||
}
|
||||
// Cancel any in-flight creation so getOrCreate won't store the session
|
||||
this.pending.delete(userId)
|
||||
}
|
||||
|
||||
private async createSession(userId: string): Promise<UserSession> {
|
||||
const results = await Promise.allSettled(
|
||||
this.providers.map((p) =>
|
||||
typeof p === "function" ? p(userId) : p.feedSourceForUser(userId),
|
||||
),
|
||||
)
|
||||
|
||||
const sources: FeedSource[] = []
|
||||
const errors: unknown[] = []
|
||||
|
||||
for (const result of results) {
|
||||
if (result.status === "fulfilled") {
|
||||
sources.push(result.value)
|
||||
} else {
|
||||
errors.push(result.reason)
|
||||
}
|
||||
}
|
||||
|
||||
if (sources.length === 0 && errors.length > 0) {
|
||||
throw new AggregateError(errors, "All feed source providers failed")
|
||||
}
|
||||
|
||||
for (const error of errors) {
|
||||
console.error("[UserSessionManager] Feed source provider failed:", error)
|
||||
}
|
||||
|
||||
return new UserSession(sources, this.feedEnhancer)
|
||||
}
|
||||
}
|
||||
@@ -1,216 +0,0 @@
|
||||
import type { ActionDefinition, ContextEntry, FeedItem, FeedSource } from "@aelis/core"
|
||||
|
||||
import { LocationSource } from "@aelis/source-location"
|
||||
import { describe, expect, test } from "bun:test"
|
||||
|
||||
import { UserSession } from "./user-session.ts"
|
||||
|
||||
function createStubSource(id: string, items: FeedItem[] = []): FeedSource {
|
||||
return {
|
||||
id,
|
||||
async listActions(): Promise<Record<string, ActionDefinition>> {
|
||||
return {}
|
||||
},
|
||||
async executeAction(): Promise<unknown> {
|
||||
return undefined
|
||||
},
|
||||
async fetchContext(): Promise<readonly ContextEntry[] | null> {
|
||||
return null
|
||||
},
|
||||
async fetchItems() {
|
||||
return items
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe("UserSession", () => {
|
||||
test("registers sources and starts engine", async () => {
|
||||
const session = new UserSession([createStubSource("test-a"), createStubSource("test-b")])
|
||||
|
||||
const result = await session.engine.refresh()
|
||||
|
||||
expect(result.errors).toHaveLength(0)
|
||||
})
|
||||
|
||||
test("getSource returns registered source", () => {
|
||||
const location = new LocationSource()
|
||||
const session = new UserSession([location])
|
||||
|
||||
const result = session.getSource<LocationSource>("aelis.location")
|
||||
|
||||
expect(result).toBe(location)
|
||||
})
|
||||
|
||||
test("getSource returns undefined for unknown source", () => {
|
||||
const session = new UserSession([createStubSource("test")])
|
||||
|
||||
expect(session.getSource("unknown")).toBeUndefined()
|
||||
})
|
||||
|
||||
test("destroy stops engine and clears sources", () => {
|
||||
const session = new UserSession([createStubSource("test")])
|
||||
|
||||
session.destroy()
|
||||
|
||||
expect(session.getSource("test")).toBeUndefined()
|
||||
})
|
||||
|
||||
test("engine.executeAction routes to correct source", async () => {
|
||||
const location = new LocationSource()
|
||||
const session = new UserSession([location])
|
||||
|
||||
await session.engine.executeAction("aelis.location", "update-location", {
|
||||
lat: 51.5,
|
||||
lng: -0.1,
|
||||
accuracy: 10,
|
||||
timestamp: new Date(),
|
||||
})
|
||||
|
||||
expect(location.lastLocation).toBeDefined()
|
||||
expect(location.lastLocation!.lat).toBe(51.5)
|
||||
})
|
||||
})
|
||||
|
||||
describe("UserSession.feed", () => {
|
||||
test("returns feed items without enhancer", async () => {
|
||||
const items: FeedItem[] = [
|
||||
{
|
||||
id: "item-1",
|
||||
sourceId: "test",
|
||||
type: "test",
|
||||
timestamp: new Date("2025-01-01T00:00:00.000Z"),
|
||||
data: { value: 42 },
|
||||
},
|
||||
]
|
||||
const session = new UserSession([createStubSource("test", items)])
|
||||
|
||||
const result = await session.feed()
|
||||
|
||||
expect(result.items).toHaveLength(1)
|
||||
expect(result.items[0]!.id).toBe("item-1")
|
||||
})
|
||||
|
||||
test("returns enhanced items when enhancer is provided", async () => {
|
||||
const items: FeedItem[] = [
|
||||
{
|
||||
id: "item-1",
|
||||
sourceId: "test",
|
||||
type: "test",
|
||||
timestamp: new Date("2025-01-01T00:00:00.000Z"),
|
||||
data: { value: 42 },
|
||||
},
|
||||
]
|
||||
const enhancer = async (feedItems: FeedItem[]) =>
|
||||
feedItems.map((item) => ({ ...item, data: { ...item.data, enhanced: true } }))
|
||||
|
||||
const session = new UserSession([createStubSource("test", items)], enhancer)
|
||||
|
||||
const result = await session.feed()
|
||||
|
||||
expect(result.items).toHaveLength(1)
|
||||
expect(result.items[0]!.data.enhanced).toBe(true)
|
||||
})
|
||||
|
||||
test("caches enhanced items on subsequent calls", async () => {
|
||||
const items: FeedItem[] = [
|
||||
{
|
||||
id: "item-1",
|
||||
sourceId: "test",
|
||||
type: "test",
|
||||
timestamp: new Date("2025-01-01T00:00:00.000Z"),
|
||||
data: { value: 42 },
|
||||
},
|
||||
]
|
||||
let enhancerCallCount = 0
|
||||
const enhancer = async (feedItems: FeedItem[]) => {
|
||||
enhancerCallCount++
|
||||
return feedItems.map((item) => ({ ...item, data: { ...item.data, enhanced: true } }))
|
||||
}
|
||||
|
||||
const session = new UserSession([createStubSource("test", items)], enhancer)
|
||||
|
||||
const result1 = await session.feed()
|
||||
expect(result1.items[0]!.data.enhanced).toBe(true)
|
||||
expect(enhancerCallCount).toBe(1)
|
||||
|
||||
const result2 = await session.feed()
|
||||
expect(result2.items[0]!.data.enhanced).toBe(true)
|
||||
expect(enhancerCallCount).toBe(1)
|
||||
})
|
||||
|
||||
test("re-enhances after engine refresh with new data", async () => {
|
||||
let currentItems: FeedItem[] = [
|
||||
{
|
||||
id: "item-1",
|
||||
sourceId: "test",
|
||||
type: "test",
|
||||
timestamp: new Date("2025-01-01T00:00:00.000Z"),
|
||||
data: { version: 1 },
|
||||
},
|
||||
]
|
||||
const source = createStubSource("test", currentItems)
|
||||
// Make fetchItems dynamic so refresh returns new data
|
||||
source.fetchItems = async () => currentItems
|
||||
|
||||
const enhancedVersions: number[] = []
|
||||
const enhancer = async (feedItems: FeedItem[]) => {
|
||||
const version = feedItems[0]!.data.version as number
|
||||
enhancedVersions.push(version)
|
||||
return feedItems.map((item) => ({
|
||||
...item,
|
||||
data: { ...item.data, enhanced: true },
|
||||
}))
|
||||
}
|
||||
|
||||
const session = new UserSession([source], enhancer)
|
||||
|
||||
// First feed triggers refresh + enhancement
|
||||
const result1 = await session.feed()
|
||||
expect(result1.items[0]!.data.version).toBe(1)
|
||||
expect(result1.items[0]!.data.enhanced).toBe(true)
|
||||
|
||||
// Update source data and trigger engine refresh
|
||||
currentItems = [
|
||||
{
|
||||
id: "item-1",
|
||||
sourceId: "test",
|
||||
type: "test",
|
||||
timestamp: new Date("2025-01-02T00:00:00.000Z"),
|
||||
data: { version: 2 },
|
||||
},
|
||||
]
|
||||
await session.engine.refresh()
|
||||
|
||||
// Wait for subscriber-triggered background enhancement
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
|
||||
// feed() should now serve re-enhanced items with version 2
|
||||
const result2 = await session.feed()
|
||||
expect(result2.items[0]!.data.version).toBe(2)
|
||||
expect(result2.items[0]!.data.enhanced).toBe(true)
|
||||
expect(enhancedVersions).toEqual([1, 2])
|
||||
})
|
||||
|
||||
test("falls back to unenhanced items when enhancer throws", async () => {
|
||||
const items: FeedItem[] = [
|
||||
{
|
||||
id: "item-1",
|
||||
sourceId: "test",
|
||||
type: "test",
|
||||
timestamp: new Date("2025-01-01T00:00:00.000Z"),
|
||||
data: { value: 42 },
|
||||
},
|
||||
]
|
||||
const enhancer = async () => {
|
||||
throw new Error("enhancement exploded")
|
||||
}
|
||||
|
||||
const session = new UserSession([createStubSource("test", items)], enhancer)
|
||||
|
||||
const result = await session.feed()
|
||||
|
||||
expect(result.items).toHaveLength(1)
|
||||
expect(result.items[0]!.id).toBe("item-1")
|
||||
expect(result.items[0]!.data.value).toBe(42)
|
||||
})
|
||||
})
|
||||
@@ -1,104 +0,0 @@
|
||||
import { FeedEngine, type FeedItem, type FeedResult, type FeedSource } from "@aelis/core"
|
||||
|
||||
import type { FeedEnhancer } from "../enhancement/enhance-feed.ts"
|
||||
|
||||
export class UserSession {
|
||||
readonly engine: FeedEngine
|
||||
private sources = new Map<string, FeedSource>()
|
||||
private readonly enhancer: FeedEnhancer | null
|
||||
private enhancedItems: FeedItem[] | null = null
|
||||
/** The FeedResult that enhancedItems was derived from. */
|
||||
private enhancedSource: FeedResult | null = null
|
||||
private enhancingPromise: Promise<void> | null = null
|
||||
private unsubscribe: (() => void) | null = null
|
||||
|
||||
constructor(sources: FeedSource[], enhancer?: FeedEnhancer | null) {
|
||||
this.engine = new FeedEngine()
|
||||
this.enhancer = enhancer ?? null
|
||||
for (const source of sources) {
|
||||
this.sources.set(source.id, source)
|
||||
this.engine.register(source)
|
||||
}
|
||||
|
||||
if (this.enhancer) {
|
||||
this.unsubscribe = this.engine.subscribe((result) => {
|
||||
this.invalidateEnhancement()
|
||||
this.runEnhancement(result)
|
||||
})
|
||||
}
|
||||
|
||||
this.engine.start()
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the current feed, refreshing if the engine cache expired.
|
||||
* Enhancement runs eagerly on engine updates; this method awaits
|
||||
* any in-flight enhancement or triggers one if needed.
|
||||
*/
|
||||
async feed(): Promise<FeedResult> {
|
||||
const cached = this.engine.lastFeed()
|
||||
const result = cached ?? (await this.engine.refresh())
|
||||
|
||||
if (!this.enhancer) {
|
||||
return result
|
||||
}
|
||||
|
||||
// Wait for any in-flight background enhancement to finish
|
||||
if (this.enhancingPromise) {
|
||||
await this.enhancingPromise
|
||||
}
|
||||
|
||||
// Serve cached enhancement only if it matches the current engine result
|
||||
if (this.enhancedItems && this.enhancedSource === result) {
|
||||
return { ...result, items: this.enhancedItems }
|
||||
}
|
||||
|
||||
// Stale or missing — re-enhance
|
||||
await this.runEnhancement(result)
|
||||
|
||||
if (this.enhancedItems) {
|
||||
return { ...result, items: this.enhancedItems }
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
getSource<T extends FeedSource>(sourceId: string): T | undefined {
|
||||
return this.sources.get(sourceId) as T | undefined
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
this.unsubscribe?.()
|
||||
this.unsubscribe = null
|
||||
this.engine.stop()
|
||||
this.sources.clear()
|
||||
this.invalidateEnhancement()
|
||||
this.enhancingPromise = null
|
||||
}
|
||||
|
||||
private invalidateEnhancement(): void {
|
||||
this.enhancedItems = null
|
||||
this.enhancedSource = null
|
||||
}
|
||||
|
||||
private runEnhancement(result: FeedResult): Promise<void> {
|
||||
const promise = this.enhance(result)
|
||||
this.enhancingPromise = promise
|
||||
promise.finally(() => {
|
||||
if (this.enhancingPromise === promise) {
|
||||
this.enhancingPromise = null
|
||||
}
|
||||
})
|
||||
return promise
|
||||
}
|
||||
|
||||
private async enhance(result: FeedResult): Promise<void> {
|
||||
try {
|
||||
this.enhancedItems = await this.enhancer!(result.items)
|
||||
this.enhancedSource = result
|
||||
} catch (err) {
|
||||
console.error("[enhancement] Unexpected error:", err)
|
||||
this.invalidateEnhancement()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
/**
|
||||
* Thrown by a FeedSourceProvider when the source is not enabled for a user.
|
||||
*
|
||||
* UserSessionManager's Promise.allSettled handles this gracefully —
|
||||
* the source is excluded from the session without crashing.
|
||||
*/
|
||||
export class SourceDisabledError extends Error {
|
||||
readonly sourceId: string
|
||||
readonly userId: string
|
||||
|
||||
constructor(sourceId: string, userId: string) {
|
||||
super(`Source "${sourceId}" is not enabled for user "${userId}"`)
|
||||
this.name = "SourceDisabledError"
|
||||
this.sourceId = sourceId
|
||||
this.userId = userId
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Thrown when an operation targets a user source that doesn't exist.
|
||||
*/
|
||||
export class SourceNotFoundError extends Error {
|
||||
readonly sourceId: string
|
||||
readonly userId: string
|
||||
|
||||
constructor(sourceId: string, userId: string) {
|
||||
super(`Source "${sourceId}" not found for user "${userId}"`)
|
||||
this.name = "SourceNotFoundError"
|
||||
this.sourceId = sourceId
|
||||
this.userId = userId
|
||||
}
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
import { TflSource, type ITflApi, type TflLineId } from "@aelis/source-tfl"
|
||||
import { type } from "arktype"
|
||||
|
||||
import type { Database } from "../db/index.ts"
|
||||
import type { FeedSourceProvider } from "../session/feed-source-provider.ts"
|
||||
|
||||
import { SourceDisabledError } from "../sources/errors.ts"
|
||||
import { sources } from "../sources/user-sources.ts"
|
||||
|
||||
export type TflSourceProviderOptions =
|
||||
| { db: Database; apiKey: string; client?: never }
|
||||
| { db: Database; apiKey?: never; client: ITflApi }
|
||||
|
||||
const tflConfig = type({
|
||||
"lines?": "string[]",
|
||||
})
|
||||
|
||||
export class TflSourceProvider implements FeedSourceProvider {
|
||||
private readonly db: Database
|
||||
private readonly apiKey: string | undefined
|
||||
private readonly client: ITflApi | undefined
|
||||
|
||||
constructor(options: TflSourceProviderOptions) {
|
||||
this.db = options.db
|
||||
this.apiKey = "apiKey" in options ? options.apiKey : undefined
|
||||
this.client = "client" in options ? options.client : undefined
|
||||
}
|
||||
|
||||
async feedSourceForUser(userId: string): Promise<TflSource> {
|
||||
const row = await sources(this.db, userId).find("aelis.tfl")
|
||||
|
||||
if (!row || !row.enabled) {
|
||||
throw new SourceDisabledError("aelis.tfl", userId)
|
||||
}
|
||||
|
||||
const parsed = tflConfig(row.config ?? {})
|
||||
if (parsed instanceof type.errors) {
|
||||
throw new Error(`Invalid TFL config for user ${userId}: ${parsed.summary}`)
|
||||
}
|
||||
|
||||
return new TflSource({
|
||||
apiKey: this.apiKey,
|
||||
client: this.client,
|
||||
lines: parsed.lines as TflLineId[] | undefined,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,152 +0,0 @@
|
||||
{
|
||||
"expo": {
|
||||
"name": "Aelis",
|
||||
"slug": "aelis-client",
|
||||
"version": "1.0.0",
|
||||
"orientation": "portrait",
|
||||
"icon": "./assets/images/icon.png",
|
||||
"scheme": "aelis",
|
||||
"userInterfaceStyle": "automatic",
|
||||
"newArchEnabled": true,
|
||||
"ios": {
|
||||
"infoPlist": {
|
||||
"NSAppTransportSecurity": {
|
||||
"NSAllowsArbitraryLoads": true
|
||||
},
|
||||
"ITSAppUsesNonExemptEncryption": false
|
||||
},
|
||||
"bundleIdentifier": "sh.nym.aelis"
|
||||
},
|
||||
"android": {
|
||||
"adaptiveIcon": {
|
||||
"backgroundColor": "#E6F4FE",
|
||||
"foregroundImage": "./assets/images/android-icon-foreground.png",
|
||||
"backgroundImage": "./assets/images/android-icon-background.png",
|
||||
"monochromeImage": "./assets/images/android-icon-monochrome.png"
|
||||
},
|
||||
"edgeToEdgeEnabled": true,
|
||||
"predictiveBackGestureEnabled": false,
|
||||
"package": "sh.nym.aelis"
|
||||
},
|
||||
"web": {
|
||||
"output": "static",
|
||||
"favicon": "./assets/images/favicon.png"
|
||||
},
|
||||
"plugins": [
|
||||
"expo-router",
|
||||
[
|
||||
"expo-splash-screen",
|
||||
{
|
||||
"image": "./assets/images/splash-icon.png",
|
||||
"imageWidth": 200,
|
||||
"resizeMode": "contain",
|
||||
"backgroundColor": "#ffffff",
|
||||
"dark": {
|
||||
"backgroundColor": "#000000"
|
||||
}
|
||||
}
|
||||
],
|
||||
[
|
||||
"expo-font",
|
||||
{
|
||||
"android": {
|
||||
"fonts": [
|
||||
{
|
||||
"fontFamily": "Inter",
|
||||
"fontDefinitions": [
|
||||
{ "path": "./assets/fonts/Inter_100Thin.ttf", "weight": 100 },
|
||||
{ "path": "./assets/fonts/Inter_100Thin_Italic.ttf", "weight": 100, "style": "italic" },
|
||||
{ "path": "./assets/fonts/Inter_200ExtraLight.ttf", "weight": 200 },
|
||||
{ "path": "./assets/fonts/Inter_200ExtraLight_Italic.ttf", "weight": 200, "style": "italic" },
|
||||
{ "path": "./assets/fonts/Inter_300Light.ttf", "weight": 300 },
|
||||
{ "path": "./assets/fonts/Inter_300Light_Italic.ttf", "weight": 300, "style": "italic" },
|
||||
{ "path": "./assets/fonts/Inter_400Regular.ttf", "weight": 400 },
|
||||
{ "path": "./assets/fonts/Inter_400Regular_Italic.ttf", "weight": 400, "style": "italic" },
|
||||
{ "path": "./assets/fonts/Inter_500Medium.ttf", "weight": 500 },
|
||||
{ "path": "./assets/fonts/Inter_500Medium_Italic.ttf", "weight": 500, "style": "italic" },
|
||||
{ "path": "./assets/fonts/Inter_600SemiBold.ttf", "weight": 600 },
|
||||
{ "path": "./assets/fonts/Inter_600SemiBold_Italic.ttf", "weight": 600, "style": "italic" },
|
||||
{ "path": "./assets/fonts/Inter_700Bold.ttf", "weight": 700 },
|
||||
{ "path": "./assets/fonts/Inter_700Bold_Italic.ttf", "weight": 700, "style": "italic" },
|
||||
{ "path": "./assets/fonts/Inter_800ExtraBold.ttf", "weight": 800 },
|
||||
{ "path": "./assets/fonts/Inter_800ExtraBold_Italic.ttf", "weight": 800, "style": "italic" },
|
||||
{ "path": "./assets/fonts/Inter_900Black.ttf", "weight": 900 },
|
||||
{ "path": "./assets/fonts/Inter_900Black_Italic.ttf", "weight": 900, "style": "italic" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"fontFamily": "Source Serif 4",
|
||||
"fontDefinitions": [
|
||||
{ "path": "./assets/fonts/SourceSerif4_200ExtraLight.ttf", "weight": 200 },
|
||||
{ "path": "./assets/fonts/SourceSerif4_200ExtraLight_Italic.ttf", "weight": 200, "style": "italic" },
|
||||
{ "path": "./assets/fonts/SourceSerif4_300Light.ttf", "weight": 300 },
|
||||
{ "path": "./assets/fonts/SourceSerif4_300Light_Italic.ttf", "weight": 300, "style": "italic" },
|
||||
{ "path": "./assets/fonts/SourceSerif4_400Regular.ttf", "weight": 400 },
|
||||
{ "path": "./assets/fonts/SourceSerif4_400Regular_Italic.ttf", "weight": 400, "style": "italic" },
|
||||
{ "path": "./assets/fonts/SourceSerif4_500Medium.ttf", "weight": 500 },
|
||||
{ "path": "./assets/fonts/SourceSerif4_500Medium_Italic.ttf", "weight": 500, "style": "italic" },
|
||||
{ "path": "./assets/fonts/SourceSerif4_600SemiBold.ttf", "weight": 600 },
|
||||
{ "path": "./assets/fonts/SourceSerif4_600SemiBold_Italic.ttf", "weight": 600, "style": "italic" },
|
||||
{ "path": "./assets/fonts/SourceSerif4_700Bold.ttf", "weight": 700 },
|
||||
{ "path": "./assets/fonts/SourceSerif4_700Bold_Italic.ttf", "weight": 700, "style": "italic" },
|
||||
{ "path": "./assets/fonts/SourceSerif4_800ExtraBold.ttf", "weight": 800 },
|
||||
{ "path": "./assets/fonts/SourceSerif4_800ExtraBold_Italic.ttf", "weight": 800, "style": "italic" },
|
||||
{ "path": "./assets/fonts/SourceSerif4_900Black.ttf", "weight": 900 },
|
||||
{ "path": "./assets/fonts/SourceSerif4_900Black_Italic.ttf", "weight": 900, "style": "italic" }
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"ios": {
|
||||
"fonts": [
|
||||
"./assets/fonts/Inter_100Thin.ttf",
|
||||
"./assets/fonts/Inter_100Thin_Italic.ttf",
|
||||
"./assets/fonts/Inter_200ExtraLight.ttf",
|
||||
"./assets/fonts/Inter_200ExtraLight_Italic.ttf",
|
||||
"./assets/fonts/Inter_300Light.ttf",
|
||||
"./assets/fonts/Inter_300Light_Italic.ttf",
|
||||
"./assets/fonts/Inter_400Regular.ttf",
|
||||
"./assets/fonts/Inter_400Regular_Italic.ttf",
|
||||
"./assets/fonts/Inter_500Medium.ttf",
|
||||
"./assets/fonts/Inter_500Medium_Italic.ttf",
|
||||
"./assets/fonts/Inter_600SemiBold.ttf",
|
||||
"./assets/fonts/Inter_600SemiBold_Italic.ttf",
|
||||
"./assets/fonts/Inter_700Bold.ttf",
|
||||
"./assets/fonts/Inter_700Bold_Italic.ttf",
|
||||
"./assets/fonts/Inter_800ExtraBold.ttf",
|
||||
"./assets/fonts/Inter_800ExtraBold_Italic.ttf",
|
||||
"./assets/fonts/Inter_900Black.ttf",
|
||||
"./assets/fonts/Inter_900Black_Italic.ttf",
|
||||
"./assets/fonts/SourceSerif4_200ExtraLight.ttf",
|
||||
"./assets/fonts/SourceSerif4_200ExtraLight_Italic.ttf",
|
||||
"./assets/fonts/SourceSerif4_300Light.ttf",
|
||||
"./assets/fonts/SourceSerif4_300Light_Italic.ttf",
|
||||
"./assets/fonts/SourceSerif4_400Regular.ttf",
|
||||
"./assets/fonts/SourceSerif4_400Regular_Italic.ttf",
|
||||
"./assets/fonts/SourceSerif4_500Medium.ttf",
|
||||
"./assets/fonts/SourceSerif4_500Medium_Italic.ttf",
|
||||
"./assets/fonts/SourceSerif4_600SemiBold.ttf",
|
||||
"./assets/fonts/SourceSerif4_600SemiBold_Italic.ttf",
|
||||
"./assets/fonts/SourceSerif4_700Bold.ttf",
|
||||
"./assets/fonts/SourceSerif4_700Bold_Italic.ttf",
|
||||
"./assets/fonts/SourceSerif4_800ExtraBold.ttf",
|
||||
"./assets/fonts/SourceSerif4_800ExtraBold_Italic.ttf",
|
||||
"./assets/fonts/SourceSerif4_900Black.ttf",
|
||||
"./assets/fonts/SourceSerif4_900Black_Italic.ttf"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
],
|
||||
"experiments": {
|
||||
"typedRoutes": true,
|
||||
"reactCompiler": true
|
||||
},
|
||||
"extra": {
|
||||
"router": {},
|
||||
"eas": {
|
||||
"projectId": "61092d23-36aa-418e-929d-ea40dc912e8f"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
{
|
||||
"name": "aelis-client",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"main": "expo-router/entry",
|
||||
"scripts": {
|
||||
"start": "./scripts/run-dev-server.sh",
|
||||
"reset-project": "node ./scripts/reset-project.js",
|
||||
"android": "expo start --android",
|
||||
"ios": "expo start --ios",
|
||||
"web": "expo start --web",
|
||||
"lint": "expo lint",
|
||||
"build:ios": "eas build --profile development --platform ios --non-interactive",
|
||||
"build:ios-simulator": "eas build --profile development-simulator --platform ios --non-interactive",
|
||||
"debugger": "bun run scripts/open-debugger.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo-google-fonts/inter": "^0.4.2",
|
||||
"@expo-google-fonts/source-serif-4": "^0.4.1",
|
||||
"@expo/vector-icons": "^15.0.3",
|
||||
"@json-render/react-native": "^0.13.0",
|
||||
"@react-navigation/bottom-tabs": "^7.4.0",
|
||||
"@react-navigation/elements": "^2.6.3",
|
||||
"@react-navigation/native": "^7.1.8",
|
||||
"@tanstack/react-query": "^5.90.21",
|
||||
"expo": "~54.0.33",
|
||||
"expo-constants": "~18.0.13",
|
||||
"expo-dev-client": "~6.0.20",
|
||||
"expo-font": "~14.0.11",
|
||||
"expo-haptics": "~15.0.8",
|
||||
"expo-image": "~3.0.11",
|
||||
"expo-linking": "~8.0.11",
|
||||
"expo-location": "~19.0.8",
|
||||
"expo-router": "~6.0.23",
|
||||
"expo-splash-screen": "~31.0.13",
|
||||
"expo-status-bar": "~3.0.9",
|
||||
"expo-symbols": "~1.0.8",
|
||||
"expo-system-ui": "~6.0.9",
|
||||
"expo-web-browser": "~15.0.10",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"react-native": "0.81.5",
|
||||
"react-native-gesture-handler": "~2.28.0",
|
||||
"react-native-reanimated": "~4.1.1",
|
||||
"react-native-safe-area-context": "~5.6.0",
|
||||
"react-native-screens": "~4.16.0",
|
||||
"react-native-svg": "15.12.1",
|
||||
"react-native-web": "~0.21.0",
|
||||
"react-native-worklets": "0.5.1",
|
||||
"twrnc": "^4.16.0",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "~19.1.0",
|
||||
"eas-cli": "^18.0.1",
|
||||
"eslint": "^9.25.0",
|
||||
"eslint-config-expo": "~10.0.0",
|
||||
"typescript": "~5.9.2"
|
||||
}
|
||||
}
|
||||
@@ -1,127 +0,0 @@
|
||||
// Reverse proxy that sits in front of Metro so that all requests
|
||||
// (including those arriving via Tailscale or Ona port-forwarding) reach
|
||||
// Metro as loopback connections. This satisfies the isLocalSocket check
|
||||
// in Expo's debug middleware, making /debugger-frontend, /json, and
|
||||
// /open-debugger accessible from a remote browser.
|
||||
|
||||
import type { ServerWebSocket } from "bun"
|
||||
|
||||
const PROXY_PORT = parseInt(process.env.PROXY_PORT || "8080", 10)
|
||||
const METRO_PORT = parseInt(process.env.METRO_PORT || "8081", 10)
|
||||
const METRO_BASE = `http://127.0.0.1:${METRO_PORT}`
|
||||
|
||||
function forwardHeaders(headers: Headers): Headers {
|
||||
const result = new Headers(headers)
|
||||
result.delete("origin")
|
||||
result.delete("referer")
|
||||
result.set("host", `127.0.0.1:${METRO_PORT}`)
|
||||
return result
|
||||
}
|
||||
|
||||
interface WsData {
|
||||
upstream: WebSocket
|
||||
isDevice: boolean
|
||||
}
|
||||
|
||||
Bun.serve<WsData>({
|
||||
port: PROXY_PORT,
|
||||
|
||||
async fetch(req, server) {
|
||||
const url = new URL(req.url)
|
||||
|
||||
// WebSocket upgrade — bridge to Metro's ws endpoint
|
||||
if (req.headers.get("upgrade")?.toLowerCase() === "websocket") {
|
||||
const wsUrl = `ws://127.0.0.1:${METRO_PORT}${url.pathname}${url.search}`
|
||||
const upstream = new WebSocket(wsUrl)
|
||||
|
||||
// Wait for upstream to connect before upgrading the client
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
upstream.addEventListener("open", () => resolve())
|
||||
upstream.addEventListener("error", () => reject(new Error("upstream ws failed")))
|
||||
})
|
||||
} catch {
|
||||
return new Response("Upstream WebSocket unavailable", { status: 502 })
|
||||
}
|
||||
|
||||
const isDevice = url.pathname.startsWith("/inspector/device")
|
||||
const ok = server.upgrade(req, { data: { upstream, isDevice } })
|
||||
if (!ok) {
|
||||
upstream.close()
|
||||
return new Response("WebSocket upgrade failed", { status: 500 })
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
// HTTP proxy
|
||||
const upstream = `${METRO_BASE}${url.pathname}${url.search}`
|
||||
const res = await fetch(upstream, {
|
||||
method: req.method,
|
||||
headers: forwardHeaders(req.headers),
|
||||
body: req.body,
|
||||
redirect: "manual",
|
||||
})
|
||||
|
||||
return new Response(res.body, {
|
||||
status: res.status,
|
||||
statusText: res.statusText,
|
||||
headers: res.headers,
|
||||
})
|
||||
},
|
||||
|
||||
websocket: {
|
||||
message(ws: ServerWebSocket<WsData>, msg) {
|
||||
ws.data.upstream.send(msg)
|
||||
},
|
||||
open(ws: ServerWebSocket<WsData>) {
|
||||
const { upstream } = ws.data
|
||||
upstream.addEventListener("message", (ev) => {
|
||||
if (typeof ev.data === "string") {
|
||||
ws.send(ev.data)
|
||||
} else if (ev.data instanceof ArrayBuffer) {
|
||||
ws.sendBinary(new Uint8Array(ev.data))
|
||||
}
|
||||
})
|
||||
upstream.addEventListener("close", () => ws.close())
|
||||
upstream.addEventListener("error", () => ws.close())
|
||||
|
||||
// Print debugger URL shortly after a device connects,
|
||||
// giving Metro time to register the target.
|
||||
if (ws.data.isDevice) {
|
||||
setTimeout(() => printDebuggerUrl(), 1000)
|
||||
}
|
||||
},
|
||||
close(ws: ServerWebSocket<WsData>) {
|
||||
ws.data.upstream.close()
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const tsIp = await Bun.$`tailscale ip -4`.text().then((s) => s.trim())
|
||||
|
||||
async function printDebuggerUrl() {
|
||||
const base = `http://${tsIp}:${PROXY_PORT}`
|
||||
const res = await fetch(`${METRO_BASE}/json`)
|
||||
if (!res.ok) return
|
||||
|
||||
interface DebugTarget {
|
||||
webSocketDebuggerUrl: string
|
||||
reactNative?: {
|
||||
capabilities?: { prefersFuseboxFrontend?: boolean }
|
||||
}
|
||||
}
|
||||
|
||||
const targets: DebugTarget[] = await res.json()
|
||||
const target = targets.find((t) => t.reactNative?.capabilities?.prefersFuseboxFrontend)
|
||||
if (!target) return
|
||||
|
||||
const wsPath = target.webSocketDebuggerUrl
|
||||
.replace(/^ws:\/\//, "")
|
||||
.replace(`127.0.0.1:${METRO_PORT}`, `${tsIp}:${PROXY_PORT}`)
|
||||
|
||||
console.log(
|
||||
`\n React Native DevTools:\n ${base}/debugger-frontend/rn_fusebox.html?ws=${encodeURIComponent(wsPath)}&sources.hide_add_folder=true&unstable_enableNetworkPanel=true\n`,
|
||||
)
|
||||
}
|
||||
|
||||
console.log(`[proxy] listening on :${PROXY_PORT}, forwarding to 127.0.0.1:${METRO_PORT}`)
|
||||
@@ -1,52 +0,0 @@
|
||||
// Opens React Native DevTools in Chrome, connected to the first
|
||||
// available Hermes debug target. Requires Metro + proxy to be running.
|
||||
|
||||
import { $ } from "bun"
|
||||
|
||||
const PROXY_PORT = process.env.PROXY_PORT || "8080"
|
||||
const METRO_PORT = process.env.METRO_PORT || "8081"
|
||||
const tsIp = (await $`tailscale ip -4`.text()).trim()
|
||||
const base = `http://${tsIp}:${PROXY_PORT}`
|
||||
|
||||
interface DebugTarget {
|
||||
devtoolsFrontendUrl: string
|
||||
webSocketDebuggerUrl: string
|
||||
reactNative?: {
|
||||
capabilities?: {
|
||||
prefersFuseboxFrontend?: boolean
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const res = await fetch(`${base}/json`)
|
||||
if (!res.ok) {
|
||||
console.error("Failed to fetch /json — is Metro running?")
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const targets: DebugTarget[] = await res.json()
|
||||
const target = targets.find((t) => t.reactNative?.capabilities?.prefersFuseboxFrontend)
|
||||
|
||||
if (!target) {
|
||||
console.error("No debug target found. Is the app connected?")
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const wsUrl = target.webSocketDebuggerUrl
|
||||
.replace(/^ws:\/\//, "")
|
||||
.replace(`127.0.0.1:${METRO_PORT}`, `${tsIp}:${PROXY_PORT}`)
|
||||
|
||||
const url = `${base}/debugger-frontend/rn_fusebox.html?ws=${encodeURIComponent(wsUrl)}&sources.hide_add_folder=true&unstable_enableNetworkPanel=true`
|
||||
|
||||
console.log(url)
|
||||
|
||||
// Open in Chrome app mode if on macOS
|
||||
try {
|
||||
await $`open -a "Google Chrome" --args --app=${url}`.quiet()
|
||||
} catch {
|
||||
try {
|
||||
await $`xdg-open ${url}`.quiet()
|
||||
} catch {
|
||||
console.log("Open the URL above in Chrome.")
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
PROXY_PORT=8080
|
||||
METRO_PORT=8081
|
||||
|
||||
# Start a reverse proxy so Metro sees all requests as loopback.
|
||||
# This makes debugger endpoints (/debugger-frontend, /json, /open-debugger)
|
||||
# accessible through the Tailscale IP.
|
||||
PROXY_PORT=$PROXY_PORT METRO_PORT=$METRO_PORT bun run scripts/dev-proxy.ts &
|
||||
PROXY_PID=$!
|
||||
trap "kill $PROXY_PID 2>/dev/null" EXIT
|
||||
|
||||
EXPO_PACKAGER_PROXY_URL=http://$(tailscale ip -4):$PROXY_PORT bunx expo start --localhost -p $METRO_PORT
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
import { ApiRequestMiddleware } from "./client"
|
||||
|
||||
export const authMiddleware: ApiRequestMiddleware = (_url, init) => {
|
||||
// TODO: placeholder auth middleware
|
||||
return init
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
import "react-native-reanimated"
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
|
||||
import { Stack } from "expo-router"
|
||||
import { StatusBar } from "expo-status-bar"
|
||||
import React from "react"
|
||||
import { useColorScheme } from "react-native"
|
||||
import tw, { useDeviceContext } from "twrnc"
|
||||
|
||||
import { authMiddleware } from "@/api/auth-middleware"
|
||||
import { ApiClient, ApiClientContext } from "@/api/client"
|
||||
|
||||
const queryClient = new QueryClient()
|
||||
const apiClient = new ApiClient({
|
||||
baseUrl: process.env.EXPO_PUBLIC_API_BASE_URL ?? "",
|
||||
middlewares: [authMiddleware],
|
||||
})
|
||||
|
||||
export default function RootLayout() {
|
||||
useDeviceContext(tw)
|
||||
|
||||
const colorScheme = useColorScheme()
|
||||
const headerBg = colorScheme === "dark" ? "#1c1917" : "#f5f5f4"
|
||||
const headerTint = colorScheme === "dark" ? "#e7e5e4" : "#1c1917"
|
||||
|
||||
return (
|
||||
<ContextProvider>
|
||||
<Stack
|
||||
screenOptions={{
|
||||
headerShown: false,
|
||||
contentStyle: { backgroundColor: headerBg },
|
||||
}}
|
||||
>
|
||||
<Stack.Screen
|
||||
name="components/index"
|
||||
options={{
|
||||
headerShown: true,
|
||||
title: "Components",
|
||||
headerStyle: { backgroundColor: headerBg },
|
||||
headerTintColor: headerTint,
|
||||
headerShadowVisible: false,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="components/[name]"
|
||||
options={{
|
||||
headerShown: true,
|
||||
title: "",
|
||||
headerStyle: { backgroundColor: headerBg },
|
||||
headerTintColor: headerTint,
|
||||
headerShadowVisible: false,
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
<StatusBar style="auto" />
|
||||
</ContextProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextProvider({ children }: React.PropsWithChildren) {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ApiClientContext value={apiClient}>{children}</ApiClientContext>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
import { Link } from "expo-router"
|
||||
import { Pressable } from "react-native"
|
||||
import { SafeAreaView } from "react-native-safe-area-context"
|
||||
import tw from "twrnc"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { FeedCard } from "@/components/ui/feed-card"
|
||||
import { MonospaceText } from "@/components/ui/monospace-text"
|
||||
import { SansSerifText } from "@/components/ui/sans-serif-text"
|
||||
import { SerifText } from "@/components/ui/serif-text"
|
||||
|
||||
export default function HomeScreen() {
|
||||
return (
|
||||
<SafeAreaView style={tw`bg-stone-100 dark:bg-stone-900 flex-1 px-5 pt-6 gap-4`}>
|
||||
<FeedCard>
|
||||
<SerifText style={tw`text-4xl`}>Hello world asdsadsa</SerifText>
|
||||
<SansSerifText style={tw`text-4xl font-bold`}>Hello world</SansSerifText>
|
||||
<MonospaceText style={tw`text-4xl`}>asdjsakljdl</MonospaceText>
|
||||
<Button style={tw`self-start`} label="Test" />
|
||||
</FeedCard>
|
||||
<Link href="/components" asChild>
|
||||
<Pressable>
|
||||
<SansSerifText style={tw`text-teal-600`}>View component library</SansSerifText>
|
||||
</Pressable>
|
||||
</Link>
|
||||
</SafeAreaView>
|
||||
)
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
import { View } from "react-native"
|
||||
import tw from "twrnc"
|
||||
|
||||
import { Button } from "./button"
|
||||
import { type Showcase, Section } from "../showcase"
|
||||
|
||||
function ButtonShowcase() {
|
||||
return (
|
||||
<View style={tw`gap-6`}>
|
||||
<Section title="Default">
|
||||
<Button style={tw`self-start`} label="Press me" />
|
||||
</Section>
|
||||
<Section title="Leading icon">
|
||||
<Button
|
||||
style={tw`self-start`}
|
||||
label="Add item"
|
||||
leadingIcon={<Button.Icon name="plus" />}
|
||||
/>
|
||||
</Section>
|
||||
<Section title="Trailing icon">
|
||||
<Button
|
||||
style={tw`self-start`}
|
||||
label="Next"
|
||||
trailingIcon={<Button.Icon name="arrow-right" />}
|
||||
/>
|
||||
</Section>
|
||||
<Section title="Both icons">
|
||||
<Button
|
||||
style={tw`self-start`}
|
||||
label="Download"
|
||||
leadingIcon={<Button.Icon name="download" />}
|
||||
trailingIcon={<Button.Icon name="chevron-down" />}
|
||||
/>
|
||||
</Section>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export const buttonShowcase: Showcase = {
|
||||
title: "Button",
|
||||
component: ButtonShowcase,
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
import Feather from "@expo/vector-icons/Feather"
|
||||
import { type PressableProps, Pressable, View } from "react-native"
|
||||
import tw from "twrnc"
|
||||
|
||||
import { SansSerifText } from "./sans-serif-text"
|
||||
|
||||
type FeatherIconName = React.ComponentProps<typeof Feather>["name"]
|
||||
|
||||
type ButtonIconProps = {
|
||||
name: FeatherIconName
|
||||
}
|
||||
|
||||
function ButtonIcon({ name }: ButtonIconProps) {
|
||||
return <Feather name={name} size={18} color={tw.color("text-stone-100 dark:text-stone-200")} />
|
||||
}
|
||||
|
||||
type ButtonProps = Omit<PressableProps, "children"> & {
|
||||
label: string
|
||||
leadingIcon?: React.ReactNode
|
||||
trailingIcon?: React.ReactNode
|
||||
}
|
||||
|
||||
export function Button({ style, label, leadingIcon, trailingIcon, ...props }: ButtonProps) {
|
||||
const hasIcons = leadingIcon != null || trailingIcon != null
|
||||
|
||||
const textElement = <SansSerifText style={tw`text-stone-100 dark:text-stone-200 font-medium`}>{label}</SansSerifText>
|
||||
|
||||
return (
|
||||
<Pressable style={[tw`rounded-full bg-teal-600 px-4 py-3 w-fit`, style]} {...props}>
|
||||
{hasIcons ? (
|
||||
<View style={tw`flex-row items-center gap-1.5`}>
|
||||
{leadingIcon}
|
||||
{textElement}
|
||||
{trailingIcon}
|
||||
</View>
|
||||
) : (
|
||||
textElement
|
||||
)}
|
||||
</Pressable>
|
||||
)
|
||||
}
|
||||
|
||||
Button.Icon = ButtonIcon
|
||||
15
apps/agent-test-cli/package.json
Normal file
15
apps/agent-test-cli/package.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "@freya/agent-test-cli",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"format": "oxfmt --write .",
|
||||
"start": "bun run src/agent-test-cli.ts",
|
||||
"typecheck": "bun tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@freya/agent-protocol": "workspace:*",
|
||||
"@nym.sh/jrpc": "^0.1.0"
|
||||
}
|
||||
}
|
||||
888
apps/agent-test-cli/src/agent-test-cli.ts
Normal file
888
apps/agent-test-cli/src/agent-test-cli.ts
Normal file
@@ -0,0 +1,888 @@
|
||||
import type {
|
||||
AgentClientApi,
|
||||
AgentEvent,
|
||||
AgentServerApi,
|
||||
SendMessageResult,
|
||||
} from "@freya/agent-protocol"
|
||||
import type { JrpcChannel, JrpcMessage, JsonRpcMessage } from "@nym.sh/jrpc"
|
||||
|
||||
import { JsonRpcClient, JsonRpcServer } from "@nym.sh/jrpc"
|
||||
|
||||
type JsonObject = Record<string, unknown>
|
||||
|
||||
interface AuthUser {
|
||||
id: string
|
||||
name: string
|
||||
email: string
|
||||
image: string | null
|
||||
}
|
||||
|
||||
interface AuthSession {
|
||||
user: AuthUser
|
||||
session: {
|
||||
id: string
|
||||
token: string
|
||||
}
|
||||
}
|
||||
|
||||
interface QueryToolDefinition {
|
||||
name: string
|
||||
label: string
|
||||
description: string
|
||||
parameters: unknown
|
||||
}
|
||||
|
||||
interface QueryToolsResponse {
|
||||
tools: QueryToolDefinition[]
|
||||
}
|
||||
|
||||
interface ResultResponse {
|
||||
result: unknown
|
||||
}
|
||||
|
||||
interface SourceActionsResponse {
|
||||
actions: Record<string, { id: string; description?: string }>
|
||||
}
|
||||
|
||||
interface RequestOptions {
|
||||
method?: "GET" | "POST"
|
||||
body?: unknown
|
||||
}
|
||||
|
||||
class CookieJar {
|
||||
private readonly cookies = new Map<string, string>()
|
||||
|
||||
apply(response: Response): void {
|
||||
for (const header of readSetCookieHeaders(response.headers)) {
|
||||
const cookie = parseCookie(header)
|
||||
if (!cookie) continue
|
||||
this.cookies.set(cookie.name, cookie.value)
|
||||
}
|
||||
}
|
||||
|
||||
header(): string | undefined {
|
||||
if (this.cookies.size === 0) return undefined
|
||||
return [...this.cookies.entries()].map(([name, value]) => `${name}=${value}`).join("; ")
|
||||
}
|
||||
}
|
||||
|
||||
class AgentWebSocketSession implements AgentClientApi {
|
||||
private readonly channel: WebSocketJrpcChannel
|
||||
private readonly client: JsonRpcClient<AgentServerApi>
|
||||
private readonly server: JsonRpcServer<AgentClientApi>
|
||||
private conversationId: string | undefined
|
||||
private responseHadText = false
|
||||
|
||||
private constructor(channel: WebSocketJrpcChannel) {
|
||||
this.channel = channel
|
||||
this.client = new JsonRpcClient<AgentServerApi>(channel)
|
||||
this.server = new JsonRpcServer<AgentClientApi>(
|
||||
{
|
||||
notify: this.notify.bind(this),
|
||||
},
|
||||
channel,
|
||||
)
|
||||
}
|
||||
|
||||
static async connect(backendUrl: string, cookies: CookieJar): Promise<AgentWebSocketSession> {
|
||||
const channel = new WebSocketJrpcChannel(agentWebSocketUrl(backendUrl), cookies.header())
|
||||
const session = new AgentWebSocketSession(channel)
|
||||
|
||||
try {
|
||||
await channel.waitUntilOpen()
|
||||
void session.server.start().catch((err: unknown) => {
|
||||
if (!channel.isClosed()) {
|
||||
console.error(`\nWebSocket JSON-RPC failed: ${formatError(err)}\n`)
|
||||
}
|
||||
})
|
||||
await session.client.call("ping")
|
||||
} catch (err) {
|
||||
channel.close()
|
||||
throw err
|
||||
}
|
||||
|
||||
return session
|
||||
}
|
||||
|
||||
async ask(message: string): Promise<void> {
|
||||
this.responseHadText = false
|
||||
|
||||
const result = await this.sendMessage(message)
|
||||
if (result.conversationId) {
|
||||
this.conversationId = result.conversationId
|
||||
}
|
||||
|
||||
if (!this.responseHadText) {
|
||||
console.log(`\nagent> ${result.message || "(no message)"}`)
|
||||
}
|
||||
console.log("")
|
||||
}
|
||||
|
||||
notify(event: AgentEvent): void {
|
||||
switch (event.type) {
|
||||
case "conversation_started":
|
||||
this.conversationId = event.conversationId
|
||||
break
|
||||
case "message_created":
|
||||
this.printMessage(event.text)
|
||||
break
|
||||
case "tool_started":
|
||||
console.log(`\ntool> ${event.toolName} started`)
|
||||
break
|
||||
case "tool_finished":
|
||||
console.log(`tool> ${event.toolName} ${event.ok ? "finished" : "failed"}`)
|
||||
break
|
||||
case "message_finished":
|
||||
break
|
||||
case "message_failed":
|
||||
console.log(`\nagent! ${event.error}`)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
describeConversation(): string {
|
||||
return this.conversationId ? `Conversation: ${this.conversationId}` : "No conversation yet."
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.channel.close()
|
||||
}
|
||||
|
||||
private async sendMessage(message: string): Promise<SendMessageResult> {
|
||||
return this.client.call("sendMessage", message)
|
||||
}
|
||||
|
||||
private printMessage(text: string): void {
|
||||
if (text === "") return
|
||||
|
||||
console.log(`\nagent> ${text}`)
|
||||
this.responseHadText = true
|
||||
}
|
||||
}
|
||||
|
||||
class WebSocketJrpcChannel implements JrpcChannel {
|
||||
private readonly ws: WebSocket
|
||||
private readonly opened: Promise<void>
|
||||
private closed = false
|
||||
private openedOnce = false
|
||||
private queue: JrpcMessage[] = []
|
||||
private waiters: Array<(result: IteratorResult<JrpcMessage, void>) => void> = []
|
||||
|
||||
constructor(url: string, cookieHeader?: string) {
|
||||
this.ws = new WebSocket(url, createWebSocketOptions(cookieHeader))
|
||||
this.opened = new Promise((resolve, reject) => {
|
||||
this.ws.onopen = () => {
|
||||
this.openedOnce = true
|
||||
resolve()
|
||||
}
|
||||
this.ws.onerror = () => {
|
||||
if (!this.openedOnce) {
|
||||
reject(new Error(`Could not connect to ${url}`))
|
||||
}
|
||||
}
|
||||
this.ws.onclose = (event) => {
|
||||
if (!this.openedOnce) {
|
||||
reject(new Error(formatWebSocketClose(url, event)))
|
||||
}
|
||||
this.close()
|
||||
}
|
||||
})
|
||||
this.ws.onmessage = (event) => {
|
||||
this.receive(event.data)
|
||||
}
|
||||
}
|
||||
|
||||
waitUntilOpen(): Promise<void> {
|
||||
return this.opened
|
||||
}
|
||||
|
||||
isClosed(): boolean {
|
||||
return this.closed
|
||||
}
|
||||
|
||||
async send(msg: JsonRpcMessage): Promise<void> {
|
||||
await this.opened
|
||||
if (this.closed || this.ws.readyState !== WebSocket.OPEN) {
|
||||
throw new Error("JSON-RPC WebSocket channel is closed")
|
||||
}
|
||||
|
||||
this.ws.send(JSON.stringify(msg))
|
||||
}
|
||||
|
||||
async next(): Promise<IteratorResult<JrpcMessage, void>> {
|
||||
const msg = this.queue.shift()
|
||||
if (msg) {
|
||||
return { done: false, value: msg }
|
||||
}
|
||||
|
||||
if (this.closed) {
|
||||
return { done: true, value: undefined }
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
this.waiters.push(resolve)
|
||||
})
|
||||
}
|
||||
|
||||
async return(): Promise<IteratorResult<JrpcMessage, void>> {
|
||||
this.close()
|
||||
return { done: true, value: undefined }
|
||||
}
|
||||
|
||||
async throw(error?: unknown): Promise<IteratorResult<JrpcMessage, void>> {
|
||||
this.close()
|
||||
throw error
|
||||
}
|
||||
|
||||
async [Symbol.asyncDispose](): Promise<void> {
|
||||
await this.return()
|
||||
}
|
||||
|
||||
close(): void {
|
||||
if (this.closed) return
|
||||
|
||||
this.closed = true
|
||||
for (const resolve of this.waiters.splice(0)) {
|
||||
resolve({ done: true, value: undefined })
|
||||
}
|
||||
|
||||
if (this.ws.readyState === WebSocket.CONNECTING || this.ws.readyState === WebSocket.OPEN) {
|
||||
this.ws.close()
|
||||
}
|
||||
}
|
||||
|
||||
[Symbol.asyncIterator](): AsyncGenerator<JrpcMessage, void, unknown> {
|
||||
return this
|
||||
}
|
||||
|
||||
private receive(message: unknown): void {
|
||||
const parsed = parseJrpcMessage(message)
|
||||
if (!parsed) {
|
||||
this.ws.close(1003, "Invalid JSON-RPC message")
|
||||
this.close()
|
||||
return
|
||||
}
|
||||
|
||||
this.push(parsed)
|
||||
}
|
||||
|
||||
private push(msg: JrpcMessage): void {
|
||||
if (this.closed) return
|
||||
|
||||
const resolve = this.waiters.shift()
|
||||
if (resolve) {
|
||||
resolve({ done: false, value: msg })
|
||||
return
|
||||
}
|
||||
|
||||
this.queue.push(msg)
|
||||
}
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
if (wantsHelp()) {
|
||||
printUsage()
|
||||
return
|
||||
}
|
||||
|
||||
printIntro()
|
||||
|
||||
const backendUrl = askRequired(
|
||||
"Backend URL",
|
||||
Bun.env.FREYA_BACKEND_URL ?? "http://localhost:3000",
|
||||
normalizeBackendUrl,
|
||||
)
|
||||
const email = askRequired("Email", Bun.env.FREYA_EMAIL)
|
||||
const password = askRequired("Password", Bun.env.FREYA_PASSWORD, undefined, true)
|
||||
|
||||
const cookies = new CookieJar()
|
||||
|
||||
try {
|
||||
const session = await signIn(backendUrl, cookies, email, password)
|
||||
console.log(`\nSigned in as ${session.user.email}`)
|
||||
await runChatLoop(backendUrl, cookies, session)
|
||||
} catch (err) {
|
||||
console.error(`\n${formatError(err)}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function signIn(
|
||||
backendUrl: string,
|
||||
cookies: CookieJar,
|
||||
email: string,
|
||||
password: string,
|
||||
): Promise<AuthSession> {
|
||||
await requestJson(backendUrl, cookies, "/api/auth/sign-in/email", {
|
||||
method: "POST",
|
||||
body: { email, password },
|
||||
})
|
||||
|
||||
const data = await requestJson(backendUrl, cookies, "/api/auth/get-session")
|
||||
if (!isAuthSession(data)) {
|
||||
throw new Error("Sign-in succeeded, but no session was returned")
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
async function runChatLoop(
|
||||
backendUrl: string,
|
||||
cookies: CookieJar,
|
||||
session: AuthSession,
|
||||
): Promise<void> {
|
||||
const agent = await AgentWebSocketSession.connect(backendUrl, cookies)
|
||||
console.log("Connected to /api/agent/ws")
|
||||
printHelp()
|
||||
|
||||
try {
|
||||
for (;;) {
|
||||
const input = askOptional("you> ")?.trim()
|
||||
if (!input) continue
|
||||
|
||||
if (input === "/quit" || input === "/exit") {
|
||||
console.log("Bye.")
|
||||
return
|
||||
}
|
||||
|
||||
if (input === "/help") {
|
||||
printHelp()
|
||||
continue
|
||||
}
|
||||
|
||||
if (input === "/session") {
|
||||
console.log(`${session.user.name || session.user.email} (${session.user.id})`)
|
||||
continue
|
||||
}
|
||||
|
||||
if (input === "/conversation") {
|
||||
console.log(agent.describeConversation())
|
||||
continue
|
||||
}
|
||||
|
||||
if (input === "/tools") {
|
||||
await runCliCommand(() => listQueryTools(backendUrl, cookies))
|
||||
continue
|
||||
}
|
||||
|
||||
if (input.startsWith("/tool ")) {
|
||||
await runCliCommand(() =>
|
||||
executeQueryTool(backendUrl, cookies, input.slice("/tool ".length)),
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
if (input.startsWith("/actions ")) {
|
||||
await runCliCommand(() =>
|
||||
listSourceActions(backendUrl, cookies, input.slice("/actions ".length)),
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
if (input.startsWith("/action ")) {
|
||||
await runCliCommand(() =>
|
||||
executeSourceAction(backendUrl, cookies, input.slice("/action ".length)),
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
await agent.ask(input)
|
||||
} catch (err) {
|
||||
console.error(`\n${formatError(err)}\n`)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
agent.close()
|
||||
}
|
||||
}
|
||||
|
||||
async function runCliCommand(command: () => Promise<void>): Promise<void> {
|
||||
try {
|
||||
await command()
|
||||
} catch (err) {
|
||||
console.error(`\n${formatError(err)}\n`)
|
||||
}
|
||||
}
|
||||
|
||||
async function listQueryTools(backendUrl: string, cookies: CookieJar): Promise<void> {
|
||||
const data = await requestJson(backendUrl, cookies, "/api/agent/tools")
|
||||
if (!isQueryToolsResponse(data)) {
|
||||
throw new Error("Agent tools returned an unexpected response shape")
|
||||
}
|
||||
|
||||
console.log("")
|
||||
for (const tool of data.tools) {
|
||||
console.log(`${tool.name} - ${tool.label}`)
|
||||
console.log(` ${tool.description}`)
|
||||
console.log(` params=${formatJson(tool.parameters)}`)
|
||||
}
|
||||
console.log("")
|
||||
}
|
||||
|
||||
async function executeQueryTool(
|
||||
backendUrl: string,
|
||||
cookies: CookieJar,
|
||||
command: string,
|
||||
): Promise<void> {
|
||||
const parsed = splitFirst(command.trim())
|
||||
if (!parsed) {
|
||||
throw new Error("Usage: /tool <name> <json-params>; example: /tool freya_list_context {}")
|
||||
}
|
||||
|
||||
const params = parseJsonArgument(parsed.rest, {})
|
||||
const data = await requestJson(backendUrl, cookies, `/api/agent/tools/${urlPart(parsed.head)}`, {
|
||||
method: "POST",
|
||||
body: params,
|
||||
})
|
||||
if (!isResultResponse(data)) {
|
||||
throw new Error("Tool execution returned an unexpected response shape")
|
||||
}
|
||||
|
||||
console.log(`\ntool ${parsed.head}>`)
|
||||
console.log(formatJson(data.result))
|
||||
console.log("")
|
||||
}
|
||||
|
||||
async function listSourceActions(
|
||||
backendUrl: string,
|
||||
cookies: CookieJar,
|
||||
command: string,
|
||||
): Promise<void> {
|
||||
const sourceId = command.trim()
|
||||
if (!sourceId) {
|
||||
throw new Error("Usage: /actions <source-id>")
|
||||
}
|
||||
|
||||
const data = await requestJson(backendUrl, cookies, `/api/sources/${urlPart(sourceId)}/actions`)
|
||||
if (!isSourceActionsResponse(data)) {
|
||||
throw new Error("Source actions returned an unexpected response shape")
|
||||
}
|
||||
|
||||
const actions = Object.entries(data.actions)
|
||||
console.log("")
|
||||
if (actions.length === 0) {
|
||||
console.log(`No actions for ${sourceId}.`)
|
||||
} else {
|
||||
for (const [key, action] of actions) {
|
||||
console.log(`${sourceId}/${key}`)
|
||||
console.log(` id=${action.id}`)
|
||||
if (action.description) console.log(` ${action.description}`)
|
||||
}
|
||||
}
|
||||
console.log("")
|
||||
}
|
||||
|
||||
async function executeSourceAction(
|
||||
backendUrl: string,
|
||||
cookies: CookieJar,
|
||||
command: string,
|
||||
): Promise<void> {
|
||||
const source = splitFirst(command.trim())
|
||||
if (!source) {
|
||||
throw new Error(
|
||||
'Usage: /action <source-id> <action-id> <json-params>; example: /action freya.location update-location {"lat":51.5,"lng":-0.1}',
|
||||
)
|
||||
}
|
||||
|
||||
const action = splitFirst(source.rest)
|
||||
if (!action) {
|
||||
throw new Error(
|
||||
'Usage: /action <source-id> <action-id> <json-params>; example: /action freya.location update-location {"lat":51.5,"lng":-0.1}',
|
||||
)
|
||||
}
|
||||
|
||||
const params = parseJsonArgument(action.rest, {})
|
||||
const data = await requestJson(
|
||||
backendUrl,
|
||||
cookies,
|
||||
`/api/sources/${urlPart(source.head)}/actions/${urlPart(action.head)}`,
|
||||
{
|
||||
method: "POST",
|
||||
body: params,
|
||||
},
|
||||
)
|
||||
if (!isResultResponse(data)) {
|
||||
throw new Error("Source action returned an unexpected response shape")
|
||||
}
|
||||
|
||||
console.log(`\naction ${source.head}/${action.head}>`)
|
||||
console.log(formatJson(data.result))
|
||||
console.log("")
|
||||
}
|
||||
|
||||
async function requestJson(
|
||||
backendUrl: string,
|
||||
cookies: CookieJar,
|
||||
path: string,
|
||||
options: RequestOptions = {},
|
||||
): Promise<unknown> {
|
||||
const headers = new Headers()
|
||||
headers.set("Accept", "application/json")
|
||||
|
||||
const cookieHeader = cookies.header()
|
||||
if (cookieHeader) headers.set("Cookie", cookieHeader)
|
||||
|
||||
let body: string | undefined
|
||||
if (options.body !== undefined) {
|
||||
headers.set("Content-Type", "application/json")
|
||||
body = JSON.stringify(options.body)
|
||||
}
|
||||
|
||||
const response = await fetch(`${backendUrl}${path}`, {
|
||||
method: options.method ?? "GET",
|
||||
headers,
|
||||
body,
|
||||
})
|
||||
|
||||
cookies.apply(response)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(await readResponseError(response, path))
|
||||
}
|
||||
|
||||
return response.json()
|
||||
}
|
||||
|
||||
function printIntro(): void {
|
||||
console.log("FREYA agent test CLI")
|
||||
console.log("Connect to a backend, sign in, then send test messages to /api/agent/ws.\n")
|
||||
}
|
||||
|
||||
function printUsage(): void {
|
||||
console.log("FREYA agent test CLI")
|
||||
console.log("")
|
||||
console.log("Usage:")
|
||||
console.log(" bun run agent-test-cli")
|
||||
console.log(
|
||||
" FREYA_BACKEND_URL=http://localhost:3000 FREYA_EMAIL=user@example.com FREYA_PASSWORD=secret bun run agent-test-cli",
|
||||
)
|
||||
console.log("")
|
||||
printHelp()
|
||||
}
|
||||
|
||||
function printHelp(): void {
|
||||
console.log("\nCommands:")
|
||||
console.log(" /tools List agent debug tools")
|
||||
console.log(" /tool Execute an agent debug tool with JSON params")
|
||||
console.log(" /actions List source actions: /actions <source-id>")
|
||||
console.log(" /action Execute source action: /action <source-id> <action-id> <json-params>")
|
||||
console.log(" /conversation Show the current websocket conversation")
|
||||
console.log(" /session Show the signed-in user")
|
||||
console.log(" /help Show commands")
|
||||
console.log(" /quit Exit\n")
|
||||
}
|
||||
|
||||
function askRequired(
|
||||
label: string,
|
||||
defaultValue?: string,
|
||||
transform?: (value: string) => string,
|
||||
hidden = false,
|
||||
): string {
|
||||
if (hidden && defaultValue) {
|
||||
const value = defaultValue.trim()
|
||||
if (value) return transform ? transform(value) : value
|
||||
}
|
||||
|
||||
const canRetry = canRunStty()
|
||||
|
||||
for (;;) {
|
||||
const answer = hidden
|
||||
? askHidden(label, defaultValue)
|
||||
: askOptional(formatPromptLabel(label, defaultValue))
|
||||
const value = (answer || defaultValue || "").trim()
|
||||
if (!value) {
|
||||
if (!canRetry) {
|
||||
throw new Error(`${label} is required`)
|
||||
}
|
||||
console.log(`${label} is required.`)
|
||||
continue
|
||||
}
|
||||
return transform ? transform(value) : value
|
||||
}
|
||||
}
|
||||
|
||||
function askOptional(label: string): string | null {
|
||||
return prompt(label)
|
||||
}
|
||||
|
||||
function askHidden(label: string, defaultValue?: string): string | null {
|
||||
const shouldHide = !defaultValue && canRunStty()
|
||||
if (!shouldHide) return askOptional(formatPromptLabel(label, defaultValue))
|
||||
|
||||
try {
|
||||
Bun.spawnSync(["stty", "-echo"], { stdin: "inherit", stdout: "inherit", stderr: "inherit" })
|
||||
return askOptional(`${label}: `)
|
||||
} finally {
|
||||
Bun.spawnSync(["stty", "echo"], { stdin: "inherit", stdout: "inherit", stderr: "inherit" })
|
||||
console.log("")
|
||||
}
|
||||
}
|
||||
|
||||
function wantsHelp(): boolean {
|
||||
return Bun.argv.some((arg) => arg === "--help" || arg === "-h")
|
||||
}
|
||||
|
||||
function normalizeBackendUrl(value: string): string {
|
||||
const withProtocol = /^[a-z]+:\/\//i.test(value) ? value : `http://${value}`
|
||||
|
||||
try {
|
||||
const url = new URL(withProtocol)
|
||||
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
||||
throw new Error("Backend URL must use http or https")
|
||||
}
|
||||
return url.toString().replace(/\/+$/, "")
|
||||
} catch {
|
||||
throw new Error(`Invalid backend URL: ${value}`)
|
||||
}
|
||||
}
|
||||
|
||||
function agentWebSocketUrl(backendUrl: string): string {
|
||||
const url = new URL(backendUrl)
|
||||
url.protocol = url.protocol === "https:" ? "wss:" : "ws:"
|
||||
url.pathname = "/api/agent/ws"
|
||||
url.search = ""
|
||||
url.hash = ""
|
||||
return url.toString()
|
||||
}
|
||||
|
||||
function createWebSocketOptions(cookieHeader?: string): Bun.WebSocketOptions | undefined {
|
||||
if (!cookieHeader) return undefined
|
||||
|
||||
return {
|
||||
headers: {
|
||||
Cookie: cookieHeader,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function formatWebSocketClose(
|
||||
url: string,
|
||||
event: { code: number; reason: string; wasClean: boolean },
|
||||
): string {
|
||||
const reason = event.reason ? `: ${event.reason}` : ""
|
||||
return `Could not connect to ${url} (${event.code}${reason})`
|
||||
}
|
||||
|
||||
function formatPromptLabel(label: string, defaultValue?: string): string {
|
||||
return defaultValue ? `${label} (${defaultValue}): ` : `${label}: `
|
||||
}
|
||||
|
||||
function splitFirst(value: string): { head: string; rest: string } | null {
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) return null
|
||||
|
||||
const match = /\s/.exec(trimmed)
|
||||
if (!match) {
|
||||
return { head: trimmed, rest: "" }
|
||||
}
|
||||
|
||||
const head = trimmed.slice(0, match.index)
|
||||
const rest = trimmed.slice(match.index).trim()
|
||||
return { head, rest }
|
||||
}
|
||||
|
||||
function parseJsonArgument(value: string, fallback: unknown): unknown {
|
||||
if (!value.trim()) return fallback
|
||||
|
||||
try {
|
||||
return JSON.parse(value)
|
||||
} catch (err) {
|
||||
throw new Error(`Invalid JSON params: ${formatError(err)}`)
|
||||
}
|
||||
}
|
||||
|
||||
function formatJson(value: unknown): string {
|
||||
const serialized = JSON.stringify(value, null, 2)
|
||||
return serialized ?? "undefined"
|
||||
}
|
||||
|
||||
function urlPart(value: string): string {
|
||||
return encodeURIComponent(value)
|
||||
}
|
||||
|
||||
function canRunStty(): boolean {
|
||||
const result = Bun.spawnSync(["stty", "-g"], { stdin: "inherit", stdout: "pipe", stderr: "pipe" })
|
||||
return result.exitCode === 0
|
||||
}
|
||||
|
||||
function readSetCookieHeaders(headers: Headers): string[] {
|
||||
const setCookies = headers.getSetCookie()
|
||||
if (setCookies && setCookies.length > 0) return setCookies
|
||||
|
||||
const header = headers.get("set-cookie")
|
||||
if (!header) return []
|
||||
|
||||
return splitSetCookieHeader(header)
|
||||
}
|
||||
|
||||
function parseCookie(header: string): { name: string; value: string } | null {
|
||||
const [cookiePair] = header.split(";")
|
||||
if (!cookiePair) return null
|
||||
|
||||
const index = cookiePair.indexOf("=")
|
||||
if (index <= 0) return null
|
||||
|
||||
return {
|
||||
name: cookiePair.slice(0, index).trim(),
|
||||
value: cookiePair.slice(index + 1).trim(),
|
||||
}
|
||||
}
|
||||
|
||||
function splitSetCookieHeader(header: string): string[] {
|
||||
const parts: string[] = []
|
||||
let start = 0
|
||||
let inExpires = false
|
||||
|
||||
for (let index = 0; index < header.length; index += 1) {
|
||||
const char = header[index]
|
||||
const remainder = header.slice(index).toLowerCase()
|
||||
|
||||
if (remainder.startsWith("expires=")) {
|
||||
inExpires = true
|
||||
continue
|
||||
}
|
||||
|
||||
if (inExpires && char === ";") {
|
||||
inExpires = false
|
||||
continue
|
||||
}
|
||||
|
||||
if (char === "," && !inExpires) {
|
||||
parts.push(header.slice(start, index).trim())
|
||||
start = index + 1
|
||||
}
|
||||
}
|
||||
|
||||
parts.push(header.slice(start).trim())
|
||||
return parts.filter(Boolean)
|
||||
}
|
||||
|
||||
function parseJrpcMessage(message: unknown): JrpcMessage | null {
|
||||
const text = webSocketMessageText(message)
|
||||
if (!text) return null
|
||||
|
||||
try {
|
||||
const value: unknown = JSON.parse(text)
|
||||
return isJrpcMessage(value) ? value : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function webSocketMessageText(message: unknown): string | null {
|
||||
if (typeof message === "string") return message
|
||||
if (message instanceof Uint8Array) return Buffer.from(message).toString("utf8")
|
||||
if (message instanceof ArrayBuffer) return Buffer.from(message).toString("utf8")
|
||||
return null
|
||||
}
|
||||
|
||||
async function readResponseError(response: Response, path: string): Promise<string> {
|
||||
const text = await response.text()
|
||||
if (response.status === 404 && path === "/api/agent") {
|
||||
return "Backend does not expose /api/agent. Restart the WIP backend on port 3000 or check FREYA_BACKEND_URL."
|
||||
}
|
||||
if (!text) return `Request failed: ${response.status} ${response.statusText}`
|
||||
|
||||
try {
|
||||
const data: unknown = JSON.parse(text)
|
||||
if (isJsonObject(data)) {
|
||||
const message = readString(data, "message") ?? readString(data, "error")
|
||||
if (message) return message
|
||||
}
|
||||
} catch {
|
||||
return `Request failed: ${response.status} ${response.statusText}: ${text}`
|
||||
}
|
||||
|
||||
return `Request failed: ${response.status} ${response.statusText}: ${text}`
|
||||
}
|
||||
|
||||
function isAuthSession(value: unknown): value is AuthSession {
|
||||
if (!isJsonObject(value)) return false
|
||||
const user = value.user
|
||||
const session = value.session
|
||||
|
||||
return (
|
||||
isJsonObject(user) &&
|
||||
isJsonObject(session) &&
|
||||
typeof user.id === "string" &&
|
||||
typeof user.name === "string" &&
|
||||
typeof user.email === "string" &&
|
||||
(user.image === null || typeof user.image === "string") &&
|
||||
typeof session.id === "string" &&
|
||||
typeof session.token === "string"
|
||||
)
|
||||
}
|
||||
|
||||
function isQueryToolsResponse(value: unknown): value is QueryToolsResponse {
|
||||
if (!isJsonObject(value) || !Array.isArray(value.tools)) return false
|
||||
return value.tools.every(isQueryToolDefinition)
|
||||
}
|
||||
|
||||
function isQueryToolDefinition(value: unknown): value is QueryToolDefinition {
|
||||
return (
|
||||
isJsonObject(value) &&
|
||||
typeof value.name === "string" &&
|
||||
typeof value.label === "string" &&
|
||||
typeof value.description === "string" &&
|
||||
"parameters" in value
|
||||
)
|
||||
}
|
||||
|
||||
function isResultResponse(value: unknown): value is ResultResponse {
|
||||
return isJsonObject(value) && "result" in value
|
||||
}
|
||||
|
||||
function isSourceActionsResponse(value: unknown): value is SourceActionsResponse {
|
||||
if (!isJsonObject(value) || !isJsonObject(value.actions)) return false
|
||||
return Object.values(value.actions).every(isSourceActionDefinition)
|
||||
}
|
||||
|
||||
function isSourceActionDefinition(value: unknown): value is { id: string; description?: string } {
|
||||
return (
|
||||
isJsonObject(value) &&
|
||||
typeof value.id === "string" &&
|
||||
(value.description === undefined || typeof value.description === "string")
|
||||
)
|
||||
}
|
||||
|
||||
function isJrpcMessage(value: unknown): value is JrpcMessage {
|
||||
if (!isJsonObject(value) || value.jsonrpc !== "2.0" || typeof value.id !== "number") {
|
||||
return false
|
||||
}
|
||||
|
||||
if ("method" in value) {
|
||||
return (
|
||||
typeof value.method === "string" &&
|
||||
(value.params === undefined || Array.isArray(value.params))
|
||||
)
|
||||
}
|
||||
|
||||
if ("result" in value) {
|
||||
return true
|
||||
}
|
||||
|
||||
if ("error" in value) {
|
||||
return isJsonRpcErrorObject(value.error)
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
function isJsonRpcErrorObject(value: unknown): boolean {
|
||||
return isJsonObject(value) && typeof value.code === "number" && typeof value.message === "string"
|
||||
}
|
||||
|
||||
function isJsonObject(value: unknown): value is JsonObject {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function readString(object: JsonObject, key: string): string | undefined {
|
||||
const value = object[key]
|
||||
return typeof value === "string" ? value : undefined
|
||||
}
|
||||
|
||||
function formatError(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
|
||||
await main()
|
||||
4
apps/agent-test-cli/tsconfig.json
Normal file
4
apps/agent-test-cli/tsconfig.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
@@ -5,15 +5,13 @@ DATABASE_URL=postgresql://user:password@localhost:5432/aris
|
||||
BETTER_AUTH_SECRET=
|
||||
|
||||
# Encryption key for source credentials at rest (32 bytes, generate with: openssl rand -base64 32)
|
||||
CREDENTIALS_ENCRYPTION_KEY=
|
||||
CREDENTIAL_ENCRYPTION_KEY=
|
||||
|
||||
# Base URL of the backend
|
||||
BETTER_AUTH_URL=http://localhost:3000
|
||||
|
||||
# OpenRouter (LLM feed enhancement)
|
||||
OPENROUTER_API_KEY=
|
||||
# Optional: override the default model (default: openai/gpt-4.1-mini)
|
||||
# OPENROUTER_MODEL=openai/gpt-4.1-mini
|
||||
|
||||
# Apple WeatherKit credentials
|
||||
WEATHERKIT_PRIVATE_KEY=
|
||||
@@ -49,6 +49,33 @@ CREATE TABLE "user_sources" (
|
||||
CONSTRAINT "user_sources_user_id_source_id_unique" UNIQUE("user_id","source_id")
|
||||
);
|
||||
--> 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" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"identifier" text NOT NULL,
|
||||
@@ -61,6 +88,13 @@ CREATE TABLE "verification" (
|
||||
ALTER TABLE "account" ADD CONSTRAINT "account_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "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 "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 "session_userId_idx" ON "session" USING btree ("user_id");--> statement-breakpoint
|
||||
CREATE INDEX "verification_identifier_idx" ON "verification" USING btree ("identifier");
|
||||
CREATE INDEX "reminders_user_id_due_at_idx" ON "reminders" USING btree ("user_id","due_at");--> statement-breakpoint
|
||||
CREATE INDEX "reminders_user_id_updated_at_idx" ON "reminders" USING btree ("user_id","updated_at");--> statement-breakpoint
|
||||
CREATE INDEX "reminder_occurrence_overrides_user_id_reminder_id_idx" ON "reminder_occurrence_overrides" USING btree ("user_id","reminder_id");--> statement-breakpoint
|
||||
CREATE INDEX "reminder_occurrence_overrides_user_id_original_due_at_idx" ON "reminder_occurrence_overrides" USING btree ("user_id","original_due_at");--> statement-breakpoint
|
||||
CREATE INDEX "verification_identifier_idx" ON "verification" USING btree ("identifier");
|
||||
49
apps/freya-backend/drizzle/0001_misty_white_tiger.sql
Normal file
49
apps/freya-backend/drizzle/0001_misty_white_tiger.sql
Normal file
@@ -0,0 +1,49 @@
|
||||
CREATE INDEX "user_sources_user_id_enabled_idx" ON "user_sources" USING btree ("user_id","enabled");--> statement-breakpoint
|
||||
CREATE TABLE "conversation_entries" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"conversation_id" uuid NOT NULL,
|
||||
"sequence" integer NOT NULL,
|
||||
"kind" text NOT NULL,
|
||||
"visibility" text DEFAULT 'internal' NOT NULL,
|
||||
"file_id" uuid,
|
||||
"payload" jsonb NOT NULL,
|
||||
"metadata" jsonb DEFAULT '{}'::jsonb NOT NULL,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "conversation_entries_conversation_id_sequence_unique" UNIQUE("conversation_id","sequence")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "conversations" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"user_id" text NOT NULL,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "files" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"user_id" text NOT NULL,
|
||||
"storage_key" text NOT NULL,
|
||||
"original_name" text,
|
||||
"mime_type" text NOT NULL,
|
||||
"size_bytes" integer NOT NULL,
|
||||
"metadata" jsonb DEFAULT '{}'::jsonb NOT NULL,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "files_storage_key_unique" UNIQUE("storage_key")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "session" ADD COLUMN "impersonated_by" text;--> statement-breakpoint
|
||||
ALTER TABLE "user" ADD COLUMN "role" text;--> statement-breakpoint
|
||||
ALTER TABLE "user" ADD COLUMN "banned" boolean DEFAULT false;--> statement-breakpoint
|
||||
ALTER TABLE "user" ADD COLUMN "ban_reason" text;--> statement-breakpoint
|
||||
ALTER TABLE "user" ADD COLUMN "ban_expires" timestamp;--> statement-breakpoint
|
||||
ALTER TABLE "conversation_entries" ADD CONSTRAINT "conversation_entries_conversation_id_conversations_id_fk" FOREIGN KEY ("conversation_id") REFERENCES "public"."conversations"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "conversation_entries" ADD CONSTRAINT "conversation_entries_file_id_files_id_fk" FOREIGN KEY ("file_id") REFERENCES "public"."files"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "conversation_entries" ADD CONSTRAINT "conversation_entries_attachment_file_id_check" CHECK (("conversation_entries"."kind" = 'attachment' and "conversation_entries"."file_id" is not null) or ("conversation_entries"."kind" <> 'attachment' and "conversation_entries"."file_id" is null));--> statement-breakpoint
|
||||
ALTER TABLE "conversations" ADD CONSTRAINT "conversations_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "files" ADD CONSTRAINT "files_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "conversation_entries_conversation_id_sequence_idx" ON "conversation_entries" USING btree ("conversation_id","sequence");--> statement-breakpoint
|
||||
CREATE INDEX "conversation_entries_conversation_id_visibility_sequence_idx" ON "conversation_entries" USING btree ("conversation_id","visibility","sequence");--> statement-breakpoint
|
||||
CREATE INDEX "conversation_entries_kind_idx" ON "conversation_entries" USING btree ("kind");--> statement-breakpoint
|
||||
CREATE INDEX "conversation_entries_file_id_idx" ON "conversation_entries" USING btree ("file_id");--> statement-breakpoint
|
||||
CREATE INDEX "conversations_user_id_updated_at_idx" ON "conversations" USING btree ("user_id","updated_at");--> statement-breakpoint
|
||||
CREATE INDEX "files_user_id_created_at_idx" ON "files" USING btree ("user_id","created_at");
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"id": "d963322c-77e2-4ac9-bd3c-ca544c85ae35",
|
||||
"prevId": "d8c59ec7-b686-41a7-a472-da29f3ab6727",
|
||||
"id": "d8c59ec7-b686-41a7-a472-da29f3ab6727",
|
||||
"prevId": "00000000-0000-0000-0000-000000000000",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"tables": {
|
||||
@@ -346,29 +346,7 @@
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"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": {}
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"user_sources_user_id_user_id_fk": {
|
||||
"name": "user_sources_user_id_user_id_fk",
|
||||
@@ -463,6 +441,296 @@
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"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": {},
|
||||
@@ -476,4 +744,4 @@
|
||||
"schemas": {},
|
||||
"tables": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
1175
apps/freya-backend/drizzle/meta/0001_snapshot.json
Normal file
1175
apps/freya-backend/drizzle/meta/0001_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user