chore: rename aelis to freya
43
apps/freya-client/.gitignore
vendored
Normal file
@@ -0,0 +1,43 @@
|
||||
# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
|
||||
|
||||
# dependencies
|
||||
node_modules/
|
||||
|
||||
# Expo
|
||||
.expo/
|
||||
dist/
|
||||
web-build/
|
||||
expo-env.d.ts
|
||||
|
||||
# Native
|
||||
.kotlin/
|
||||
*.orig.*
|
||||
*.jks
|
||||
*.p8
|
||||
*.p12
|
||||
*.key
|
||||
*.mobileprovision
|
||||
|
||||
# Metro
|
||||
.metro-health-check*
|
||||
|
||||
# debug
|
||||
npm-debug.*
|
||||
yarn-debug.*
|
||||
yarn-error.*
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# local env files
|
||||
.env*.local
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
|
||||
app-example
|
||||
|
||||
# generated native folders
|
||||
/ios
|
||||
/android
|
||||
1
apps/freya-client/.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{ "recommendations": ["expo.vscode-expo-tools"] }
|
||||
7
apps/freya-client/.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll": "explicit",
|
||||
"source.organizeImports": "explicit",
|
||||
"source.sortMembers": "explicit"
|
||||
}
|
||||
}
|
||||
50
apps/freya-client/README.md
Normal file
@@ -0,0 +1,50 @@
|
||||
# Welcome to your Expo app 👋
|
||||
|
||||
This is an [Expo](https://expo.dev) project created with [`create-expo-app`](https://www.npmjs.com/package/create-expo-app).
|
||||
|
||||
## Get started
|
||||
|
||||
1. Install dependencies
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
2. Start the app
|
||||
|
||||
```bash
|
||||
npx expo start
|
||||
```
|
||||
|
||||
In the output, you'll find options to open the app in a
|
||||
|
||||
- [development build](https://docs.expo.dev/develop/development-builds/introduction/)
|
||||
- [Android emulator](https://docs.expo.dev/workflow/android-studio-emulator/)
|
||||
- [iOS simulator](https://docs.expo.dev/workflow/ios-simulator/)
|
||||
- [Expo Go](https://expo.dev/go), a limited sandbox for trying out app development with Expo
|
||||
|
||||
You can start developing by editing the files inside the **app** directory. This project uses [file-based routing](https://docs.expo.dev/router/introduction).
|
||||
|
||||
## Get a fresh project
|
||||
|
||||
When you're ready, run:
|
||||
|
||||
```bash
|
||||
npm run reset-project
|
||||
```
|
||||
|
||||
This command will move the starter code to the **app-example** directory and create a blank **app** directory where you can start developing.
|
||||
|
||||
## Learn more
|
||||
|
||||
To learn more about developing your project with Expo, look at the following resources:
|
||||
|
||||
- [Expo documentation](https://docs.expo.dev/): Learn fundamentals, or go into advanced topics with our [guides](https://docs.expo.dev/guides).
|
||||
- [Learn Expo tutorial](https://docs.expo.dev/tutorial/introduction/): Follow a step-by-step tutorial where you'll create a project that runs on Android, iOS, and the web.
|
||||
|
||||
## Join the community
|
||||
|
||||
Join our community of developers creating universal apps.
|
||||
|
||||
- [Expo on GitHub](https://github.com/expo/expo): View our open source platform and contribute.
|
||||
- [Discord community](https://chat.expo.dev): Chat with Expo users and ask questions.
|
||||
220
apps/freya-client/app.json
Normal file
@@ -0,0 +1,220 @@
|
||||
{
|
||||
"expo": {
|
||||
"name": "Freya",
|
||||
"slug": "freya-client",
|
||||
"version": "1.0.0",
|
||||
"orientation": "portrait",
|
||||
"icon": "./assets/images/icon.png",
|
||||
"scheme": "freya",
|
||||
"userInterfaceStyle": "automatic",
|
||||
"newArchEnabled": true,
|
||||
"ios": {
|
||||
"infoPlist": {
|
||||
"NSAppTransportSecurity": {
|
||||
"NSAllowsArbitraryLoads": true
|
||||
},
|
||||
"ITSAppUsesNonExemptEncryption": false
|
||||
},
|
||||
"bundleIdentifier": "sh.nym.freya"
|
||||
},
|
||||
"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.freya"
|
||||
},
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
BIN
apps/freya-client/assets/fonts/Inter_100Thin.ttf
Normal file
BIN
apps/freya-client/assets/fonts/Inter_100Thin_Italic.ttf
Normal file
BIN
apps/freya-client/assets/fonts/Inter_200ExtraLight.ttf
Normal file
BIN
apps/freya-client/assets/fonts/Inter_200ExtraLight_Italic.ttf
Normal file
BIN
apps/freya-client/assets/fonts/Inter_300Light.ttf
Normal file
BIN
apps/freya-client/assets/fonts/Inter_300Light_Italic.ttf
Normal file
BIN
apps/freya-client/assets/fonts/Inter_400Regular.ttf
Normal file
BIN
apps/freya-client/assets/fonts/Inter_400Regular_Italic.ttf
Normal file
BIN
apps/freya-client/assets/fonts/Inter_500Medium.ttf
Normal file
BIN
apps/freya-client/assets/fonts/Inter_500Medium_Italic.ttf
Normal file
BIN
apps/freya-client/assets/fonts/Inter_600SemiBold.ttf
Normal file
BIN
apps/freya-client/assets/fonts/Inter_600SemiBold_Italic.ttf
Normal file
BIN
apps/freya-client/assets/fonts/Inter_700Bold.ttf
Normal file
BIN
apps/freya-client/assets/fonts/Inter_700Bold_Italic.ttf
Normal file
BIN
apps/freya-client/assets/fonts/Inter_800ExtraBold.ttf
Normal file
BIN
apps/freya-client/assets/fonts/Inter_800ExtraBold_Italic.ttf
Normal file
BIN
apps/freya-client/assets/fonts/Inter_900Black.ttf
Normal file
BIN
apps/freya-client/assets/fonts/Inter_900Black_Italic.ttf
Normal file
BIN
apps/freya-client/assets/fonts/SourceSerif4_200ExtraLight.ttf
Normal file
BIN
apps/freya-client/assets/fonts/SourceSerif4_300Light.ttf
Normal file
BIN
apps/freya-client/assets/fonts/SourceSerif4_300Light_Italic.ttf
Normal file
BIN
apps/freya-client/assets/fonts/SourceSerif4_400Regular.ttf
Normal file
BIN
apps/freya-client/assets/fonts/SourceSerif4_500Medium.ttf
Normal file
BIN
apps/freya-client/assets/fonts/SourceSerif4_500Medium_Italic.ttf
Normal file
BIN
apps/freya-client/assets/fonts/SourceSerif4_600SemiBold.ttf
Normal file
BIN
apps/freya-client/assets/fonts/SourceSerif4_700Bold.ttf
Normal file
BIN
apps/freya-client/assets/fonts/SourceSerif4_700Bold_Italic.ttf
Normal file
BIN
apps/freya-client/assets/fonts/SourceSerif4_800ExtraBold.ttf
Normal file
BIN
apps/freya-client/assets/fonts/SourceSerif4_900Black.ttf
Normal file
BIN
apps/freya-client/assets/fonts/SourceSerif4_900Black_Italic.ttf
Normal file
BIN
apps/freya-client/assets/images/android-icon-background.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
apps/freya-client/assets/images/android-icon-foreground.png
Normal file
|
After Width: | Height: | Size: 77 KiB |
BIN
apps/freya-client/assets/images/android-icon-monochrome.png
Normal file
|
After Width: | Height: | Size: 4.0 KiB |
BIN
apps/freya-client/assets/images/favicon.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
apps/freya-client/assets/images/icon.png
Normal file
|
After Width: | Height: | Size: 384 KiB |
BIN
apps/freya-client/assets/images/partial-react-logo.png
Normal file
|
After Width: | Height: | Size: 5.0 KiB |
BIN
apps/freya-client/assets/images/react-logo.png
Normal file
|
After Width: | Height: | Size: 6.2 KiB |
BIN
apps/freya-client/assets/images/react-logo@2x.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
apps/freya-client/assets/images/react-logo@3x.png
Normal file
|
After Width: | Height: | Size: 21 KiB |
BIN
apps/freya-client/assets/images/splash-icon.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
27
apps/freya-client/eas.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"cli": {
|
||||
"version": ">= 18.0.1",
|
||||
"appVersionSource": "remote"
|
||||
},
|
||||
"build": {
|
||||
"development": {
|
||||
"developmentClient": true,
|
||||
"distribution": "internal"
|
||||
},
|
||||
"development-simulator": {
|
||||
"extends": "development",
|
||||
"ios": {
|
||||
"simulator": "true"
|
||||
}
|
||||
},
|
||||
"preview": {
|
||||
"distribution": "internal"
|
||||
},
|
||||
"production": {
|
||||
"autoIncrement": true
|
||||
}
|
||||
},
|
||||
"submit": {
|
||||
"production": {}
|
||||
}
|
||||
}
|
||||
10
apps/freya-client/eslint.config.js
Normal file
@@ -0,0 +1,10 @@
|
||||
// https://docs.expo.dev/guides/using-eslint/
|
||||
const { defineConfig } = require("eslint/config")
|
||||
const expoConfig = require("eslint-config-expo/flat")
|
||||
|
||||
module.exports = defineConfig([
|
||||
expoConfig,
|
||||
{
|
||||
ignores: ["dist/*"],
|
||||
},
|
||||
])
|
||||
60
apps/freya-client/package.json
Normal file
@@ -0,0 +1,60 @@
|
||||
{
|
||||
"name": "freya-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": "^6"
|
||||
}
|
||||
}
|
||||
154
apps/freya-client/scripts/dev-proxy.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
// 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 PROXY_HOST = process.env.PROXY_HOST || "0.0.0.0"
|
||||
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
|
||||
}
|
||||
|
||||
interface DebugTarget {
|
||||
webSocketDebuggerUrl: string
|
||||
reactNative?: {
|
||||
capabilities?: { prefersFuseboxFrontend?: boolean }
|
||||
}
|
||||
}
|
||||
|
||||
Bun.serve<WsData>({
|
||||
hostname: PROXY_HOST,
|
||||
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 body = req.body ? await req.arrayBuffer() : undefined
|
||||
const res = await fetch(upstream, {
|
||||
method: req.method,
|
||||
headers: forwardHeaders(req.headers),
|
||||
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
|
||||
|
||||
const parsedTargets: unknown = await res.json()
|
||||
if (!Array.isArray(parsedTargets)) return
|
||||
|
||||
const targets = parsedTargets.filter(isDebugTarget)
|
||||
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_HOST}:${PROXY_PORT}, forwarding to 127.0.0.1:${METRO_PORT}`,
|
||||
)
|
||||
|
||||
function isDebugTarget(value: unknown): value is DebugTarget {
|
||||
if (!isRecord(value) || typeof value.webSocketDebuggerUrl !== "string") return false
|
||||
|
||||
const reactNative = value.reactNative
|
||||
if (reactNative === undefined) return true
|
||||
if (!isRecord(reactNative)) return false
|
||||
|
||||
const capabilities = reactNative.capabilities
|
||||
if (capabilities === undefined) return true
|
||||
if (!isRecord(capabilities)) return false
|
||||
|
||||
const prefersFuseboxFrontend = capabilities.prefersFuseboxFrontend
|
||||
return prefersFuseboxFrontend === undefined || typeof prefersFuseboxFrontend === "boolean"
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null
|
||||
}
|
||||
76
apps/freya-client/scripts/open-debugger.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
// 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 {
|
||||
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 parsedTargets: unknown = await res.json()
|
||||
if (!Array.isArray(parsedTargets)) {
|
||||
console.error("Invalid /json response from Metro.")
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const targets = parsedTargets.filter(isDebugTarget)
|
||||
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.")
|
||||
}
|
||||
}
|
||||
|
||||
function isDebugTarget(value: unknown): value is DebugTarget {
|
||||
if (!isRecord(value) || typeof value.webSocketDebuggerUrl !== "string") return false
|
||||
|
||||
const reactNative = value.reactNative
|
||||
if (reactNative === undefined) return true
|
||||
if (!isRecord(reactNative)) return false
|
||||
|
||||
const capabilities = reactNative.capabilities
|
||||
if (capabilities === undefined) return true
|
||||
if (!isRecord(capabilities)) return false
|
||||
|
||||
const prefersFuseboxFrontend = capabilities.prefersFuseboxFrontend
|
||||
return prefersFuseboxFrontend === undefined || typeof prefersFuseboxFrontend === "boolean"
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null
|
||||
}
|
||||
112
apps/freya-client/scripts/reset-project.js
Executable file
@@ -0,0 +1,112 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* This script is used to reset the project to a blank state.
|
||||
* It deletes or moves the /app, /components, /hooks, /scripts, and /constants directories to /app-example based on user input and creates a new /app directory with an index.tsx and _layout.tsx file.
|
||||
* You can remove the `reset-project` script from package.json and safely delete this file after running it.
|
||||
*/
|
||||
|
||||
const fs = require("fs")
|
||||
const path = require("path")
|
||||
const readline = require("readline")
|
||||
|
||||
const root = process.cwd()
|
||||
const oldDirs = ["app", "components", "hooks", "constants", "scripts"]
|
||||
const exampleDir = "app-example"
|
||||
const newAppDir = "app"
|
||||
const exampleDirPath = path.join(root, exampleDir)
|
||||
|
||||
const indexContent = `import { Text, View } from "react-native";
|
||||
|
||||
export default function Index() {
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
flex: 1,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<Text>Edit app/index.tsx to edit this screen.</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
`
|
||||
|
||||
const layoutContent = `import { Stack } from "expo-router";
|
||||
|
||||
export default function RootLayout() {
|
||||
return <Stack />;
|
||||
}
|
||||
`
|
||||
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
})
|
||||
|
||||
const moveDirectories = async (userInput) => {
|
||||
try {
|
||||
if (userInput === "y") {
|
||||
// Create the app-example directory
|
||||
await fs.promises.mkdir(exampleDirPath, { recursive: true })
|
||||
console.log(`📁 /${exampleDir} directory created.`)
|
||||
}
|
||||
|
||||
// Move old directories to new app-example directory or delete them
|
||||
for (const dir of oldDirs) {
|
||||
const oldDirPath = path.join(root, dir)
|
||||
if (fs.existsSync(oldDirPath)) {
|
||||
if (userInput === "y") {
|
||||
const newDirPath = path.join(root, exampleDir, dir)
|
||||
await fs.promises.rename(oldDirPath, newDirPath)
|
||||
console.log(`➡️ /${dir} moved to /${exampleDir}/${dir}.`)
|
||||
} else {
|
||||
await fs.promises.rm(oldDirPath, { recursive: true, force: true })
|
||||
console.log(`❌ /${dir} deleted.`)
|
||||
}
|
||||
} else {
|
||||
console.log(`➡️ /${dir} does not exist, skipping.`)
|
||||
}
|
||||
}
|
||||
|
||||
// Create new /app directory
|
||||
const newAppDirPath = path.join(root, newAppDir)
|
||||
await fs.promises.mkdir(newAppDirPath, { recursive: true })
|
||||
console.log("\n📁 New /app directory created.")
|
||||
|
||||
// Create index.tsx
|
||||
const indexPath = path.join(newAppDirPath, "index.tsx")
|
||||
await fs.promises.writeFile(indexPath, indexContent)
|
||||
console.log("📄 app/index.tsx created.")
|
||||
|
||||
// Create _layout.tsx
|
||||
const layoutPath = path.join(newAppDirPath, "_layout.tsx")
|
||||
await fs.promises.writeFile(layoutPath, layoutContent)
|
||||
console.log("📄 app/_layout.tsx created.")
|
||||
|
||||
console.log("\n✅ Project reset complete. Next steps:")
|
||||
console.log(
|
||||
`1. Run \`npx expo start\` to start a development server.\n2. Edit app/index.tsx to edit the main screen.${
|
||||
userInput === "y"
|
||||
? `\n3. Delete the /${exampleDir} directory when you're done referencing it.`
|
||||
: ""
|
||||
}`,
|
||||
)
|
||||
} catch (error) {
|
||||
console.error(`❌ Error during script execution: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
rl.question(
|
||||
"Do you want to move existing files to /app-example instead of deleting them? (Y/n): ",
|
||||
(answer) => {
|
||||
const userInput = answer.trim().toLowerCase() || "y"
|
||||
if (userInput === "y" || userInput === "n") {
|
||||
moveDirectories(userInput).finally(() => rl.close())
|
||||
} else {
|
||||
console.log("❌ Invalid input. Please enter 'Y' or 'N'.")
|
||||
rl.close()
|
||||
}
|
||||
},
|
||||
)
|
||||
16
apps/freya-client/scripts/run-dev-server.sh
Executable file
@@ -0,0 +1,16 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
PROXY_PORT=8080
|
||||
METRO_PORT=8081
|
||||
TS_IP=$(tailscale ip -4)
|
||||
|
||||
# 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
|
||||
|
||||
echo "Expo proxy: http://${TS_IP}:${PROXY_PORT}"
|
||||
EXPO_PACKAGER_PROXY_URL=http://${TS_IP}:$PROXY_PORT bunx expo start --localhost -p $METRO_PORT
|
||||
6
apps/freya-client/src/api/auth-middleware.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { ApiRequestMiddleware } from "./client"
|
||||
|
||||
export const authMiddleware: ApiRequestMiddleware = (_url, init) => {
|
||||
// TODO: placeholder auth middleware
|
||||
return init
|
||||
}
|
||||
39
apps/freya-client/src/api/client.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { createContext, useContext } from "react"
|
||||
|
||||
export type ApiRequestMiddleware = (
|
||||
url: Parameters<typeof fetch>[0],
|
||||
init: RequestInit,
|
||||
) => RequestInit
|
||||
|
||||
export class ApiClient {
|
||||
private readonly baseUrl: string
|
||||
private readonly middlewares: readonly ApiRequestMiddleware[]
|
||||
|
||||
static noop = new ApiClient({ baseUrl: "" })
|
||||
|
||||
constructor({
|
||||
baseUrl,
|
||||
middlewares = [],
|
||||
}: {
|
||||
baseUrl: string
|
||||
middlewares?: ApiRequestMiddleware[]
|
||||
}) {
|
||||
this.baseUrl = baseUrl
|
||||
this.middlewares = middlewares
|
||||
}
|
||||
|
||||
async request<T>(...[url, init = {}]: Parameters<typeof fetch>): Promise<[Response, T]> {
|
||||
const finalInit = this.middlewares.reduce(
|
||||
(prevInit, middleware) => middleware(url, prevInit),
|
||||
init,
|
||||
)
|
||||
return fetch(this.baseUrl ? new URL(url.toString(), this.baseUrl) : url, finalInit).then(
|
||||
(res) => Promise.all([Promise.resolve(res), res.json()]),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export const ApiClientContext = createContext(ApiClient.noop)
|
||||
export function useApiClient() {
|
||||
return useContext(ApiClientContext)
|
||||
}
|
||||
65
apps/freya-client/src/app/_layout.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
51
apps/freya-client/src/app/components/[name].tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import { useLocalSearchParams, useNavigation } from "expo-router"
|
||||
import { useEffect } from "react"
|
||||
import { ScrollView, View } from "react-native"
|
||||
import tw from "twrnc"
|
||||
|
||||
import { type Showcase } from "@/components/showcase"
|
||||
import { buttonShowcase } from "@/components/ui/button.showcase"
|
||||
import { feedCardShowcase } from "@/components/ui/feed-card.showcase"
|
||||
import { monospaceTextShowcase } from "@/components/ui/monospace-text.showcase"
|
||||
import { SansSerifText } from "@/components/ui/sans-serif-text"
|
||||
import { sansSerifTextShowcase } from "@/components/ui/sans-serif-text.showcase"
|
||||
import { serifTextShowcase } from "@/components/ui/serif-text.showcase"
|
||||
|
||||
const showcases: Record<string, Showcase> = {
|
||||
button: buttonShowcase,
|
||||
"feed-card": feedCardShowcase,
|
||||
"serif-text": serifTextShowcase,
|
||||
"sans-serif-text": sansSerifTextShowcase,
|
||||
"monospace-text": monospaceTextShowcase,
|
||||
}
|
||||
|
||||
export default function ComponentDetailScreen() {
|
||||
const { name } = useLocalSearchParams<{ name: string }>()
|
||||
const navigation = useNavigation()
|
||||
const showcase = showcases[name]
|
||||
|
||||
useEffect(() => {
|
||||
if (showcase) {
|
||||
navigation.setOptions({ title: showcase.title })
|
||||
}
|
||||
}, [navigation, showcase])
|
||||
|
||||
if (!showcase) {
|
||||
return (
|
||||
<View style={tw`bg-stone-100 dark:bg-stone-900 flex-1 items-center justify-center`}>
|
||||
<SansSerifText>Component not found</SansSerifText>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const ShowcaseComponent = showcase.component
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
style={tw`bg-stone-100 dark:bg-stone-900 flex-1`}
|
||||
contentContainerStyle={tw`px-5 pb-10 pt-4 gap-6`}
|
||||
>
|
||||
<ShowcaseComponent />
|
||||
</ScrollView>
|
||||
)
|
||||
}
|
||||
39
apps/freya-client/src/app/components/index.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import { Link } from "expo-router"
|
||||
import { FlatList, Pressable, View } from "react-native"
|
||||
import tw from "twrnc"
|
||||
|
||||
import { SansSerifText } from "@/components/ui/sans-serif-text"
|
||||
|
||||
const components = [
|
||||
{ name: "button", label: "Button" },
|
||||
{ name: "feed-card", label: "FeedCard" },
|
||||
{ name: "serif-text", label: "SerifText" },
|
||||
{ name: "sans-serif-text", label: "SansSerifText" },
|
||||
{ name: "monospace-text", label: "MonospaceText" },
|
||||
] as const
|
||||
|
||||
export default function ComponentsScreen() {
|
||||
return (
|
||||
<View style={tw`flex-1`}>
|
||||
<View
|
||||
style={tw`mx-4 mt-4 rounded-xl border border-stone-200 dark:border-stone-800 overflow-hidden`}
|
||||
>
|
||||
<FlatList
|
||||
data={components}
|
||||
keyExtractor={(item) => item.name}
|
||||
scrollEnabled={false}
|
||||
ItemSeparatorComponent={() => (
|
||||
<View style={tw`border-b border-stone-200 dark:border-stone-800`} />
|
||||
)}
|
||||
renderItem={({ item }) => (
|
||||
<Link href={`/components/${item.name}`} asChild>
|
||||
<Pressable style={tw`px-4 py-3`}>
|
||||
<SansSerifText style={tw`text-base`}>{item.label}</SansSerifText>
|
||||
</Pressable>
|
||||
</Link>
|
||||
)}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
28
apps/freya-client/src/app/index.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
18
apps/freya-client/src/components/showcase.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { View } from "react-native"
|
||||
import tw from "twrnc"
|
||||
|
||||
import { SansSerifText } from "./ui/sans-serif-text"
|
||||
|
||||
export type Showcase = {
|
||||
title: string
|
||||
component: React.ComponentType
|
||||
}
|
||||
|
||||
export function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<View style={tw`gap-3`}>
|
||||
<SansSerifText style={tw`text-sm text-stone-500 dark:text-stone-400`}>{title}</SansSerifText>
|
||||
{children}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
38
apps/freya-client/src/components/ui/button.showcase.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import { View } from "react-native"
|
||||
import tw from "twrnc"
|
||||
|
||||
import { type Showcase, Section } from "../showcase"
|
||||
import { Button } from "./button"
|
||||
|
||||
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,
|
||||
}
|
||||
47
apps/freya-client/src/components/ui/button.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
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
|
||||
32
apps/freya-client/src/components/ui/feed-card.showcase.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { View } from "react-native"
|
||||
import tw from "twrnc"
|
||||
|
||||
import { type Showcase, Section } from "../showcase"
|
||||
import { Button } from "./button"
|
||||
import { FeedCard } from "./feed-card"
|
||||
import { SansSerifText } from "./sans-serif-text"
|
||||
import { SerifText } from "./serif-text"
|
||||
|
||||
function FeedCardShowcase() {
|
||||
return (
|
||||
<View style={tw`gap-6`}>
|
||||
<Section title="Default">
|
||||
<FeedCard style={tw`p-4`}>
|
||||
<SansSerifText>Card content goes here</SansSerifText>
|
||||
</FeedCard>
|
||||
</Section>
|
||||
<Section title="With mixed content">
|
||||
<FeedCard style={tw`p-4 gap-2`}>
|
||||
<SerifText style={tw`text-xl`}>Title</SerifText>
|
||||
<SansSerifText>Body text inside a feed card.</SansSerifText>
|
||||
<Button style={tw`self-start mt-2`} label="Action" />
|
||||
</FeedCard>
|
||||
</Section>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export const feedCardShowcase: Showcase = {
|
||||
title: "FeedCard",
|
||||
component: FeedCardShowcase,
|
||||
}
|
||||
11
apps/freya-client/src/components/ui/feed-card.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import { View, type ViewProps } from "react-native"
|
||||
import tw from "twrnc"
|
||||
|
||||
export function FeedCard({ style, ...props }: ViewProps) {
|
||||
return (
|
||||
<View
|
||||
style={[tw`border border-stone-200 dark:border-stone-800 rounded-lg`, style]}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { View } from "react-native"
|
||||
import tw from "twrnc"
|
||||
|
||||
import { type Showcase, Section } from "../showcase"
|
||||
import { MonospaceText } from "./monospace-text"
|
||||
|
||||
function MonospaceTextShowcase() {
|
||||
return (
|
||||
<View style={tw`gap-6`}>
|
||||
<Section title="Sizes">
|
||||
<View style={tw`gap-2`}>
|
||||
<MonospaceText style={tw`text-sm`}>Small monospace text</MonospaceText>
|
||||
<MonospaceText style={tw`text-base`}>Base monospace text</MonospaceText>
|
||||
<MonospaceText style={tw`text-xl`}>Extra large monospace text</MonospaceText>
|
||||
<MonospaceText style={tw`text-3xl`}>3XL monospace text</MonospaceText>
|
||||
</View>
|
||||
</Section>
|
||||
<Section title="Code-like usage">
|
||||
<View style={tw`bg-stone-200 dark:bg-stone-800 rounded-lg p-3`}>
|
||||
<MonospaceText style={tw`text-sm`}>{"const x = 42;"}</MonospaceText>
|
||||
<MonospaceText style={tw`text-sm`}>{"console.log(x);"}</MonospaceText>
|
||||
</View>
|
||||
</Section>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export const monospaceTextShowcase: Showcase = {
|
||||
title: "MonospaceText",
|
||||
component: MonospaceTextShowcase,
|
||||
}
|
||||
13
apps/freya-client/src/components/ui/monospace-text.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { Text, type TextProps } from "react-native"
|
||||
import tw from "twrnc"
|
||||
|
||||
export function MonospaceText({ children, style, ...props }: TextProps) {
|
||||
return (
|
||||
<Text
|
||||
style={[tw`text-stone-800 dark:text-stone-200`, { fontFamily: "Menlo" }, style]}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { View } from "react-native"
|
||||
import tw from "twrnc"
|
||||
|
||||
import { type Showcase, Section } from "../showcase"
|
||||
import { SansSerifText } from "./sans-serif-text"
|
||||
|
||||
function SansSerifTextShowcase() {
|
||||
return (
|
||||
<View style={tw`gap-6`}>
|
||||
<Section title="Sizes">
|
||||
<View style={tw`gap-2`}>
|
||||
<SansSerifText style={tw`text-sm`}>Small sans-serif text</SansSerifText>
|
||||
<SansSerifText style={tw`text-base`}>Base sans-serif text</SansSerifText>
|
||||
<SansSerifText style={tw`text-xl`}>Extra large sans-serif text</SansSerifText>
|
||||
<SansSerifText style={tw`text-3xl`}>3XL sans-serif text</SansSerifText>
|
||||
</View>
|
||||
</Section>
|
||||
<Section title="Weights">
|
||||
<View style={tw`gap-2`}>
|
||||
<SansSerifText style={tw`font-light`}>Light weight</SansSerifText>
|
||||
<SansSerifText style={tw`font-normal`}>Normal weight</SansSerifText>
|
||||
<SansSerifText style={tw`font-medium`}>Medium weight</SansSerifText>
|
||||
<SansSerifText style={tw`font-semibold`}>Semibold weight</SansSerifText>
|
||||
<SansSerifText style={tw`font-bold`}>Bold weight</SansSerifText>
|
||||
</View>
|
||||
</Section>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export const sansSerifTextShowcase: Showcase = {
|
||||
title: "SansSerifText",
|
||||
component: SansSerifTextShowcase,
|
||||
}
|
||||
13
apps/freya-client/src/components/ui/sans-serif-text.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { Text, type TextProps } from "react-native"
|
||||
import tw from "twrnc"
|
||||
|
||||
export function SansSerifText({ children, style, ...props }: TextProps) {
|
||||
return (
|
||||
<Text
|
||||
style={[tw`text-stone-800 dark:text-stone-200`, { fontFamily: "Inter" }, style]}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
25
apps/freya-client/src/components/ui/serif-text.showcase.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { View } from "react-native"
|
||||
import tw from "twrnc"
|
||||
|
||||
import { type Showcase, Section } from "../showcase"
|
||||
import { SerifText } from "./serif-text"
|
||||
|
||||
function SerifTextShowcase() {
|
||||
return (
|
||||
<View style={tw`gap-6`}>
|
||||
<Section title="Sizes">
|
||||
<View style={tw`gap-2`}>
|
||||
<SerifText style={tw`text-sm`}>Small serif text</SerifText>
|
||||
<SerifText style={tw`text-base`}>Base serif text</SerifText>
|
||||
<SerifText style={tw`text-xl`}>Extra large serif text</SerifText>
|
||||
<SerifText style={tw`text-3xl`}>3XL serif text</SerifText>
|
||||
</View>
|
||||
</Section>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export const serifTextShowcase: Showcase = {
|
||||
title: "SerifText",
|
||||
component: SerifTextShowcase,
|
||||
}
|
||||
13
apps/freya-client/src/components/ui/serif-text.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { Text, type TextProps } from "react-native"
|
||||
import tw from "twrnc"
|
||||
|
||||
export function SerifText({ children, style, ...props }: TextProps) {
|
||||
return (
|
||||
<Text
|
||||
style={[tw`text-stone-800 dark:text-stone-200`, { fontFamily: "Source Serif 4" }, style]}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
13
apps/freya-client/src/feed/queries.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { queryOptions } from "@tanstack/react-query"
|
||||
|
||||
import { useApiClient } from "@/api/client"
|
||||
|
||||
import { FeedItem } from "./types"
|
||||
|
||||
export function useFeedQuery() {
|
||||
const api = useApiClient()
|
||||
return queryOptions({
|
||||
queryKey: ["feed"],
|
||||
queryFn: async () => api.request<{ items: FeedItem[] }>("/feed?render=json-render"),
|
||||
})
|
||||
}
|
||||
5
apps/freya-client/src/feed/types.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { Spec } from "@json-render/core"
|
||||
|
||||
export interface FeedItem {
|
||||
ui: Spec
|
||||
}
|
||||
69
apps/freya-client/src/json-render/catalog.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { defineCatalog } from "@json-render/core"
|
||||
import { schema } from "@json-render/react-native/schema"
|
||||
import { z } from "zod"
|
||||
|
||||
export const catalog = defineCatalog(schema, {
|
||||
components: {
|
||||
View: {
|
||||
props: z.object({
|
||||
style: z.string().nullable(),
|
||||
}),
|
||||
slots: ["default"],
|
||||
description:
|
||||
"Generic layout container. The style prop accepts a twrnc class string (e.g. 'flex-row gap-2 p-4 items-center').",
|
||||
example: { style: "flex-row gap-2 p-4" },
|
||||
},
|
||||
Button: {
|
||||
props: z.object({
|
||||
label: z.string(),
|
||||
leadingIcon: z.string().nullable(),
|
||||
trailingIcon: z.string().nullable(),
|
||||
}),
|
||||
events: ["press"],
|
||||
slots: [],
|
||||
description:
|
||||
"Pressable button with a label and optional Feather icons. Icon values are Feather icon names (e.g. 'plus', 'arrow-right'). Bind on.press to trigger an action.",
|
||||
example: { label: "Add item", leadingIcon: "plus", trailingIcon: null },
|
||||
},
|
||||
FeedCard: {
|
||||
props: z.object({
|
||||
style: z.string().nullable(),
|
||||
}),
|
||||
slots: ["default"],
|
||||
description:
|
||||
"Bordered card container for feed content. The style prop accepts a twrnc class string.",
|
||||
example: { style: "p-4 gap-2" },
|
||||
},
|
||||
SansSerifText: {
|
||||
props: z.object({
|
||||
text: z.string(),
|
||||
style: z.string().nullable(),
|
||||
}),
|
||||
slots: [],
|
||||
description:
|
||||
"Sans-serif text (Inter font). The style prop accepts a twrnc class string for size, weight, color, etc.",
|
||||
example: { text: "Hello world", style: "text-base font-medium" },
|
||||
},
|
||||
SerifText: {
|
||||
props: z.object({
|
||||
text: z.string(),
|
||||
style: z.string().nullable(),
|
||||
}),
|
||||
slots: [],
|
||||
description:
|
||||
"Serif text (Source Serif 4 font). The style prop accepts a twrnc class string for size, color, etc.",
|
||||
example: { text: "Heading", style: "text-xl" },
|
||||
},
|
||||
MonospaceText: {
|
||||
props: z.object({
|
||||
text: z.string(),
|
||||
style: z.string().nullable(),
|
||||
}),
|
||||
slots: [],
|
||||
description:
|
||||
"Monospace text (Menlo font). The style prop accepts a twrnc class string for size, color, etc.",
|
||||
example: { text: "const x = 42", style: "text-sm" },
|
||||
},
|
||||
},
|
||||
actions: {},
|
||||
})
|
||||
2
apps/freya-client/src/json-render/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { catalog } from "./catalog"
|
||||
export { registry } from "./registry"
|
||||
51
apps/freya-client/src/json-render/registry.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import { defineRegistry } from "@json-render/react-native"
|
||||
import { View } from "react-native"
|
||||
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"
|
||||
|
||||
import { catalog } from "./catalog"
|
||||
|
||||
type ButtonIconName = React.ComponentProps<typeof Button.Icon>["name"]
|
||||
|
||||
export const { registry } = defineRegistry(catalog, {
|
||||
components: {
|
||||
View: ({ props, children }) => (
|
||||
<View style={props.style ? tw`${props.style}` : undefined}>{children}</View>
|
||||
),
|
||||
Button: ({ props, emit }) => (
|
||||
<Button
|
||||
label={props.label}
|
||||
leadingIcon={
|
||||
props.leadingIcon ? <Button.Icon name={props.leadingIcon as ButtonIconName} /> : undefined
|
||||
}
|
||||
trailingIcon={
|
||||
props.trailingIcon ? (
|
||||
<Button.Icon name={props.trailingIcon as ButtonIconName} />
|
||||
) : undefined
|
||||
}
|
||||
onPress={() => emit("press")}
|
||||
/>
|
||||
),
|
||||
FeedCard: ({ props, children }) => (
|
||||
<FeedCard style={props.style ? tw`${props.style}` : undefined}>{children}</FeedCard>
|
||||
),
|
||||
SansSerifText: ({ props }) => (
|
||||
<SansSerifText style={props.style ? tw`${props.style}` : undefined}>
|
||||
{props.text}
|
||||
</SansSerifText>
|
||||
),
|
||||
SerifText: ({ props }) => (
|
||||
<SerifText style={props.style ? tw`${props.style}` : undefined}>{props.text}</SerifText>
|
||||
),
|
||||
MonospaceText: ({ props }) => (
|
||||
<MonospaceText style={props.style ? tw`${props.style}` : undefined}>
|
||||
{props.text}
|
||||
</MonospaceText>
|
||||
),
|
||||
},
|
||||
})
|
||||
11
apps/freya-client/tsconfig.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"extends": "expo/tsconfig.base",
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"paths": {
|
||||
"@/*": ["./src/*"],
|
||||
"@assets/*": ["./assets/*"]
|
||||
}
|
||||
},
|
||||
"include": ["**/*.ts", "**/*.tsx", ".expo/types/**/*.ts", "expo-env.d.ts"]
|
||||
}
|
||||