mirror of
https://github.com/kennethnym/aris.git
synced 2026-03-20 17:11:17 +00:00
Compare commits
10 Commits
feat/dev-a
...
ec083c3c77
| Author | SHA1 | Date | |
|---|---|---|---|
| ec083c3c77 | |||
| 45fa539d3e | |||
| b4ad910a14 | |||
| d3452dd452 | |||
| c78ad25f0d | |||
| e07157eba0 | |||
| 3036f4ad3f | |||
| f2c991eebb | |||
| 805e4f2bc6 | |||
| 34ead53e1d |
@@ -61,7 +61,7 @@ export async function getSessionFromHeaders(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Test-only middleware that injects a fake user and session.
|
* Dev/test middleware that injects a fake user and session.
|
||||||
* Pass userId to simulate an authenticated request, or omit to get 401.
|
* Pass userId to simulate an authenticated request, or omit to get 401.
|
||||||
*/
|
*/
|
||||||
export function mockAuthSessionMiddleware(userId?: string): AuthSessionMiddleware {
|
export function mockAuthSessionMiddleware(userId?: string): AuthSessionMiddleware {
|
||||||
@@ -69,8 +69,34 @@ export function mockAuthSessionMiddleware(userId?: string): AuthSessionMiddlewar
|
|||||||
if (!userId) {
|
if (!userId) {
|
||||||
return c.json({ error: "Unauthorized" }, 401)
|
return c.json({ error: "Unauthorized" }, 401)
|
||||||
}
|
}
|
||||||
c.set("user", { id: userId } as AuthUser)
|
|
||||||
c.set("session", { id: "mock-session" } as AuthSession)
|
const now = new Date()
|
||||||
|
const expiresAt = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000)
|
||||||
|
|
||||||
|
const user: AuthUser = {
|
||||||
|
id: "k7Gx2mPqRvNwYs9TdLfA4bHcJeUo1iZn",
|
||||||
|
name: "Dev User",
|
||||||
|
email: "dev@aelis.local",
|
||||||
|
emailVerified: true,
|
||||||
|
image: null,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
}
|
||||||
|
|
||||||
|
const session: AuthSession = {
|
||||||
|
id: "Wt3FvBpXaQrMhD8sKjE6LcYn0gUz5iRo",
|
||||||
|
userId: "k7Gx2mPqRvNwYs9TdLfA4bHcJeUo1iZn",
|
||||||
|
token: "Vb9CxNfRm2KwQs7TjPeA5dLhYg0UoZi4",
|
||||||
|
expiresAt,
|
||||||
|
ipAddress: "127.0.0.1",
|
||||||
|
userAgent: "aelis-dev",
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
}
|
||||||
|
|
||||||
|
c.set("user", user)
|
||||||
|
c.set("session", session)
|
||||||
|
|
||||||
await next()
|
await next()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
313
apps/aelis-backend/src/engine/http.test.ts
Normal file
313
apps/aelis-backend/src/engine/http.test.ts
Normal file
@@ -0,0 +1,313 @@
|
|||||||
|
import type { ActionDefinition, ContextEntry, FeedItem, FeedSource } from "@aelis/core"
|
||||||
|
|
||||||
|
import { contextKey } from "@aelis/core"
|
||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
import { Hono } from "hono"
|
||||||
|
|
||||||
|
import { mockAuthSessionMiddleware } from "../auth/session-middleware.ts"
|
||||||
|
import { UserSessionManager } from "../session/index.ts"
|
||||||
|
import { registerFeedHttpHandlers } from "./http.ts"
|
||||||
|
|
||||||
|
interface FeedResponse {
|
||||||
|
items: Array<{
|
||||||
|
id: string
|
||||||
|
type: string
|
||||||
|
priority: number
|
||||||
|
timestamp: string
|
||||||
|
data: Record<string, unknown>
|
||||||
|
}>
|
||||||
|
errors: Array<{ sourceId: string; error: string }>
|
||||||
|
}
|
||||||
|
|
||||||
|
function createStubSource(
|
||||||
|
id: string,
|
||||||
|
items: FeedItem[] = [],
|
||||||
|
contextEntries: readonly ContextEntry[] | null = null,
|
||||||
|
): FeedSource {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
async listActions(): Promise<Record<string, ActionDefinition>> {
|
||||||
|
return {}
|
||||||
|
},
|
||||||
|
async executeAction(): Promise<unknown> {
|
||||||
|
return undefined
|
||||||
|
},
|
||||||
|
async fetchContext(): Promise<readonly ContextEntry[] | null> {
|
||||||
|
return contextEntries
|
||||||
|
},
|
||||||
|
async fetchItems() {
|
||||||
|
return items
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildTestApp(sessionManager: UserSessionManager, userId?: string) {
|
||||||
|
const app = new Hono()
|
||||||
|
registerFeedHttpHandlers(app, {
|
||||||
|
sessionManager,
|
||||||
|
authSessionMiddleware: mockAuthSessionMiddleware(userId),
|
||||||
|
})
|
||||||
|
return app
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("GET /api/feed", () => {
|
||||||
|
test("returns 401 without auth", async () => {
|
||||||
|
const manager = new UserSessionManager({ providers: [] })
|
||||||
|
const app = buildTestApp(manager)
|
||||||
|
|
||||||
|
const res = await app.request("/api/feed")
|
||||||
|
|
||||||
|
expect(res.status).toBe(401)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("returns cached feed when available", async () => {
|
||||||
|
const items: FeedItem[] = [
|
||||||
|
{
|
||||||
|
id: "item-1",
|
||||||
|
type: "test",
|
||||||
|
priority: 0.8,
|
||||||
|
timestamp: new Date("2025-01-01T00:00:00.000Z"),
|
||||||
|
data: { value: 42 },
|
||||||
|
},
|
||||||
|
]
|
||||||
|
const manager = new UserSessionManager({
|
||||||
|
providers: [() => createStubSource("test", items)],
|
||||||
|
})
|
||||||
|
const app = buildTestApp(manager, "user-1")
|
||||||
|
|
||||||
|
// Prime the cache
|
||||||
|
const session = manager.getOrCreate("user-1")
|
||||||
|
await session.engine.refresh()
|
||||||
|
expect(session.engine.lastFeed()).not.toBeNull()
|
||||||
|
|
||||||
|
const res = await app.request("/api/feed")
|
||||||
|
|
||||||
|
expect(res.status).toBe(200)
|
||||||
|
const body = (await res.json()) as FeedResponse
|
||||||
|
expect(body.items).toHaveLength(1)
|
||||||
|
expect(body.items[0]!.id).toBe("item-1")
|
||||||
|
expect(body.items[0]!.type).toBe("test")
|
||||||
|
expect(body.items[0]!.priority).toBe(0.8)
|
||||||
|
expect(body.items[0]!.timestamp).toBe("2025-01-01T00:00:00.000Z")
|
||||||
|
expect(body.errors).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("forces refresh when no cached feed", async () => {
|
||||||
|
const items: FeedItem[] = [
|
||||||
|
{
|
||||||
|
id: "fresh-1",
|
||||||
|
type: "test",
|
||||||
|
priority: 0.5,
|
||||||
|
timestamp: new Date("2025-06-01T12:00:00.000Z"),
|
||||||
|
data: { fresh: true },
|
||||||
|
},
|
||||||
|
]
|
||||||
|
const manager = new UserSessionManager({
|
||||||
|
providers: [() => createStubSource("test", items)],
|
||||||
|
})
|
||||||
|
const app = buildTestApp(manager, "user-1")
|
||||||
|
|
||||||
|
// No prior refresh — lastFeed() returns null, handler should call refresh()
|
||||||
|
const res = await app.request("/api/feed")
|
||||||
|
|
||||||
|
expect(res.status).toBe(200)
|
||||||
|
const body = (await res.json()) as FeedResponse
|
||||||
|
expect(body.items).toHaveLength(1)
|
||||||
|
expect(body.items[0]!.id).toBe("fresh-1")
|
||||||
|
expect(body.items[0]!.data.fresh).toBe(true)
|
||||||
|
expect(body.errors).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("serializes source errors as message strings", async () => {
|
||||||
|
const failingSource: FeedSource = {
|
||||||
|
id: "failing",
|
||||||
|
async listActions() {
|
||||||
|
return {}
|
||||||
|
},
|
||||||
|
async executeAction() {
|
||||||
|
return undefined
|
||||||
|
},
|
||||||
|
async fetchContext() {
|
||||||
|
return null
|
||||||
|
},
|
||||||
|
async fetchItems() {
|
||||||
|
throw new Error("connection timeout")
|
||||||
|
},
|
||||||
|
}
|
||||||
|
const manager = new UserSessionManager({ providers: [() => failingSource] })
|
||||||
|
const app = buildTestApp(manager, "user-1")
|
||||||
|
|
||||||
|
const res = await app.request("/api/feed")
|
||||||
|
|
||||||
|
expect(res.status).toBe(200)
|
||||||
|
const body = (await res.json()) as FeedResponse
|
||||||
|
expect(body.items).toHaveLength(0)
|
||||||
|
expect(body.errors).toHaveLength(1)
|
||||||
|
expect(body.errors[0]!.sourceId).toBe("failing")
|
||||||
|
expect(body.errors[0]!.error).toBe("connection timeout")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("GET /api/context", () => {
|
||||||
|
const weatherKey = contextKey("aelis.weather", "weather")
|
||||||
|
const weatherData = { temperature: 20, condition: "Clear" }
|
||||||
|
const contextEntries: readonly ContextEntry[] = [[weatherKey, weatherData]]
|
||||||
|
|
||||||
|
// The mock auth middleware always injects this hardcoded user ID
|
||||||
|
const mockUserId = "k7Gx2mPqRvNwYs9TdLfA4bHcJeUo1iZn"
|
||||||
|
|
||||||
|
function buildContextApp(userId?: string) {
|
||||||
|
const manager = new UserSessionManager({
|
||||||
|
providers: [() => createStubSource("weather", [], contextEntries)],
|
||||||
|
})
|
||||||
|
const app = buildTestApp(manager, userId)
|
||||||
|
const session = manager.getOrCreate(mockUserId)
|
||||||
|
return { app, session }
|
||||||
|
}
|
||||||
|
|
||||||
|
test("returns 401 without auth", async () => {
|
||||||
|
const manager = new UserSessionManager({ providers: [] })
|
||||||
|
const app = buildTestApp(manager)
|
||||||
|
|
||||||
|
const res = await app.request('/api/context?key=["aelis.weather","weather"]')
|
||||||
|
|
||||||
|
expect(res.status).toBe(401)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("returns 400 when key param is missing", async () => {
|
||||||
|
const { app } = buildContextApp("user-1")
|
||||||
|
|
||||||
|
const res = await app.request("/api/context")
|
||||||
|
|
||||||
|
expect(res.status).toBe(400)
|
||||||
|
const body = (await res.json()) as { error: string }
|
||||||
|
expect(body.error).toContain("key")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("returns 400 when key is invalid JSON", async () => {
|
||||||
|
const { app } = buildContextApp("user-1")
|
||||||
|
|
||||||
|
const res = await app.request("/api/context?key=notjson")
|
||||||
|
|
||||||
|
expect(res.status).toBe(400)
|
||||||
|
const body = (await res.json()) as { error: string }
|
||||||
|
expect(body.error).toContain("key")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("returns 400 when key is not an array", async () => {
|
||||||
|
const { app } = buildContextApp("user-1")
|
||||||
|
|
||||||
|
const res = await app.request('/api/context?key="string"')
|
||||||
|
|
||||||
|
expect(res.status).toBe(400)
|
||||||
|
const body = (await res.json()) as { error: string }
|
||||||
|
expect(body.error).toContain("key")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("returns 400 when key contains invalid element types", async () => {
|
||||||
|
const { app } = buildContextApp("user-1")
|
||||||
|
|
||||||
|
const res = await app.request("/api/context?key=[true,null,[1,2]]")
|
||||||
|
|
||||||
|
expect(res.status).toBe(400)
|
||||||
|
const body = (await res.json()) as { error: string }
|
||||||
|
expect(body.error).toContain("key")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("returns 400 when key is an empty array", async () => {
|
||||||
|
const { app } = buildContextApp("user-1")
|
||||||
|
|
||||||
|
const res = await app.request("/api/context?key=[]")
|
||||||
|
|
||||||
|
expect(res.status).toBe(400)
|
||||||
|
const body = (await res.json()) as { error: string }
|
||||||
|
expect(body.error).toContain("key")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("returns 400 when match param is invalid", async () => {
|
||||||
|
const { app } = buildContextApp("user-1")
|
||||||
|
|
||||||
|
const res = await app.request('/api/context?key=["aelis.weather"]&match=invalid')
|
||||||
|
|
||||||
|
expect(res.status).toBe(400)
|
||||||
|
const body = (await res.json()) as { error: string }
|
||||||
|
expect(body.error).toContain("match")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("returns exact match with match=exact", async () => {
|
||||||
|
const { app, session } = buildContextApp("user-1")
|
||||||
|
await session.engine.refresh()
|
||||||
|
|
||||||
|
const res = await app.request('/api/context?key=["aelis.weather","weather"]&match=exact')
|
||||||
|
|
||||||
|
expect(res.status).toBe(200)
|
||||||
|
const body = (await res.json()) as { match: string; value: unknown }
|
||||||
|
expect(body.match).toBe("exact")
|
||||||
|
expect(body.value).toEqual(weatherData)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("returns 404 with match=exact when only prefix would match", async () => {
|
||||||
|
const { app, session } = buildContextApp("user-1")
|
||||||
|
await session.engine.refresh()
|
||||||
|
|
||||||
|
const res = await app.request('/api/context?key=["aelis.weather"]&match=exact')
|
||||||
|
|
||||||
|
expect(res.status).toBe(404)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("returns prefix match with match=prefix", async () => {
|
||||||
|
const { app, session } = buildContextApp("user-1")
|
||||||
|
await session.engine.refresh()
|
||||||
|
|
||||||
|
const res = await app.request('/api/context?key=["aelis.weather"]&match=prefix')
|
||||||
|
|
||||||
|
expect(res.status).toBe(200)
|
||||||
|
const body = (await res.json()) as {
|
||||||
|
match: string
|
||||||
|
entries: Array<{ key: unknown[]; value: unknown }>
|
||||||
|
}
|
||||||
|
expect(body.match).toBe("prefix")
|
||||||
|
expect(body.entries).toHaveLength(1)
|
||||||
|
expect(body.entries[0]!.key).toEqual(["aelis.weather", "weather"])
|
||||||
|
expect(body.entries[0]!.value).toEqual(weatherData)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("default mode returns exact match when available", async () => {
|
||||||
|
const { app, session } = buildContextApp("user-1")
|
||||||
|
await session.engine.refresh()
|
||||||
|
|
||||||
|
const res = await app.request('/api/context?key=["aelis.weather","weather"]')
|
||||||
|
|
||||||
|
expect(res.status).toBe(200)
|
||||||
|
const body = (await res.json()) as { match: string; value: unknown }
|
||||||
|
expect(body.match).toBe("exact")
|
||||||
|
expect(body.value).toEqual(weatherData)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("default mode falls back to prefix when no exact match", async () => {
|
||||||
|
const { app, session } = buildContextApp("user-1")
|
||||||
|
await session.engine.refresh()
|
||||||
|
|
||||||
|
const res = await app.request('/api/context?key=["aelis.weather"]')
|
||||||
|
|
||||||
|
expect(res.status).toBe(200)
|
||||||
|
const body = (await res.json()) as {
|
||||||
|
match: string
|
||||||
|
entries: Array<{ key: unknown[]; value: unknown }>
|
||||||
|
}
|
||||||
|
expect(body.match).toBe("prefix")
|
||||||
|
expect(body.entries).toHaveLength(1)
|
||||||
|
expect(body.entries[0]!.value).toEqual(weatherData)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("returns 404 when neither exact nor prefix matches", async () => {
|
||||||
|
const { app, session } = buildContextApp("user-1")
|
||||||
|
await session.engine.refresh()
|
||||||
|
|
||||||
|
const res = await app.request('/api/context?key=["nonexistent"]')
|
||||||
|
|
||||||
|
expect(res.status).toBe(404)
|
||||||
|
const body = (await res.json()) as { error: string }
|
||||||
|
expect(body.error).toBe("Context key not found")
|
||||||
|
})
|
||||||
|
})
|
||||||
118
apps/aelis-backend/src/engine/http.ts
Normal file
118
apps/aelis-backend/src/engine/http.ts
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
import type { Context, Hono } from "hono"
|
||||||
|
|
||||||
|
import { contextKey } from "@aelis/core"
|
||||||
|
import { createMiddleware } from "hono/factory"
|
||||||
|
|
||||||
|
import type { AuthSessionMiddleware } from "../auth/session-middleware.ts"
|
||||||
|
import type { UserSessionManager } from "../session/index.ts"
|
||||||
|
|
||||||
|
type Env = {
|
||||||
|
Variables: {
|
||||||
|
sessionManager: UserSessionManager
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FeedHttpHandlersDeps {
|
||||||
|
sessionManager: UserSessionManager
|
||||||
|
authSessionMiddleware: AuthSessionMiddleware
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerFeedHttpHandlers(
|
||||||
|
app: Hono,
|
||||||
|
{ sessionManager, authSessionMiddleware }: FeedHttpHandlersDeps,
|
||||||
|
) {
|
||||||
|
const inject = createMiddleware<Env>(async (c, next) => {
|
||||||
|
c.set("sessionManager", sessionManager)
|
||||||
|
await next()
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get("/api/feed", inject, authSessionMiddleware, handleGetFeed)
|
||||||
|
app.get("/api/context", inject, authSessionMiddleware, handleGetContext)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleGetFeed(c: Context<Env>) {
|
||||||
|
const user = c.get("user")!
|
||||||
|
const sessionManager = c.get("sessionManager")
|
||||||
|
const session = sessionManager.getOrCreate(user.id)
|
||||||
|
|
||||||
|
const feed = await session.feed()
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
items: feed.items,
|
||||||
|
errors: feed.errors.map((e) => ({
|
||||||
|
sourceId: e.sourceId,
|
||||||
|
error: e.error.message,
|
||||||
|
})),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleGetContext(c: Context<Env>) {
|
||||||
|
const keyParam = c.req.query("key")
|
||||||
|
if (!keyParam) {
|
||||||
|
return c.json({ error: 'Invalid or missing "key" parameter: must be a JSON array' }, 400)
|
||||||
|
}
|
||||||
|
|
||||||
|
let parsed: unknown
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(keyParam)
|
||||||
|
} catch {
|
||||||
|
return c.json({ error: 'Invalid or missing "key" parameter: must be a JSON array' }, 400)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Array.isArray(parsed) || parsed.length === 0 || !parsed.every(isContextKeyPart)) {
|
||||||
|
return c.json({ error: 'Invalid or missing "key" parameter: must be a JSON array' }, 400)
|
||||||
|
}
|
||||||
|
|
||||||
|
const matchParam = c.req.query("match")
|
||||||
|
if (matchParam !== undefined && matchParam !== "exact" && matchParam !== "prefix") {
|
||||||
|
return c.json({ error: 'Invalid "match" parameter: must be "exact" or "prefix"' }, 400)
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = c.get("user")!
|
||||||
|
const sessionManager = c.get("sessionManager")
|
||||||
|
const session = sessionManager.getOrCreate(user.id)
|
||||||
|
const context = session.engine.currentContext()
|
||||||
|
const key = contextKey(...parsed)
|
||||||
|
|
||||||
|
if (matchParam === "exact") {
|
||||||
|
const value = context.get(key)
|
||||||
|
if (value === undefined) {
|
||||||
|
return c.json({ error: "Context key not found" }, 404)
|
||||||
|
}
|
||||||
|
return c.json({ match: "exact", value })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matchParam === "prefix") {
|
||||||
|
const entries = context.find(key)
|
||||||
|
if (entries.length === 0) {
|
||||||
|
return c.json({ error: "Context key not found" }, 404)
|
||||||
|
}
|
||||||
|
return c.json({ match: "prefix", entries })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default: single find() covers both exact and prefix matches
|
||||||
|
const entries = context.find(key)
|
||||||
|
if (entries.length === 0) {
|
||||||
|
return c.json({ error: "Context key not found" }, 404)
|
||||||
|
}
|
||||||
|
|
||||||
|
// If exactly one result with the same key length, treat as exact match
|
||||||
|
if (entries.length === 1 && entries[0]!.key.length === parsed.length) {
|
||||||
|
return c.json({ match: "exact", value: entries[0]!.value })
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json({ match: "prefix", entries })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Validates that a value is a valid ContextKeyPart (string, number, or plain object of primitives). */
|
||||||
|
function isContextKeyPart(value: unknown): boolean {
|
||||||
|
if (typeof value === "string" || typeof value === "number") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if (typeof value === "object" && value !== null && !Array.isArray(value)) {
|
||||||
|
return Object.values(value).every(
|
||||||
|
(v) => typeof v === "string" || typeof v === "number" || typeof v === "boolean",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -1,144 +0,0 @@
|
|||||||
import type { ActionDefinition, ContextEntry, FeedItem, FeedSource } from "@aelis/core"
|
|
||||||
|
|
||||||
import { describe, expect, test } from "bun:test"
|
|
||||||
import { Hono } from "hono"
|
|
||||||
|
|
||||||
import { mockAuthSessionMiddleware } from "../auth/session-middleware.ts"
|
|
||||||
import { UserSessionManager } from "../session/index.ts"
|
|
||||||
import { registerFeedHttpHandlers } from "./http.ts"
|
|
||||||
|
|
||||||
interface FeedResponse {
|
|
||||||
items: Array<{
|
|
||||||
id: string
|
|
||||||
type: string
|
|
||||||
priority: number
|
|
||||||
timestamp: string
|
|
||||||
data: Record<string, unknown>
|
|
||||||
}>
|
|
||||||
errors: Array<{ sourceId: string; error: string }>
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildTestApp(sessionManager: UserSessionManager, userId?: string) {
|
|
||||||
const app = new Hono()
|
|
||||||
registerFeedHttpHandlers(app, {
|
|
||||||
sessionManager,
|
|
||||||
authSessionMiddleware: mockAuthSessionMiddleware(userId),
|
|
||||||
})
|
|
||||||
return app
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("GET /api/feed", () => {
|
|
||||||
test("returns 401 without auth", async () => {
|
|
||||||
const manager = new UserSessionManager({ providers: [] })
|
|
||||||
const app = buildTestApp(manager)
|
|
||||||
|
|
||||||
const res = await app.request("/api/feed")
|
|
||||||
|
|
||||||
expect(res.status).toBe(401)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("returns cached feed when available", async () => {
|
|
||||||
const items: FeedItem[] = [
|
|
||||||
{
|
|
||||||
id: "item-1",
|
|
||||||
type: "test",
|
|
||||||
priority: 0.8,
|
|
||||||
timestamp: new Date("2025-01-01T00:00:00.000Z"),
|
|
||||||
data: { value: 42 },
|
|
||||||
},
|
|
||||||
]
|
|
||||||
const manager = new UserSessionManager({
|
|
||||||
providers: [() => createStubSource("test", items)],
|
|
||||||
})
|
|
||||||
const app = buildTestApp(manager, "user-1")
|
|
||||||
|
|
||||||
// Prime the cache
|
|
||||||
const session = manager.getOrCreate("user-1")
|
|
||||||
await session.engine.refresh()
|
|
||||||
expect(session.engine.lastFeed()).not.toBeNull()
|
|
||||||
|
|
||||||
const res = await app.request("/api/feed")
|
|
||||||
|
|
||||||
expect(res.status).toBe(200)
|
|
||||||
const body = (await res.json()) as FeedResponse
|
|
||||||
expect(body.items).toHaveLength(1)
|
|
||||||
expect(body.items[0]!.id).toBe("item-1")
|
|
||||||
expect(body.items[0]!.type).toBe("test")
|
|
||||||
expect(body.items[0]!.priority).toBe(0.8)
|
|
||||||
expect(body.items[0]!.timestamp).toBe("2025-01-01T00:00:00.000Z")
|
|
||||||
expect(body.errors).toHaveLength(0)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("forces refresh when no cached feed", async () => {
|
|
||||||
const items: FeedItem[] = [
|
|
||||||
{
|
|
||||||
id: "fresh-1",
|
|
||||||
type: "test",
|
|
||||||
priority: 0.5,
|
|
||||||
timestamp: new Date("2025-06-01T12:00:00.000Z"),
|
|
||||||
data: { fresh: true },
|
|
||||||
},
|
|
||||||
]
|
|
||||||
const manager = new UserSessionManager({
|
|
||||||
providers: [() => createStubSource("test", items)],
|
|
||||||
})
|
|
||||||
const app = buildTestApp(manager, "user-1")
|
|
||||||
|
|
||||||
// No prior refresh — lastFeed() returns null, handler should call refresh()
|
|
||||||
const res = await app.request("/api/feed")
|
|
||||||
|
|
||||||
expect(res.status).toBe(200)
|
|
||||||
const body = (await res.json()) as FeedResponse
|
|
||||||
expect(body.items).toHaveLength(1)
|
|
||||||
expect(body.items[0]!.id).toBe("fresh-1")
|
|
||||||
expect(body.items[0]!.data.fresh).toBe(true)
|
|
||||||
expect(body.errors).toHaveLength(0)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("serializes source errors as message strings", async () => {
|
|
||||||
const failingSource: FeedSource = {
|
|
||||||
id: "failing",
|
|
||||||
async listActions() {
|
|
||||||
return {}
|
|
||||||
},
|
|
||||||
async executeAction() {
|
|
||||||
return undefined
|
|
||||||
},
|
|
||||||
async fetchContext() {
|
|
||||||
return null
|
|
||||||
},
|
|
||||||
async fetchItems() {
|
|
||||||
throw new Error("connection timeout")
|
|
||||||
},
|
|
||||||
}
|
|
||||||
const manager = new UserSessionManager({ providers: [() => failingSource] })
|
|
||||||
const app = buildTestApp(manager, "user-1")
|
|
||||||
|
|
||||||
const res = await app.request("/api/feed")
|
|
||||||
|
|
||||||
expect(res.status).toBe(200)
|
|
||||||
const body = (await res.json()) as FeedResponse
|
|
||||||
expect(body.items).toHaveLength(0)
|
|
||||||
expect(body.errors).toHaveLength(1)
|
|
||||||
expect(body.errors[0]!.sourceId).toBe("failing")
|
|
||||||
expect(body.errors[0]!.error).toBe("connection timeout")
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
import type { Context, Hono } from "hono"
|
|
||||||
|
|
||||||
import { createMiddleware } from "hono/factory"
|
|
||||||
|
|
||||||
import type { AuthSessionMiddleware } from "../auth/session-middleware.ts"
|
|
||||||
import type { UserSessionManager } from "../session/index.ts"
|
|
||||||
|
|
||||||
type Env = {
|
|
||||||
Variables: {
|
|
||||||
sessionManager: UserSessionManager
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
interface FeedHttpHandlersDeps {
|
|
||||||
sessionManager: UserSessionManager
|
|
||||||
authSessionMiddleware: AuthSessionMiddleware
|
|
||||||
}
|
|
||||||
|
|
||||||
export function registerFeedHttpHandlers(
|
|
||||||
app: Hono,
|
|
||||||
{ sessionManager, authSessionMiddleware }: FeedHttpHandlersDeps,
|
|
||||||
) {
|
|
||||||
const inject = createMiddleware<Env>(async (c, next) => {
|
|
||||||
c.set("sessionManager", sessionManager)
|
|
||||||
await next()
|
|
||||||
})
|
|
||||||
|
|
||||||
app.get("/api/feed", inject, authSessionMiddleware, handleGetFeed)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleGetFeed(c: Context<Env>) {
|
|
||||||
const user = c.get("user")!
|
|
||||||
const sessionManager = c.get("sessionManager")
|
|
||||||
const session = sessionManager.getOrCreate(user.id)
|
|
||||||
|
|
||||||
const feed = await session.feed()
|
|
||||||
|
|
||||||
return c.json({
|
|
||||||
items: feed.items,
|
|
||||||
errors: feed.errors.map((e) => ({
|
|
||||||
sourceId: e.sourceId,
|
|
||||||
error: e.error.message,
|
|
||||||
})),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -2,10 +2,10 @@ import { LocationSource } from "@aelis/source-location"
|
|||||||
import { Hono } from "hono"
|
import { Hono } from "hono"
|
||||||
|
|
||||||
import { registerAuthHandlers } from "./auth/http.ts"
|
import { registerAuthHandlers } from "./auth/http.ts"
|
||||||
import { requireSession } from "./auth/session-middleware.ts"
|
import { mockAuthSessionMiddleware, requireSession } from "./auth/session-middleware.ts"
|
||||||
import { createFeedEnhancer } from "./enhancement/enhance-feed.ts"
|
import { createFeedEnhancer } from "./enhancement/enhance-feed.ts"
|
||||||
import { createLlmClient } from "./enhancement/llm-client.ts"
|
import { createLlmClient } from "./enhancement/llm-client.ts"
|
||||||
import { registerFeedHttpHandlers } from "./feed/http.ts"
|
import { registerFeedHttpHandlers } from "./engine/http.ts"
|
||||||
import { registerLocationHttpHandlers } from "./location/http.ts"
|
import { registerLocationHttpHandlers } from "./location/http.ts"
|
||||||
import { UserSessionManager } from "./session/index.ts"
|
import { UserSessionManager } from "./session/index.ts"
|
||||||
import { WeatherSourceProvider } from "./weather/provider.ts"
|
import { WeatherSourceProvider } from "./weather/provider.ts"
|
||||||
@@ -43,10 +43,16 @@ function main() {
|
|||||||
|
|
||||||
app.get("/health", (c) => c.json({ status: "ok" }))
|
app.get("/health", (c) => c.json({ status: "ok" }))
|
||||||
|
|
||||||
|
const isDev = process.env.NODE_ENV !== "production"
|
||||||
|
const authSessionMiddleware = isDev ? mockAuthSessionMiddleware("dev-user") : requireSession
|
||||||
|
|
||||||
|
if (!isDev) {
|
||||||
registerAuthHandlers(app)
|
registerAuthHandlers(app)
|
||||||
|
}
|
||||||
|
|
||||||
registerFeedHttpHandlers(app, {
|
registerFeedHttpHandlers(app, {
|
||||||
sessionManager,
|
sessionManager,
|
||||||
authSessionMiddleware: requireSession,
|
authSessionMiddleware,
|
||||||
})
|
})
|
||||||
registerLocationHttpHandlers(app, { sessionManager })
|
registerLocationHttpHandlers(app, { sessionManager })
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
"@expo-google-fonts/inter": "^0.4.2",
|
"@expo-google-fonts/inter": "^0.4.2",
|
||||||
"@expo-google-fonts/source-serif-4": "^0.4.1",
|
"@expo-google-fonts/source-serif-4": "^0.4.1",
|
||||||
"@expo/vector-icons": "^15.0.3",
|
"@expo/vector-icons": "^15.0.3",
|
||||||
|
"@json-render/react-native": "^0.13.0",
|
||||||
"@react-navigation/bottom-tabs": "^7.4.0",
|
"@react-navigation/bottom-tabs": "^7.4.0",
|
||||||
"@react-navigation/elements": "^2.6.3",
|
"@react-navigation/elements": "^2.6.3",
|
||||||
"@react-navigation/native": "^7.1.8",
|
"@react-navigation/native": "^7.1.8",
|
||||||
@@ -45,7 +46,8 @@
|
|||||||
"react-native-svg": "15.12.1",
|
"react-native-svg": "15.12.1",
|
||||||
"react-native-web": "~0.21.0",
|
"react-native-web": "~0.21.0",
|
||||||
"react-native-worklets": "0.5.1",
|
"react-native-worklets": "0.5.1",
|
||||||
"twrnc": "^4.16.0"
|
"twrnc": "^4.16.0",
|
||||||
|
"zod": "^4.3.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/react": "~19.1.0",
|
"@types/react": "~19.1.0",
|
||||||
|
|||||||
@@ -1,36 +0,0 @@
|
|||||||
import { Tabs } from "expo-router"
|
|
||||||
import React from "react"
|
|
||||||
|
|
||||||
import { HapticTab } from "@/components/haptic-tab"
|
|
||||||
import { IconSymbol } from "@/components/ui/icon-symbol"
|
|
||||||
import { Colors } from "@/constants/theme"
|
|
||||||
import { useColorScheme } from "@/hooks/use-color-scheme"
|
|
||||||
|
|
||||||
export default function TabLayout() {
|
|
||||||
const colorScheme = useColorScheme()
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Tabs
|
|
||||||
screenOptions={{
|
|
||||||
tabBarActiveTintColor: Colors[colorScheme ?? "light"].tint,
|
|
||||||
headerShown: false,
|
|
||||||
tabBarButton: HapticTab,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Tabs.Screen
|
|
||||||
name="index"
|
|
||||||
options={{
|
|
||||||
title: "Home",
|
|
||||||
tabBarIcon: ({ color }) => <IconSymbol size={28} name="house.fill" color={color} />,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Tabs.Screen
|
|
||||||
name="explore"
|
|
||||||
options={{
|
|
||||||
title: "Explore",
|
|
||||||
tabBarIcon: ({ color }) => <IconSymbol size={28} name="paperplane.fill" color={color} />,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Tabs>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,114 +0,0 @@
|
|||||||
import { Image } from "expo-image"
|
|
||||||
import { Platform, StyleSheet } from "react-native"
|
|
||||||
|
|
||||||
import { ExternalLink } from "@/components/external-link"
|
|
||||||
import ParallaxScrollView from "@/components/parallax-scroll-view"
|
|
||||||
import { ThemedText } from "@/components/themed-text"
|
|
||||||
import { ThemedView } from "@/components/themed-view"
|
|
||||||
import { Collapsible } from "@/components/ui/collapsible"
|
|
||||||
import { IconSymbol } from "@/components/ui/icon-symbol"
|
|
||||||
import { Fonts } from "@/constants/theme"
|
|
||||||
|
|
||||||
export default function TabTwoScreen() {
|
|
||||||
return (
|
|
||||||
<ParallaxScrollView
|
|
||||||
headerBackgroundColor={{ light: "#D0D0D0", dark: "#353636" }}
|
|
||||||
headerImage={
|
|
||||||
<IconSymbol
|
|
||||||
size={310}
|
|
||||||
color="#808080"
|
|
||||||
name="chevron.left.forwardslash.chevron.right"
|
|
||||||
style={styles.headerImage}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<ThemedView style={styles.titleContainer}>
|
|
||||||
<ThemedText
|
|
||||||
type="title"
|
|
||||||
style={{
|
|
||||||
fontFamily: Fonts.rounded,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Explore
|
|
||||||
</ThemedText>
|
|
||||||
</ThemedView>
|
|
||||||
<ThemedText>This app includes example code to help you get started.</ThemedText>
|
|
||||||
<Collapsible title="File-based routing">
|
|
||||||
<ThemedText>
|
|
||||||
This app has two screens:{" "}
|
|
||||||
<ThemedText type="defaultSemiBold">app/(tabs)/index.tsx</ThemedText> and{" "}
|
|
||||||
<ThemedText type="defaultSemiBold">app/(tabs)/explore.tsx</ThemedText>
|
|
||||||
</ThemedText>
|
|
||||||
<ThemedText>
|
|
||||||
The layout file in <ThemedText type="defaultSemiBold">app/(tabs)/_layout.tsx</ThemedText>{" "}
|
|
||||||
sets up the tab navigator.
|
|
||||||
</ThemedText>
|
|
||||||
<ExternalLink href="https://docs.expo.dev/router/introduction">
|
|
||||||
<ThemedText type="link">Learn more</ThemedText>
|
|
||||||
</ExternalLink>
|
|
||||||
</Collapsible>
|
|
||||||
<Collapsible title="Android, iOS, and web support">
|
|
||||||
<ThemedText>
|
|
||||||
You can open this project on Android, iOS, and the web. To open the web version, press{" "}
|
|
||||||
<ThemedText type="defaultSemiBold">w</ThemedText> in the terminal running this project.
|
|
||||||
</ThemedText>
|
|
||||||
</Collapsible>
|
|
||||||
<Collapsible title="Images">
|
|
||||||
<ThemedText>
|
|
||||||
For static images, you can use the <ThemedText type="defaultSemiBold">@2x</ThemedText> and{" "}
|
|
||||||
<ThemedText type="defaultSemiBold">@3x</ThemedText> suffixes to provide files for
|
|
||||||
different screen densities
|
|
||||||
</ThemedText>
|
|
||||||
<Image
|
|
||||||
source={require("@assets/images/react-logo.png")}
|
|
||||||
style={{ width: 100, height: 100, alignSelf: "center" }}
|
|
||||||
/>
|
|
||||||
<ExternalLink href="https://reactnative.dev/docs/images">
|
|
||||||
<ThemedText type="link">Learn more</ThemedText>
|
|
||||||
</ExternalLink>
|
|
||||||
</Collapsible>
|
|
||||||
<Collapsible title="Light and dark mode components">
|
|
||||||
<ThemedText>
|
|
||||||
This template has light and dark mode support. The{" "}
|
|
||||||
<ThemedText type="defaultSemiBold">useColorScheme()</ThemedText> hook lets you inspect
|
|
||||||
what the user's current color scheme is, and so you can adjust UI colors accordingly.
|
|
||||||
</ThemedText>
|
|
||||||
<ExternalLink href="https://docs.expo.dev/develop/user-interface/color-themes/">
|
|
||||||
<ThemedText type="link">Learn more</ThemedText>
|
|
||||||
</ExternalLink>
|
|
||||||
</Collapsible>
|
|
||||||
<Collapsible title="Animations">
|
|
||||||
<ThemedText>
|
|
||||||
This template includes an example of an animated component. The{" "}
|
|
||||||
<ThemedText type="defaultSemiBold">components/HelloWave.tsx</ThemedText> component uses
|
|
||||||
the powerful{" "}
|
|
||||||
<ThemedText type="defaultSemiBold" style={{ fontFamily: Fonts.mono }}>
|
|
||||||
react-native-reanimated
|
|
||||||
</ThemedText>{" "}
|
|
||||||
library to create a waving hand animation.
|
|
||||||
</ThemedText>
|
|
||||||
{Platform.select({
|
|
||||||
ios: (
|
|
||||||
<ThemedText>
|
|
||||||
The <ThemedText type="defaultSemiBold">components/ParallaxScrollView.tsx</ThemedText>{" "}
|
|
||||||
component provides a parallax effect for the header image.
|
|
||||||
</ThemedText>
|
|
||||||
),
|
|
||||||
})}
|
|
||||||
</Collapsible>
|
|
||||||
</ParallaxScrollView>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
headerImage: {
|
|
||||||
color: "#808080",
|
|
||||||
bottom: -90,
|
|
||||||
left: -35,
|
|
||||||
position: "absolute",
|
|
||||||
},
|
|
||||||
titleContainer: {
|
|
||||||
flexDirection: "row",
|
|
||||||
gap: 8,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
import { Image } from "expo-image"
|
|
||||||
import { Link } from "expo-router"
|
|
||||||
import { Platform, StyleSheet } from "react-native"
|
|
||||||
|
|
||||||
import { HelloWave } from "@/components/hello-wave"
|
|
||||||
import ParallaxScrollView from "@/components/parallax-scroll-view"
|
|
||||||
import { ThemedText } from "@/components/themed-text"
|
|
||||||
import { ThemedView } from "@/components/themed-view"
|
|
||||||
|
|
||||||
export default function HomeScreen() {
|
|
||||||
return (
|
|
||||||
<ParallaxScrollView
|
|
||||||
headerBackgroundColor={{ light: "#A1CEDC", dark: "#1D3D47" }}
|
|
||||||
headerImage={
|
|
||||||
<Image source={require("@assets/images/partial-react-logo.png")} style={styles.reactLogo} />
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<ThemedView style={styles.titleContainer}>
|
|
||||||
<ThemedText type="title">Welcome!</ThemedText>
|
|
||||||
<HelloWave />
|
|
||||||
</ThemedView>
|
|
||||||
<ThemedView style={styles.stepContainer}>
|
|
||||||
<ThemedText type="subtitle">Step 1: Try it</ThemedText>
|
|
||||||
<ThemedText>
|
|
||||||
Edit <ThemedText type="defaultSemiBold">app/(tabs)/index.tsx</ThemedText> to see changes.
|
|
||||||
Press{" "}
|
|
||||||
<ThemedText type="defaultSemiBold">
|
|
||||||
{Platform.select({
|
|
||||||
ios: "cmd + d",
|
|
||||||
android: "cmd + m",
|
|
||||||
web: "F12",
|
|
||||||
})}
|
|
||||||
</ThemedText>{" "}
|
|
||||||
to open developer tools.
|
|
||||||
</ThemedText>
|
|
||||||
</ThemedView>
|
|
||||||
<ThemedView style={styles.stepContainer}>
|
|
||||||
<Link href="/modal">
|
|
||||||
<Link.Trigger>
|
|
||||||
<ThemedText type="subtitle">Step 2: Explore</ThemedText>
|
|
||||||
</Link.Trigger>
|
|
||||||
<Link.Preview />
|
|
||||||
<Link.Menu>
|
|
||||||
<Link.MenuAction title="Action" icon="cube" onPress={() => alert("Action pressed")} />
|
|
||||||
<Link.MenuAction
|
|
||||||
title="Share"
|
|
||||||
icon="square.and.arrow.up"
|
|
||||||
onPress={() => alert("Share pressed")}
|
|
||||||
/>
|
|
||||||
<Link.Menu title="More" icon="ellipsis">
|
|
||||||
<Link.MenuAction
|
|
||||||
title="Delete"
|
|
||||||
icon="trash"
|
|
||||||
destructive
|
|
||||||
onPress={() => alert("Delete pressed")}
|
|
||||||
/>
|
|
||||||
</Link.Menu>
|
|
||||||
</Link.Menu>
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<ThemedText>
|
|
||||||
{`Tap the Explore tab to learn more about what's included in this starter app.`}
|
|
||||||
</ThemedText>
|
|
||||||
</ThemedView>
|
|
||||||
<ThemedView style={styles.stepContainer}>
|
|
||||||
<ThemedText type="subtitle">Step 3: Get a fresh start</ThemedText>
|
|
||||||
<ThemedText>
|
|
||||||
{`When you're ready, run `}
|
|
||||||
<ThemedText type="defaultSemiBold">npm run reset-project</ThemedText> to get a fresh{" "}
|
|
||||||
<ThemedText type="defaultSemiBold">app</ThemedText> directory. This will move the current{" "}
|
|
||||||
<ThemedText type="defaultSemiBold">app</ThemedText> to{" "}
|
|
||||||
<ThemedText type="defaultSemiBold">app-example</ThemedText>.
|
|
||||||
</ThemedText>
|
|
||||||
</ThemedView>
|
|
||||||
</ParallaxScrollView>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
titleContainer: {
|
|
||||||
flexDirection: "row",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: 8,
|
|
||||||
},
|
|
||||||
stepContainer: {
|
|
||||||
gap: 8,
|
|
||||||
marginBottom: 8,
|
|
||||||
},
|
|
||||||
reactLogo: {
|
|
||||||
height: 178,
|
|
||||||
width: 290,
|
|
||||||
bottom: 0,
|
|
||||||
left: 0,
|
|
||||||
position: "absolute",
|
|
||||||
},
|
|
||||||
})
|
|
||||||
@@ -1,23 +1,45 @@
|
|||||||
import { DarkTheme, DefaultTheme, ThemeProvider } from "@react-navigation/native"
|
import "react-native-reanimated"
|
||||||
import { Stack } from "expo-router"
|
import { Stack } from "expo-router"
|
||||||
import { StatusBar } from "expo-status-bar"
|
import { StatusBar } from "expo-status-bar"
|
||||||
import "react-native-reanimated"
|
import { useColorScheme } from "react-native"
|
||||||
import { useColorScheme } from "@/hooks/use-color-scheme"
|
import tw, { useDeviceContext } from "twrnc"
|
||||||
|
|
||||||
export const unstable_settings = {
|
|
||||||
anchor: "(tabs)",
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function RootLayout() {
|
export default function RootLayout() {
|
||||||
|
useDeviceContext(tw)
|
||||||
const colorScheme = useColorScheme()
|
const colorScheme = useColorScheme()
|
||||||
|
const headerBg = colorScheme === "dark" ? "#1c1917" : "#f5f5f4"
|
||||||
|
const headerTint = colorScheme === "dark" ? "#e7e5e4" : "#1c1917"
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ThemeProvider value={colorScheme === "dark" ? DarkTheme : DefaultTheme}>
|
<>
|
||||||
<Stack>
|
<Stack
|
||||||
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
|
screenOptions={{
|
||||||
<Stack.Screen name="modal" options={{ presentation: "modal", title: "Modal" }} />
|
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>
|
</Stack>
|
||||||
<StatusBar style="auto" />
|
<StatusBar style="auto" />
|
||||||
</ThemeProvider>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
48
apps/aelis-client/src/app/components/[name].tsx
Normal file
48
apps/aelis-client/src/app/components/[name].tsx
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { useLocalSearchParams, useNavigation } from "expo-router"
|
||||||
|
import { useEffect } from "react"
|
||||||
|
import { ScrollView, View } from "react-native"
|
||||||
|
import tw from "twrnc"
|
||||||
|
|
||||||
|
import { buttonShowcase } from "@/components/ui/button.showcase"
|
||||||
|
import { feedCardShowcase } from "@/components/ui/feed-card.showcase"
|
||||||
|
import { monospaceTextShowcase } from "@/components/ui/monospace-text.showcase"
|
||||||
|
import { sansSerifTextShowcase } from "@/components/ui/sans-serif-text.showcase"
|
||||||
|
import { serifTextShowcase } from "@/components/ui/serif-text.showcase"
|
||||||
|
import { type Showcase } from "@/components/showcase"
|
||||||
|
import { SansSerifText } from "@/components/ui/sans-serif-text"
|
||||||
|
|
||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
37
apps/aelis-client/src/app/components/index.tsx
Normal file
37
apps/aelis-client/src/app/components/index.tsx
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
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/aelis-client/src/app/index.tsx
Normal file
28
apps/aelis-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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
import { Link } from "expo-router"
|
|
||||||
import { StyleSheet } from "react-native"
|
|
||||||
|
|
||||||
import { ThemedText } from "@/components/themed-text"
|
|
||||||
import { ThemedView } from "@/components/themed-view"
|
|
||||||
|
|
||||||
export default function ModalScreen() {
|
|
||||||
return (
|
|
||||||
<ThemedView style={styles.container}>
|
|
||||||
<ThemedText type="title">This is a modal</ThemedText>
|
|
||||||
<Link href="/" dismissTo style={styles.link}>
|
|
||||||
<ThemedText type="link">Go to home screen</ThemedText>
|
|
||||||
</Link>
|
|
||||||
</ThemedView>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
container: {
|
|
||||||
flex: 1,
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
padding: 20,
|
|
||||||
},
|
|
||||||
link: {
|
|
||||||
marginTop: 15,
|
|
||||||
paddingVertical: 15,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
import { Href, Link } from "expo-router"
|
|
||||||
import { openBrowserAsync, WebBrowserPresentationStyle } from "expo-web-browser"
|
|
||||||
import { type ComponentProps } from "react"
|
|
||||||
|
|
||||||
type Props = Omit<ComponentProps<typeof Link>, "href"> & { href: Href & string }
|
|
||||||
|
|
||||||
export function ExternalLink({ href, ...rest }: Props) {
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
target="_blank"
|
|
||||||
{...rest}
|
|
||||||
href={href}
|
|
||||||
onPress={async (event) => {
|
|
||||||
if (process.env.EXPO_OS !== "web") {
|
|
||||||
// Prevent the default behavior of linking to the default browser on native.
|
|
||||||
event.preventDefault()
|
|
||||||
// Open the link in an in-app browser.
|
|
||||||
await openBrowserAsync(href, {
|
|
||||||
presentationStyle: WebBrowserPresentationStyle.AUTOMATIC,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
import { BottomTabBarButtonProps } from "@react-navigation/bottom-tabs"
|
|
||||||
import { PlatformPressable } from "@react-navigation/elements"
|
|
||||||
import * as Haptics from "expo-haptics"
|
|
||||||
|
|
||||||
export function HapticTab(props: BottomTabBarButtonProps) {
|
|
||||||
return (
|
|
||||||
<PlatformPressable
|
|
||||||
{...props}
|
|
||||||
onPressIn={(ev) => {
|
|
||||||
if (process.env.EXPO_OS === "ios") {
|
|
||||||
// Add a soft haptic feedback when pressing down on the tabs.
|
|
||||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light)
|
|
||||||
}
|
|
||||||
props.onPressIn?.(ev)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
import Animated from "react-native-reanimated"
|
|
||||||
|
|
||||||
export function HelloWave() {
|
|
||||||
return (
|
|
||||||
<Animated.Text
|
|
||||||
style={{
|
|
||||||
fontSize: 28,
|
|
||||||
lineHeight: 32,
|
|
||||||
marginTop: -6,
|
|
||||||
animationName: {
|
|
||||||
"50%": { transform: [{ rotate: "25deg" }] },
|
|
||||||
},
|
|
||||||
animationIterationCount: 4,
|
|
||||||
animationDuration: "300ms",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
👋
|
|
||||||
</Animated.Text>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
import type { PropsWithChildren, ReactElement } from "react"
|
|
||||||
|
|
||||||
import { StyleSheet } from "react-native"
|
|
||||||
import Animated, {
|
|
||||||
interpolate,
|
|
||||||
useAnimatedRef,
|
|
||||||
useAnimatedStyle,
|
|
||||||
useScrollOffset,
|
|
||||||
} from "react-native-reanimated"
|
|
||||||
|
|
||||||
import { ThemedView } from "@/components/themed-view"
|
|
||||||
import { useColorScheme } from "@/hooks/use-color-scheme"
|
|
||||||
import { useThemeColor } from "@/hooks/use-theme-color"
|
|
||||||
|
|
||||||
const HEADER_HEIGHT = 250
|
|
||||||
|
|
||||||
type Props = PropsWithChildren<{
|
|
||||||
headerImage: ReactElement
|
|
||||||
headerBackgroundColor: { dark: string; light: string }
|
|
||||||
}>
|
|
||||||
|
|
||||||
export default function ParallaxScrollView({
|
|
||||||
children,
|
|
||||||
headerImage,
|
|
||||||
headerBackgroundColor,
|
|
||||||
}: Props) {
|
|
||||||
const backgroundColor = useThemeColor({}, "background")
|
|
||||||
const colorScheme = useColorScheme() ?? "light"
|
|
||||||
const scrollRef = useAnimatedRef<Animated.ScrollView>()
|
|
||||||
const scrollOffset = useScrollOffset(scrollRef)
|
|
||||||
const headerAnimatedStyle = useAnimatedStyle(() => {
|
|
||||||
return {
|
|
||||||
transform: [
|
|
||||||
{
|
|
||||||
translateY: interpolate(
|
|
||||||
scrollOffset.value,
|
|
||||||
[-HEADER_HEIGHT, 0, HEADER_HEIGHT],
|
|
||||||
[-HEADER_HEIGHT / 2, 0, HEADER_HEIGHT * 0.75],
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
scale: interpolate(scrollOffset.value, [-HEADER_HEIGHT, 0, HEADER_HEIGHT], [2, 1, 1]),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Animated.ScrollView
|
|
||||||
ref={scrollRef}
|
|
||||||
style={{ backgroundColor, flex: 1 }}
|
|
||||||
scrollEventThrottle={16}
|
|
||||||
>
|
|
||||||
<Animated.View
|
|
||||||
style={[
|
|
||||||
styles.header,
|
|
||||||
{ backgroundColor: headerBackgroundColor[colorScheme] },
|
|
||||||
headerAnimatedStyle,
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
{headerImage}
|
|
||||||
</Animated.View>
|
|
||||||
<ThemedView style={styles.content}>{children}</ThemedView>
|
|
||||||
</Animated.ScrollView>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
container: {
|
|
||||||
flex: 1,
|
|
||||||
},
|
|
||||||
header: {
|
|
||||||
height: HEADER_HEIGHT,
|
|
||||||
overflow: "hidden",
|
|
||||||
},
|
|
||||||
content: {
|
|
||||||
flex: 1,
|
|
||||||
padding: 32,
|
|
||||||
gap: 16,
|
|
||||||
overflow: "hidden",
|
|
||||||
},
|
|
||||||
})
|
|
||||||
18
apps/aelis-client/src/components/showcase.tsx
Normal file
18
apps/aelis-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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
import { StyleSheet, Text, type TextProps } from "react-native"
|
|
||||||
|
|
||||||
import { useThemeColor } from "@/hooks/use-theme-color"
|
|
||||||
|
|
||||||
export type ThemedTextProps = TextProps & {
|
|
||||||
lightColor?: string
|
|
||||||
darkColor?: string
|
|
||||||
type?: "default" | "title" | "defaultSemiBold" | "subtitle" | "link"
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ThemedText({
|
|
||||||
style,
|
|
||||||
lightColor,
|
|
||||||
darkColor,
|
|
||||||
type = "default",
|
|
||||||
...rest
|
|
||||||
}: ThemedTextProps) {
|
|
||||||
const color = useThemeColor({ light: lightColor, dark: darkColor }, "text")
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Text
|
|
||||||
style={[
|
|
||||||
{ color },
|
|
||||||
type === "default" ? styles.default : undefined,
|
|
||||||
type === "title" ? styles.title : undefined,
|
|
||||||
type === "defaultSemiBold" ? styles.defaultSemiBold : undefined,
|
|
||||||
type === "subtitle" ? styles.subtitle : undefined,
|
|
||||||
type === "link" ? styles.link : undefined,
|
|
||||||
style,
|
|
||||||
]}
|
|
||||||
{...rest}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
default: {
|
|
||||||
fontSize: 16,
|
|
||||||
lineHeight: 24,
|
|
||||||
},
|
|
||||||
defaultSemiBold: {
|
|
||||||
fontSize: 16,
|
|
||||||
lineHeight: 24,
|
|
||||||
fontWeight: "600",
|
|
||||||
},
|
|
||||||
title: {
|
|
||||||
fontSize: 32,
|
|
||||||
fontWeight: "bold",
|
|
||||||
lineHeight: 32,
|
|
||||||
},
|
|
||||||
subtitle: {
|
|
||||||
fontSize: 20,
|
|
||||||
fontWeight: "bold",
|
|
||||||
},
|
|
||||||
link: {
|
|
||||||
lineHeight: 30,
|
|
||||||
fontSize: 16,
|
|
||||||
color: "#0a7ea4",
|
|
||||||
},
|
|
||||||
})
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
import { View, type ViewProps } from "react-native"
|
|
||||||
|
|
||||||
import { useThemeColor } from "@/hooks/use-theme-color"
|
|
||||||
|
|
||||||
export type ThemedViewProps = ViewProps & {
|
|
||||||
lightColor?: string
|
|
||||||
darkColor?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ThemedView({ style, lightColor, darkColor, ...otherProps }: ThemedViewProps) {
|
|
||||||
const backgroundColor = useThemeColor({ light: lightColor, dark: darkColor }, "background")
|
|
||||||
|
|
||||||
return <View style={[{ backgroundColor }, style]} {...otherProps} />
|
|
||||||
}
|
|
||||||
42
apps/aelis-client/src/components/ui/button.showcase.tsx
Normal file
42
apps/aelis-client/src/components/ui/button.showcase.tsx
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
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,
|
||||||
|
}
|
||||||
43
apps/aelis-client/src/components/ui/button.tsx
Normal file
43
apps/aelis-client/src/components/ui/button.tsx
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
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
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
import { PropsWithChildren, useState } from "react"
|
|
||||||
import { StyleSheet, TouchableOpacity } from "react-native"
|
|
||||||
|
|
||||||
import { ThemedText } from "@/components/themed-text"
|
|
||||||
import { ThemedView } from "@/components/themed-view"
|
|
||||||
import { IconSymbol } from "@/components/ui/icon-symbol"
|
|
||||||
import { Colors } from "@/constants/theme"
|
|
||||||
import { useColorScheme } from "@/hooks/use-color-scheme"
|
|
||||||
|
|
||||||
export function Collapsible({ children, title }: PropsWithChildren & { title: string }) {
|
|
||||||
const [isOpen, setIsOpen] = useState(false)
|
|
||||||
const theme = useColorScheme() ?? "light"
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ThemedView>
|
|
||||||
<TouchableOpacity
|
|
||||||
style={styles.heading}
|
|
||||||
onPress={() => setIsOpen((value) => !value)}
|
|
||||||
activeOpacity={0.8}
|
|
||||||
>
|
|
||||||
<IconSymbol
|
|
||||||
name="chevron.right"
|
|
||||||
size={18}
|
|
||||||
weight="medium"
|
|
||||||
color={theme === "light" ? Colors.light.icon : Colors.dark.icon}
|
|
||||||
style={{ transform: [{ rotate: isOpen ? "90deg" : "0deg" }] }}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ThemedText type="defaultSemiBold">{title}</ThemedText>
|
|
||||||
</TouchableOpacity>
|
|
||||||
{isOpen && <ThemedView style={styles.content}>{children}</ThemedView>}
|
|
||||||
</ThemedView>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
heading: {
|
|
||||||
flexDirection: "row",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: 6,
|
|
||||||
},
|
|
||||||
content: {
|
|
||||||
marginTop: 6,
|
|
||||||
marginLeft: 24,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
32
apps/aelis-client/src/components/ui/feed-card.showcase.tsx
Normal file
32
apps/aelis-client/src/components/ui/feed-card.showcase.tsx
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { View } from "react-native"
|
||||||
|
import tw from "twrnc"
|
||||||
|
|
||||||
|
import { Button } from "./button"
|
||||||
|
import { FeedCard } from "./feed-card"
|
||||||
|
import { SansSerifText } from "./sans-serif-text"
|
||||||
|
import { SerifText } from "./serif-text"
|
||||||
|
import { type Showcase, Section } from "../showcase"
|
||||||
|
|
||||||
|
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,
|
||||||
|
}
|
||||||
6
apps/aelis-client/src/components/ui/feed-card.tsx
Normal file
6
apps/aelis-client/src/components/ui/feed-card.tsx
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
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} />
|
||||||
|
}
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
import { SymbolView, SymbolViewProps, SymbolWeight } from "expo-symbols"
|
|
||||||
import { StyleProp, ViewStyle } from "react-native"
|
|
||||||
|
|
||||||
export function IconSymbol({
|
|
||||||
name,
|
|
||||||
size = 24,
|
|
||||||
color,
|
|
||||||
style,
|
|
||||||
weight = "regular",
|
|
||||||
}: {
|
|
||||||
name: SymbolViewProps["name"]
|
|
||||||
size?: number
|
|
||||||
color: string
|
|
||||||
style?: StyleProp<ViewStyle>
|
|
||||||
weight?: SymbolWeight
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<SymbolView
|
|
||||||
weight={weight}
|
|
||||||
tintColor={color}
|
|
||||||
resizeMode="scaleAspectFit"
|
|
||||||
name={name}
|
|
||||||
style={[
|
|
||||||
{
|
|
||||||
width: size,
|
|
||||||
height: size,
|
|
||||||
},
|
|
||||||
style,
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
// Fallback for using MaterialIcons on Android and web.
|
|
||||||
|
|
||||||
import MaterialIcons from "@expo/vector-icons/MaterialIcons"
|
|
||||||
import { SymbolWeight, SymbolViewProps } from "expo-symbols"
|
|
||||||
import { ComponentProps } from "react"
|
|
||||||
import { OpaqueColorValue, type StyleProp, type TextStyle } from "react-native"
|
|
||||||
|
|
||||||
type IconMapping = Record<SymbolViewProps["name"], ComponentProps<typeof MaterialIcons>["name"]>
|
|
||||||
type IconSymbolName = keyof typeof MAPPING
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Add your SF Symbols to Material Icons mappings here.
|
|
||||||
* - see Material Icons in the [Icons Directory](https://icons.expo.fyi).
|
|
||||||
* - see SF Symbols in the [SF Symbols](https://developer.apple.com/sf-symbols/) app.
|
|
||||||
*/
|
|
||||||
const MAPPING = {
|
|
||||||
"house.fill": "home",
|
|
||||||
"paperplane.fill": "send",
|
|
||||||
"chevron.left.forwardslash.chevron.right": "code",
|
|
||||||
"chevron.right": "chevron-right",
|
|
||||||
} as IconMapping
|
|
||||||
|
|
||||||
/**
|
|
||||||
* An icon component that uses native SF Symbols on iOS, and Material Icons on Android and web.
|
|
||||||
* This ensures a consistent look across platforms, and optimal resource usage.
|
|
||||||
* Icon `name`s are based on SF Symbols and require manual mapping to Material Icons.
|
|
||||||
*/
|
|
||||||
export function IconSymbol({
|
|
||||||
name,
|
|
||||||
size = 24,
|
|
||||||
color,
|
|
||||||
style,
|
|
||||||
}: {
|
|
||||||
name: IconSymbolName
|
|
||||||
size?: number
|
|
||||||
color: string | OpaqueColorValue
|
|
||||||
style?: StyleProp<TextStyle>
|
|
||||||
weight?: SymbolWeight
|
|
||||||
}) {
|
|
||||||
return <MaterialIcons color={color} size={size} name={MAPPING[name]} style={style} />
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { View } from "react-native"
|
||||||
|
import tw from "twrnc"
|
||||||
|
|
||||||
|
import { MonospaceText } from "./monospace-text"
|
||||||
|
import { type Showcase, Section } from "../showcase"
|
||||||
|
|
||||||
|
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,
|
||||||
|
}
|
||||||
10
apps/aelis-client/src/components/ui/monospace-text.tsx
Normal file
10
apps/aelis-client/src/components/ui/monospace-text.tsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
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 { SansSerifText } from "./sans-serif-text"
|
||||||
|
import { type Showcase, Section } from "../showcase"
|
||||||
|
|
||||||
|
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,
|
||||||
|
}
|
||||||
10
apps/aelis-client/src/components/ui/sans-serif-text.tsx
Normal file
10
apps/aelis-client/src/components/ui/sans-serif-text.tsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
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/aelis-client/src/components/ui/serif-text.showcase.tsx
Normal file
25
apps/aelis-client/src/components/ui/serif-text.showcase.tsx
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { View } from "react-native"
|
||||||
|
import tw from "twrnc"
|
||||||
|
|
||||||
|
import { SerifText } from "./serif-text"
|
||||||
|
import { type Showcase, Section } from "../showcase"
|
||||||
|
|
||||||
|
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,
|
||||||
|
}
|
||||||
10
apps/aelis-client/src/components/ui/serif-text.tsx
Normal file
10
apps/aelis-client/src/components/ui/serif-text.tsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
/**
|
|
||||||
* Below are the colors that are used in the app. The colors are defined in the light and dark mode.
|
|
||||||
* There are many other ways to style your app. For example, [Nativewind](https://www.nativewind.dev/), [unistyles](https://reactnativeunistyles.vercel.app), etc.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Platform } from "react-native"
|
|
||||||
|
|
||||||
const tintColorLight = "#0a7ea4"
|
|
||||||
const tintColorDark = "#fff"
|
|
||||||
|
|
||||||
export const Colors = {
|
|
||||||
light: {
|
|
||||||
text: "#11181C",
|
|
||||||
background: "#fff",
|
|
||||||
tint: tintColorLight,
|
|
||||||
icon: "#687076",
|
|
||||||
tabIconDefault: "#687076",
|
|
||||||
tabIconSelected: tintColorLight,
|
|
||||||
},
|
|
||||||
dark: {
|
|
||||||
text: "#ECEDEE",
|
|
||||||
background: "#151718",
|
|
||||||
tint: tintColorDark,
|
|
||||||
icon: "#9BA1A6",
|
|
||||||
tabIconDefault: "#9BA1A6",
|
|
||||||
tabIconSelected: tintColorDark,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
export const Fonts = Platform.select({
|
|
||||||
ios: {
|
|
||||||
/** iOS `UIFontDescriptorSystemDesignDefault` */
|
|
||||||
sans: "system-ui",
|
|
||||||
/** iOS `UIFontDescriptorSystemDesignSerif` */
|
|
||||||
serif: "ui-serif",
|
|
||||||
/** iOS `UIFontDescriptorSystemDesignRounded` */
|
|
||||||
rounded: "ui-rounded",
|
|
||||||
/** iOS `UIFontDescriptorSystemDesignMonospaced` */
|
|
||||||
mono: "ui-monospace",
|
|
||||||
},
|
|
||||||
default: {
|
|
||||||
sans: "normal",
|
|
||||||
serif: "serif",
|
|
||||||
rounded: "normal",
|
|
||||||
mono: "monospace",
|
|
||||||
},
|
|
||||||
web: {
|
|
||||||
sans: "system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif",
|
|
||||||
serif: "Georgia, 'Times New Roman', serif",
|
|
||||||
rounded: "'SF Pro Rounded', 'Hiragino Maru Gothic ProN', Meiryo, 'MS PGothic', sans-serif",
|
|
||||||
mono: "SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace",
|
|
||||||
},
|
|
||||||
})
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export { useColorScheme } from "react-native"
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
import { useEffect, useState } from "react"
|
|
||||||
import { useColorScheme as useRNColorScheme } from "react-native"
|
|
||||||
|
|
||||||
/**
|
|
||||||
* To support static rendering, this value needs to be re-calculated on the client side for web
|
|
||||||
*/
|
|
||||||
export function useColorScheme() {
|
|
||||||
const [hasHydrated, setHasHydrated] = useState(false)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setHasHydrated(true)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const colorScheme = useRNColorScheme()
|
|
||||||
|
|
||||||
if (hasHydrated) {
|
|
||||||
return colorScheme
|
|
||||||
}
|
|
||||||
|
|
||||||
return "light"
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
/**
|
|
||||||
* Learn more about light and dark modes:
|
|
||||||
* https://docs.expo.dev/guides/color-schemes/
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Colors } from "@/constants/theme"
|
|
||||||
import { useColorScheme } from "@/hooks/use-color-scheme"
|
|
||||||
|
|
||||||
export function useThemeColor(
|
|
||||||
props: { light?: string; dark?: string },
|
|
||||||
colorName: keyof typeof Colors.light & keyof typeof Colors.dark,
|
|
||||||
) {
|
|
||||||
const theme = useColorScheme() ?? "light"
|
|
||||||
const colorFromProps = props[theme]
|
|
||||||
|
|
||||||
if (colorFromProps) {
|
|
||||||
return colorFromProps
|
|
||||||
} else {
|
|
||||||
return Colors[theme][colorName]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
68
apps/aelis-client/src/json-render/catalog.ts
Normal file
68
apps/aelis-client/src/json-render/catalog.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
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/aelis-client/src/json-render/index.ts
Normal file
2
apps/aelis-client/src/json-render/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { catalog } from "./catalog"
|
||||||
|
export { registry } from "./registry"
|
||||||
39
apps/aelis-client/src/json-render/registry.tsx
Normal file
39
apps/aelis-client/src/json-render/registry.tsx
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
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>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
})
|
||||||
34
bun.lock
34
bun.lock
@@ -5,6 +5,8 @@
|
|||||||
"": {
|
"": {
|
||||||
"name": "aelis",
|
"name": "aelis",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@json-render/core": "^0.12.1",
|
||||||
|
"@nym.sh/jrx": "^0.1.0",
|
||||||
"@types/bun": "latest",
|
"@types/bun": "latest",
|
||||||
"oxfmt": "^0.24.0",
|
"oxfmt": "^0.24.0",
|
||||||
"oxlint": "^1.39.0",
|
"oxlint": "^1.39.0",
|
||||||
@@ -40,6 +42,7 @@
|
|||||||
"@expo-google-fonts/inter": "^0.4.2",
|
"@expo-google-fonts/inter": "^0.4.2",
|
||||||
"@expo-google-fonts/source-serif-4": "^0.4.1",
|
"@expo-google-fonts/source-serif-4": "^0.4.1",
|
||||||
"@expo/vector-icons": "^15.0.3",
|
"@expo/vector-icons": "^15.0.3",
|
||||||
|
"@json-render/react-native": "^0.13.0",
|
||||||
"@react-navigation/bottom-tabs": "^7.4.0",
|
"@react-navigation/bottom-tabs": "^7.4.0",
|
||||||
"@react-navigation/elements": "^2.6.3",
|
"@react-navigation/elements": "^2.6.3",
|
||||||
"@react-navigation/native": "^7.1.8",
|
"@react-navigation/native": "^7.1.8",
|
||||||
@@ -68,6 +71,7 @@
|
|||||||
"react-native-web": "~0.21.0",
|
"react-native-web": "~0.21.0",
|
||||||
"react-native-worklets": "0.5.1",
|
"react-native-worklets": "0.5.1",
|
||||||
"twrnc": "^4.16.0",
|
"twrnc": "^4.16.0",
|
||||||
|
"zod": "^4.3.6",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/react": "~19.1.0",
|
"@types/react": "~19.1.0",
|
||||||
@@ -106,12 +110,24 @@
|
|||||||
"vite-tsconfig-paths": "^5.1.4",
|
"vite-tsconfig-paths": "^5.1.4",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
"packages/aelis-components": {
|
||||||
|
"name": "@aelis/components",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@json-render/core": "*",
|
||||||
|
"@nym.sh/jrx": "*",
|
||||||
|
},
|
||||||
|
},
|
||||||
"packages/aelis-core": {
|
"packages/aelis-core": {
|
||||||
"name": "@aelis/core",
|
"name": "@aelis/core",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@standard-schema/spec": "^1.1.0",
|
"@standard-schema/spec": "^1.1.0",
|
||||||
},
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@json-render/core": "*",
|
||||||
|
"@nym.sh/jrx": "*",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
"packages/aelis-data-source-weatherkit": {
|
"packages/aelis-data-source-weatherkit": {
|
||||||
"name": "@aelis/data-source-weatherkit",
|
"name": "@aelis/data-source-weatherkit",
|
||||||
@@ -181,6 +197,8 @@
|
|||||||
|
|
||||||
"@aelis/backend": ["@aelis/backend@workspace:apps/aelis-backend"],
|
"@aelis/backend": ["@aelis/backend@workspace:apps/aelis-backend"],
|
||||||
|
|
||||||
|
"@aelis/components": ["@aelis/components@workspace:packages/aelis-components"],
|
||||||
|
|
||||||
"@aelis/core": ["@aelis/core@workspace:packages/aelis-core"],
|
"@aelis/core": ["@aelis/core@workspace:packages/aelis-core"],
|
||||||
|
|
||||||
"@aelis/data-source-weatherkit": ["@aelis/data-source-weatherkit@workspace:packages/aelis-data-source-weatherkit"],
|
"@aelis/data-source-weatherkit": ["@aelis/data-source-weatherkit@workspace:packages/aelis-data-source-weatherkit"],
|
||||||
@@ -649,6 +667,10 @@
|
|||||||
|
|
||||||
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
|
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
|
||||||
|
|
||||||
|
"@json-render/core": ["@json-render/core@0.12.1", "", { "dependencies": { "zod": "^4.3.6" } }, "sha512-1tV/481GPHmIRd6lXfWcTaIslQusmDg5lzcSBzWLkSXjF9sjjyOQL090in7uHT4tOMWkdmlEJOW5H9C72PsUEQ=="],
|
||||||
|
|
||||||
|
"@json-render/react-native": ["@json-render/react-native@0.13.0", "", { "dependencies": { "@json-render/core": "0.13.0" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-native": ">=0.71.0", "zod": "^4.0.0" } }, "sha512-uUrK28xPb7LuyYsi9cTnvrnXnVBG0OwU5Up35aaXwcWMLrfqxJ7oWfF97HlDvZIckQtm0VEngAXhHMW97qBEkg=="],
|
||||||
|
|
||||||
"@mjackson/node-fetch-server": ["@mjackson/node-fetch-server@0.2.0", "", {}, "sha512-EMlH1e30yzmTpGLQjlFmaDAjyOeZhng1/XCd7DExR8PNAnG/G1tyruZxEoUe11ClnwGhGrtsdnyyUx1frSzjng=="],
|
"@mjackson/node-fetch-server": ["@mjackson/node-fetch-server@0.2.0", "", {}, "sha512-EMlH1e30yzmTpGLQjlFmaDAjyOeZhng1/XCd7DExR8PNAnG/G1tyruZxEoUe11ClnwGhGrtsdnyyUx1frSzjng=="],
|
||||||
|
|
||||||
"@mongodb-js/saslprep": ["@mongodb-js/saslprep@1.4.6", "", { "dependencies": { "sparse-bitfield": "^3.0.3" } }, "sha512-y+x3H1xBZd38n10NZF/rEBlvDOOMQ6LKUTHqr8R9VkJ+mmQOYtJFxIlkkK8fZrtOiL6VixbOBWMbZGBdal3Z1g=="],
|
"@mongodb-js/saslprep": ["@mongodb-js/saslprep@1.4.6", "", { "dependencies": { "sparse-bitfield": "^3.0.3" } }, "sha512-y+x3H1xBZd38n10NZF/rEBlvDOOMQ6LKUTHqr8R9VkJ+mmQOYtJFxIlkkK8fZrtOiL6VixbOBWMbZGBdal3Z1g=="],
|
||||||
@@ -669,6 +691,8 @@
|
|||||||
|
|
||||||
"@nolyfill/is-core-module": ["@nolyfill/is-core-module@1.0.39", "", {}, "sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA=="],
|
"@nolyfill/is-core-module": ["@nolyfill/is-core-module@1.0.39", "", {}, "sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA=="],
|
||||||
|
|
||||||
|
"@nym.sh/jrx": ["@nym.sh/jrx@0.1.0", "", { "peerDependencies": { "@json-render/core": ">=0.10.0" } }, "sha512-mu6fkAP/TI9FuP8A4WMCrcucpUtWF5xBTcETnrjOtvEED9i+7sQKuoOyhJeF6QaSuUkAA/8t3Xx3kYUjcAPFbw=="],
|
||||||
|
|
||||||
"@oclif/core": ["@oclif/core@4.8.4", "", { "dependencies": { "ansi-escapes": "^4.3.2", "ansis": "^3.17.0", "clean-stack": "^3.0.1", "cli-spinners": "^2.9.2", "debug": "^4.4.3", "ejs": "^3.1.10", "get-package-type": "^0.1.0", "indent-string": "^4.0.0", "is-wsl": "^2.2.0", "lilconfig": "^3.1.3", "minimatch": "^10.2.4", "semver": "^7.7.3", "string-width": "^4.2.3", "supports-color": "^8", "tinyglobby": "^0.2.14", "widest-line": "^3.1.0", "wordwrap": "^1.0.0", "wrap-ansi": "^7.0.0" } }, "sha512-UTAqwXJJyRvLBvosL+1uPZYSpr8lEHgUb/EVGbPXo5WZqUIBHfJ0sR2bkBEsrj00/ar4IegKxx4YK0wn2c8SQg=="],
|
"@oclif/core": ["@oclif/core@4.8.4", "", { "dependencies": { "ansi-escapes": "^4.3.2", "ansis": "^3.17.0", "clean-stack": "^3.0.1", "cli-spinners": "^2.9.2", "debug": "^4.4.3", "ejs": "^3.1.10", "get-package-type": "^0.1.0", "indent-string": "^4.0.0", "is-wsl": "^2.2.0", "lilconfig": "^3.1.3", "minimatch": "^10.2.4", "semver": "^7.7.3", "string-width": "^4.2.3", "supports-color": "^8", "tinyglobby": "^0.2.14", "widest-line": "^3.1.0", "wordwrap": "^1.0.0", "wrap-ansi": "^7.0.0" } }, "sha512-UTAqwXJJyRvLBvosL+1uPZYSpr8lEHgUb/EVGbPXo5WZqUIBHfJ0sR2bkBEsrj00/ar4IegKxx4YK0wn2c8SQg=="],
|
||||||
|
|
||||||
"@oclif/plugin-autocomplete": ["@oclif/plugin-autocomplete@3.2.40", "", { "dependencies": { "@oclif/core": "^4", "ansis": "^3.16.0", "debug": "^4.4.1", "ejs": "^3.1.10" } }, "sha512-HCfDuUV3l5F5Wz7SKkaoFb+OMQ5vKul8zvsPNgI0QbZcQuGHmn3svk+392wSfXboyA1gq8kzEmKPAoQK6r6UNw=="],
|
"@oclif/plugin-autocomplete": ["@oclif/plugin-autocomplete@3.2.40", "", { "dependencies": { "@oclif/core": "^4", "ansis": "^3.16.0", "debug": "^4.4.1", "ejs": "^3.1.10" } }, "sha512-HCfDuUV3l5F5Wz7SKkaoFb+OMQ5vKul8zvsPNgI0QbZcQuGHmn3svk+392wSfXboyA1gq8kzEmKPAoQK6r6UNw=="],
|
||||||
@@ -2027,7 +2051,7 @@
|
|||||||
|
|
||||||
"ical.js": ["ical.js@2.2.1", "", {}, "sha512-yK/UlPbEs316igb/tjRgbFA8ZV75rCsBJp/hWOatpyaPNlgw0dGDmU+FoicOcwX4xXkeXOkYiOmCqNPFpNPkQg=="],
|
"ical.js": ["ical.js@2.2.1", "", {}, "sha512-yK/UlPbEs316igb/tjRgbFA8ZV75rCsBJp/hWOatpyaPNlgw0dGDmU+FoicOcwX4xXkeXOkYiOmCqNPFpNPkQg=="],
|
||||||
|
|
||||||
"iconv-lite": ["iconv-lite@0.4.24", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3" } }, "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA=="],
|
"iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="],
|
||||||
|
|
||||||
"ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
|
"ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
|
||||||
|
|
||||||
@@ -3447,6 +3471,8 @@
|
|||||||
|
|
||||||
"@jest/transform/write-file-atomic": ["write-file-atomic@4.0.2", "", { "dependencies": { "imurmurhash": "^0.1.4", "signal-exit": "^3.0.7" } }, "sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg=="],
|
"@jest/transform/write-file-atomic": ["write-file-atomic@4.0.2", "", { "dependencies": { "imurmurhash": "^0.1.4", "signal-exit": "^3.0.7" } }, "sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg=="],
|
||||||
|
|
||||||
|
"@json-render/react-native/@json-render/core": ["@json-render/core@0.13.0", "", { "dependencies": { "zod": "^4.3.6" } }, "sha512-CXmCsc8BHDRq45ScVd+qgvjTbwZHPVpVD05WnTqgDxqfY3LGXu5vxaQRSwYoEodg/DGcZq/4HSj4ipVvrzy3qQ=="],
|
||||||
|
|
||||||
"@mrleebo/prisma-ast/lilconfig": ["lilconfig@2.1.0", "", {}, "sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ=="],
|
"@mrleebo/prisma-ast/lilconfig": ["lilconfig@2.1.0", "", {}, "sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ=="],
|
||||||
|
|
||||||
"@oclif/core/minimatch": ["minimatch@10.2.4", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg=="],
|
"@oclif/core/minimatch": ["minimatch@10.2.4", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg=="],
|
||||||
@@ -3523,6 +3549,8 @@
|
|||||||
|
|
||||||
"body-parser/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
|
"body-parser/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
|
||||||
|
|
||||||
|
"body-parser/iconv-lite": ["iconv-lite@0.4.24", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3" } }, "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA=="],
|
||||||
|
|
||||||
"c12/dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="],
|
"c12/dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="],
|
||||||
|
|
||||||
"c12/jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
|
"c12/jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
|
||||||
@@ -3673,8 +3701,6 @@
|
|||||||
|
|
||||||
"mv/rimraf": ["rimraf@2.4.5", "", { "dependencies": { "glob": "^6.0.1" }, "bin": { "rimraf": "./bin.js" } }, "sha512-J5xnxTyqaiw06JjMftq7L9ouA448dw/E7dKghkP9WpKNuwmARNNg+Gk8/u5ryb9N/Yo2+z3MCwuqFK/+qPOPfQ=="],
|
"mv/rimraf": ["rimraf@2.4.5", "", { "dependencies": { "glob": "^6.0.1" }, "bin": { "rimraf": "./bin.js" } }, "sha512-J5xnxTyqaiw06JjMftq7L9ouA448dw/E7dKghkP9WpKNuwmARNNg+Gk8/u5ryb9N/Yo2+z3MCwuqFK/+qPOPfQ=="],
|
||||||
|
|
||||||
"mysql2/iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="],
|
|
||||||
|
|
||||||
"node-exports-info/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
"node-exports-info/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
||||||
|
|
||||||
"npm-package-arg/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
|
"npm-package-arg/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
|
||||||
@@ -3709,6 +3735,8 @@
|
|||||||
|
|
||||||
"prop-types/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
|
"prop-types/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
|
||||||
|
|
||||||
|
"raw-body/iconv-lite": ["iconv-lite@0.4.24", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3" } }, "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA=="],
|
||||||
|
|
||||||
"rc/strip-json-comments": ["strip-json-comments@2.0.1", "", {}, "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ=="],
|
"rc/strip-json-comments": ["strip-json-comments@2.0.1", "", {}, "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ=="],
|
||||||
|
|
||||||
"react-devtools-core/ws": ["ws@7.5.10", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": "^5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ=="],
|
"react-devtools-core/ws": ["ws@7.5.10", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": "^5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ=="],
|
||||||
|
|||||||
@@ -14,6 +14,8 @@
|
|||||||
"format:check": "oxfmt --check ."
|
"format:check": "oxfmt --check ."
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@json-render/core": "^0.12.1",
|
||||||
|
"@nym.sh/jrx": "^0.1.0",
|
||||||
"@types/bun": "latest",
|
"@types/bun": "latest",
|
||||||
"oxfmt": "^0.24.0",
|
"oxfmt": "^0.24.0",
|
||||||
"oxlint": "^1.39.0"
|
"oxlint": "^1.39.0"
|
||||||
|
|||||||
14
packages/aelis-components/package.json
Normal file
14
packages/aelis-components/package.json
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"name": "@aelis/components",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"main": "src/index.ts",
|
||||||
|
"types": "src/index.ts",
|
||||||
|
"scripts": {
|
||||||
|
"test": "bun test ./src"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@json-render/core": "*",
|
||||||
|
"@nym.sh/jrx": "*"
|
||||||
|
}
|
||||||
|
}
|
||||||
15
packages/aelis-components/src/button.ts
Normal file
15
packages/aelis-components/src/button.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import type { JrxNode } from "@nym.sh/jrx"
|
||||||
|
|
||||||
|
import { jsx } from "@nym.sh/jrx/jsx-runtime"
|
||||||
|
|
||||||
|
export type ButtonProps = {
|
||||||
|
label: string
|
||||||
|
leadingIcon?: string
|
||||||
|
trailingIcon?: string
|
||||||
|
style?: string
|
||||||
|
children?: JrxNode | JrxNode[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Button(props: ButtonProps): JrxNode {
|
||||||
|
return jsx("Button", props)
|
||||||
|
}
|
||||||
155
packages/aelis-components/src/components.test.tsx
Normal file
155
packages/aelis-components/src/components.test.tsx
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
/** @jsxImportSource @nym.sh/jrx */
|
||||||
|
|
||||||
|
import { render } from "@nym.sh/jrx"
|
||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
|
||||||
|
import { Button } from "./button.ts"
|
||||||
|
import { FeedCard } from "./feed-card.ts"
|
||||||
|
import { MonospaceText } from "./monospace-text.ts"
|
||||||
|
import { SansSerifText } from "./sans-serif-text.ts"
|
||||||
|
import { SerifText } from "./serif-text.ts"
|
||||||
|
|
||||||
|
describe("Button", () => {
|
||||||
|
test("renders with label", () => {
|
||||||
|
const spec = render(<Button label="Press me" />)
|
||||||
|
|
||||||
|
expect(spec.root).toStartWith("button-")
|
||||||
|
const root = spec.elements[spec.root]!
|
||||||
|
expect(root.type).toBe("Button")
|
||||||
|
expect(root.props).toEqual({ label: "Press me" })
|
||||||
|
})
|
||||||
|
|
||||||
|
test("renders with icon props", () => {
|
||||||
|
const spec = render(<Button label="Add" leadingIcon="plus" trailingIcon="arrow-right" />)
|
||||||
|
|
||||||
|
const root = spec.elements[spec.root]!
|
||||||
|
expect(root.type).toBe("Button")
|
||||||
|
expect(root.props).toEqual({
|
||||||
|
label: "Add",
|
||||||
|
leadingIcon: "plus",
|
||||||
|
trailingIcon: "arrow-right",
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("passes style as string prop", () => {
|
||||||
|
const spec = render(<Button label="Go" style="px-4 py-2" />)
|
||||||
|
|
||||||
|
const root = spec.elements[spec.root]!
|
||||||
|
expect(root.props.style).toBe("px-4 py-2")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("FeedCard", () => {
|
||||||
|
test("renders as container", () => {
|
||||||
|
const spec = render(<FeedCard />)
|
||||||
|
|
||||||
|
expect(spec.root).toStartWith("feedcard-")
|
||||||
|
const root = spec.elements[spec.root]!
|
||||||
|
expect(root.type).toBe("FeedCard")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("renders with a single child", () => {
|
||||||
|
const spec = render(
|
||||||
|
<FeedCard>
|
||||||
|
<SansSerifText content="Only child" />
|
||||||
|
</FeedCard>,
|
||||||
|
)
|
||||||
|
|
||||||
|
const root = spec.elements[spec.root]!
|
||||||
|
expect(root.children).toHaveLength(1)
|
||||||
|
const child = spec.elements[root.children![0]!]!
|
||||||
|
expect(child.type).toBe("SansSerifText")
|
||||||
|
expect(child.props).toEqual({ content: "Only child" })
|
||||||
|
})
|
||||||
|
|
||||||
|
test("passes style as string prop", () => {
|
||||||
|
const spec = render(<FeedCard style="p-4 border rounded-lg" />)
|
||||||
|
|
||||||
|
const root = spec.elements[spec.root]!
|
||||||
|
expect(root.props.style).toBe("p-4 border rounded-lg")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("SansSerifText", () => {
|
||||||
|
test("renders with content prop", () => {
|
||||||
|
const spec = render(<SansSerifText content="Hello" />)
|
||||||
|
|
||||||
|
expect(spec.root).toStartWith("sansseriftext-")
|
||||||
|
const root = spec.elements[spec.root]!
|
||||||
|
expect(root.type).toBe("SansSerifText")
|
||||||
|
expect(root.props).toEqual({ content: "Hello" })
|
||||||
|
})
|
||||||
|
|
||||||
|
test("passes style as string prop", () => {
|
||||||
|
const spec = render(<SansSerifText content="Hello" style="text-sm text-stone-500" />)
|
||||||
|
|
||||||
|
const root = spec.elements[spec.root]!
|
||||||
|
expect(root.props.style).toBe("text-sm text-stone-500")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("SerifText", () => {
|
||||||
|
test("renders with content prop", () => {
|
||||||
|
const spec = render(<SerifText content="Title" />)
|
||||||
|
|
||||||
|
expect(spec.root).toStartWith("seriftext-")
|
||||||
|
const root = spec.elements[spec.root]!
|
||||||
|
expect(root.type).toBe("SerifText")
|
||||||
|
expect(root.props).toEqual({ content: "Title" })
|
||||||
|
})
|
||||||
|
|
||||||
|
test("passes style as string prop", () => {
|
||||||
|
const spec = render(<SerifText content="Title" style="text-xl" />)
|
||||||
|
|
||||||
|
const root = spec.elements[spec.root]!
|
||||||
|
expect(root.props.style).toBe("text-xl")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("MonospaceText", () => {
|
||||||
|
test("renders with content prop", () => {
|
||||||
|
const spec = render(<MonospaceText content="code()" />)
|
||||||
|
|
||||||
|
expect(spec.root).toStartWith("monospacetext-")
|
||||||
|
const root = spec.elements[spec.root]!
|
||||||
|
expect(root.type).toBe("MonospaceText")
|
||||||
|
expect(root.props).toEqual({ content: "code()" })
|
||||||
|
})
|
||||||
|
|
||||||
|
test("passes style as string prop", () => {
|
||||||
|
const spec = render(<MonospaceText content="code()" style="text-xs" />)
|
||||||
|
|
||||||
|
const root = spec.elements[spec.root]!
|
||||||
|
expect(root.props.style).toBe("text-xs")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("composite", () => {
|
||||||
|
test("FeedCard with nested children", () => {
|
||||||
|
const spec = render(
|
||||||
|
<FeedCard>
|
||||||
|
<SerifText content="Weather" />
|
||||||
|
<SansSerifText content="Sunny, 22C" />
|
||||||
|
<Button label="Details" />
|
||||||
|
</FeedCard>,
|
||||||
|
)
|
||||||
|
|
||||||
|
const root = spec.elements[spec.root]!
|
||||||
|
expect(root.type).toBe("FeedCard")
|
||||||
|
expect(root.children).toHaveLength(3)
|
||||||
|
|
||||||
|
const childKeys = root.children!
|
||||||
|
const child0 = spec.elements[childKeys[0]!]!
|
||||||
|
const child1 = spec.elements[childKeys[1]!]!
|
||||||
|
const child2 = spec.elements[childKeys[2]!]!
|
||||||
|
|
||||||
|
expect(child0.type).toBe("SerifText")
|
||||||
|
expect(child0.props).toEqual({ content: "Weather" })
|
||||||
|
|
||||||
|
expect(child1.type).toBe("SansSerifText")
|
||||||
|
expect(child1.props).toEqual({ content: "Sunny, 22C" })
|
||||||
|
|
||||||
|
expect(child2.type).toBe("Button")
|
||||||
|
expect(child2.props).toEqual({ label: "Details" })
|
||||||
|
})
|
||||||
|
})
|
||||||
12
packages/aelis-components/src/feed-card.ts
Normal file
12
packages/aelis-components/src/feed-card.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import type { JrxNode } from "@nym.sh/jrx"
|
||||||
|
|
||||||
|
import { jsx } from "@nym.sh/jrx/jsx-runtime"
|
||||||
|
|
||||||
|
export type FeedCardProps = {
|
||||||
|
style?: string
|
||||||
|
children?: JrxNode | JrxNode[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FeedCard(props: FeedCardProps): JrxNode {
|
||||||
|
return jsx("FeedCard", props)
|
||||||
|
}
|
||||||
14
packages/aelis-components/src/index.ts
Normal file
14
packages/aelis-components/src/index.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
export type { ButtonProps } from "./button.ts"
|
||||||
|
export { Button } from "./button.ts"
|
||||||
|
|
||||||
|
export type { FeedCardProps } from "./feed-card.ts"
|
||||||
|
export { FeedCard } from "./feed-card.ts"
|
||||||
|
|
||||||
|
export type { SansSerifTextProps } from "./sans-serif-text.ts"
|
||||||
|
export { SansSerifText } from "./sans-serif-text.ts"
|
||||||
|
|
||||||
|
export type { SerifTextProps } from "./serif-text.ts"
|
||||||
|
export { SerifText } from "./serif-text.ts"
|
||||||
|
|
||||||
|
export type { MonospaceTextProps } from "./monospace-text.ts"
|
||||||
|
export { MonospaceText } from "./monospace-text.ts"
|
||||||
13
packages/aelis-components/src/monospace-text.ts
Normal file
13
packages/aelis-components/src/monospace-text.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import type { JrxNode } from "@nym.sh/jrx"
|
||||||
|
|
||||||
|
import { jsx } from "@nym.sh/jrx/jsx-runtime"
|
||||||
|
|
||||||
|
export type MonospaceTextProps = {
|
||||||
|
content?: string
|
||||||
|
style?: string
|
||||||
|
children?: JrxNode | JrxNode[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MonospaceText(props: MonospaceTextProps): JrxNode {
|
||||||
|
return jsx("MonospaceText", props)
|
||||||
|
}
|
||||||
13
packages/aelis-components/src/sans-serif-text.ts
Normal file
13
packages/aelis-components/src/sans-serif-text.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import type { JrxNode } from "@nym.sh/jrx"
|
||||||
|
|
||||||
|
import { jsx } from "@nym.sh/jrx/jsx-runtime"
|
||||||
|
|
||||||
|
export type SansSerifTextProps = {
|
||||||
|
content?: string
|
||||||
|
style?: string
|
||||||
|
children?: JrxNode | JrxNode[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SansSerifText(props: SansSerifTextProps): JrxNode {
|
||||||
|
return jsx("SansSerifText", props)
|
||||||
|
}
|
||||||
13
packages/aelis-components/src/serif-text.ts
Normal file
13
packages/aelis-components/src/serif-text.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import type { JrxNode } from "@nym.sh/jrx"
|
||||||
|
|
||||||
|
import { jsx } from "@nym.sh/jrx/jsx-runtime"
|
||||||
|
|
||||||
|
export type SerifTextProps = {
|
||||||
|
content?: string
|
||||||
|
style?: string
|
||||||
|
children?: JrxNode | JrxNode[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SerifText(props: SerifTextProps): JrxNode {
|
||||||
|
return jsx("SerifText", props)
|
||||||
|
}
|
||||||
7
packages/aelis-components/tsconfig.json
Normal file
7
packages/aelis-components/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"jsxImportSource": "@nym.sh/jrx"
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -7,6 +7,10 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "bun test ."
|
"test": "bun test ."
|
||||||
},
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@nym.sh/jrx": "*",
|
||||||
|
"@json-render/core": "*"
|
||||||
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@standard-schema/spec": "^1.1.0"
|
"@standard-schema/spec": "^1.1.0"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import type { JrxNode } from "@nym.sh/jrx"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Source-provided hints for post-processors.
|
* Source-provided hints for post-processors.
|
||||||
*
|
*
|
||||||
@@ -76,3 +78,18 @@ export interface FeedItem<
|
|||||||
/** Named slots for LLM-fillable content. Keys are slot names. */
|
/** Named slots for LLM-fillable content. Keys are slot names. */
|
||||||
slots?: Record<string, Slot>
|
slots?: Record<string, Slot>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Takes a FeedItem and returns a JRX node tree for rendering. */
|
||||||
|
export type FeedItemRenderer<
|
||||||
|
TType extends string = string,
|
||||||
|
TData extends Record<string, unknown> = Record<string, unknown>,
|
||||||
|
> = (item: FeedItem<TType, TData>) => JrxNode
|
||||||
|
|
||||||
|
/** A FeedItem with a JRX UI tree attached for client-side rendering. */
|
||||||
|
export interface RenderedFeedItem<
|
||||||
|
TType extends string = string,
|
||||||
|
TData extends Record<string, unknown> = Record<string, unknown>,
|
||||||
|
> extends FeedItem<TType, TData> {
|
||||||
|
/** JRX node tree describing how to render this item */
|
||||||
|
ui: JrxNode
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ export type { ActionDefinition } from "./action"
|
|||||||
export { UnknownActionError } from "./action"
|
export { UnknownActionError } from "./action"
|
||||||
|
|
||||||
// Feed
|
// Feed
|
||||||
export type { FeedItem, FeedItemSignals, Slot } from "./feed"
|
export type { FeedItem, FeedItemRenderer, FeedItemSignals, RenderedFeedItem, Slot } from "./feed"
|
||||||
export { TimeRelevance } from "./feed"
|
export { TimeRelevance } from "./feed"
|
||||||
|
|
||||||
// Feed Source
|
// Feed Source
|
||||||
|
|||||||
@@ -534,4 +534,68 @@ describe("computeSignals", () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe("CalDavSource feed item slots", () => {
|
||||||
|
const EXPECTED_SLOT_NAMES = ["insight", "preparation", "crossSource"]
|
||||||
|
|
||||||
|
test("timed event has all three slots with null content", async () => {
|
||||||
|
const objects: Record<string, CalDavDAVObject[]> = {
|
||||||
|
"/cal/work": [{ url: "/cal/work/event1.ics", data: loadFixture("single-event.ics") }],
|
||||||
|
}
|
||||||
|
const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects)
|
||||||
|
const source = createSource(client)
|
||||||
|
|
||||||
|
const items = await source.fetchItems(createContext(new Date("2026-01-15T12:00:00Z")))
|
||||||
|
|
||||||
|
expect(items).toHaveLength(1)
|
||||||
|
const item = items[0]!
|
||||||
|
expect(item.slots).toBeDefined()
|
||||||
|
expect(Object.keys(item.slots!).sort()).toEqual([...EXPECTED_SLOT_NAMES].sort())
|
||||||
|
|
||||||
|
for (const name of EXPECTED_SLOT_NAMES) {
|
||||||
|
const slot = item.slots![name]!
|
||||||
|
expect(slot.content).toBeNull()
|
||||||
|
expect(typeof slot.description).toBe("string")
|
||||||
|
expect(slot.description.length).toBeGreaterThan(0)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("all-day event has all three slots with null content", async () => {
|
||||||
|
const objects: Record<string, CalDavDAVObject[]> = {
|
||||||
|
"/cal/work": [{ url: "/cal/work/allday.ics", data: loadFixture("all-day-event.ics") }],
|
||||||
|
}
|
||||||
|
const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects)
|
||||||
|
const source = createSource(client)
|
||||||
|
|
||||||
|
const items = await source.fetchItems(createContext(new Date("2026-01-15T12:00:00Z")))
|
||||||
|
|
||||||
|
expect(items).toHaveLength(1)
|
||||||
|
const item = items[0]!
|
||||||
|
expect(item.data.isAllDay).toBe(true)
|
||||||
|
expect(item.slots).toBeDefined()
|
||||||
|
expect(Object.keys(item.slots!).sort()).toEqual([...EXPECTED_SLOT_NAMES].sort())
|
||||||
|
|
||||||
|
for (const name of EXPECTED_SLOT_NAMES) {
|
||||||
|
expect(item.slots![name]!.content).toBeNull()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("cancelled event has all three slots with null content", async () => {
|
||||||
|
const objects: Record<string, CalDavDAVObject[]> = {
|
||||||
|
"/cal/work": [{ url: "/cal/work/cancelled.ics", data: loadFixture("cancelled-event.ics") }],
|
||||||
|
}
|
||||||
|
const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects)
|
||||||
|
const source = createSource(client)
|
||||||
|
|
||||||
|
const items = await source.fetchItems(createContext(new Date("2026-01-15T12:00:00Z")))
|
||||||
|
|
||||||
|
expect(items).toHaveLength(1)
|
||||||
|
const item = items[0]!
|
||||||
|
expect(item.data.status).toBe("cancelled")
|
||||||
|
expect(item.slots).toBeDefined()
|
||||||
|
expect(Object.keys(item.slots!).sort()).toEqual([...EXPECTED_SLOT_NAMES].sort())
|
||||||
|
|
||||||
|
for (const name of EXPECTED_SLOT_NAMES) {
|
||||||
|
expect(item.slots![name]!.content).toBeNull()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { ActionDefinition, ContextEntry, FeedItemSignals, FeedSource } from "@aelis/core"
|
import type { ActionDefinition, ContextEntry, FeedItemSignals, FeedSource, Slot } from "@aelis/core"
|
||||||
|
|
||||||
import { Context, TimeRelevance, UnknownActionError } from "@aelis/core"
|
import { Context, TimeRelevance, UnknownActionError } from "@aelis/core"
|
||||||
import { DAVClient } from "tsdav"
|
import { DAVClient } from "tsdav"
|
||||||
@@ -7,6 +7,9 @@ import type { CalDavDAVClient, CalDavEventData, CalDavFeedItem } from "./types.t
|
|||||||
|
|
||||||
import { CalDavCalendarKey, type CalendarContext } from "./calendar-context.ts"
|
import { CalDavCalendarKey, type CalendarContext } from "./calendar-context.ts"
|
||||||
import { parseICalEvents } from "./ical-parser.ts"
|
import { parseICalEvents } from "./ical-parser.ts"
|
||||||
|
import crossSourcePrompt from "./prompts/cross-source.txt"
|
||||||
|
import insightPrompt from "./prompts/insight.txt"
|
||||||
|
import preparationPrompt from "./prompts/preparation.txt"
|
||||||
import { CalDavEventStatus, CalDavFeedItemType } from "./types.ts"
|
import { CalDavEventStatus, CalDavFeedItemType } from "./types.ts"
|
||||||
|
|
||||||
// -- Source options --
|
// -- Source options --
|
||||||
@@ -340,6 +343,14 @@ export function computeSignals(
|
|||||||
return { urgency: 0.2, timeRelevance: TimeRelevance.Ambient }
|
return { urgency: 0.2, timeRelevance: TimeRelevance.Ambient }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createEventSlots(): Record<string, Slot> {
|
||||||
|
return {
|
||||||
|
insight: { description: insightPrompt, content: null },
|
||||||
|
preparation: { description: preparationPrompt, content: null },
|
||||||
|
crossSource: { description: crossSourcePrompt, content: null },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function createFeedItem(event: CalDavEventData, now: Date, timeZone?: string): CalDavFeedItem {
|
function createFeedItem(event: CalDavEventData, now: Date, timeZone?: string): CalDavFeedItem {
|
||||||
return {
|
return {
|
||||||
id: `caldav-event-${event.uid}${event.recurrenceId ? `-${event.recurrenceId}` : ""}`,
|
id: `caldav-event-${event.uid}${event.recurrenceId ? `-${event.recurrenceId}` : ""}`,
|
||||||
@@ -347,5 +358,6 @@ function createFeedItem(event: CalDavEventData, now: Date, timeZone?: string): C
|
|||||||
timestamp: now,
|
timestamp: now,
|
||||||
data: event,
|
data: event,
|
||||||
signals: computeSignals(event, now, timeZone),
|
signals: computeSignals(event, now, timeZone),
|
||||||
|
slots: createEventSlots(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
If other feed data (weather, transit, nearby events) would disrupt or materially affect this event, state the connection in one sentence. Infer whether the event is indoor/outdoor/virtual from the title and location. Weather is only relevant if it affects getting to the event or the activity itself (e.g., rain for outdoor events, extreme conditions for physical activities). Return null for indoor or virtual events where weather has no impact. Do not fabricate information you don't have — only reference data present in the feed.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
- "rain expected at 5pm — bring an umbrella for the walk to Tooley Street"
|
||||||
|
- "Northern line has delays — leave 15 minutes early"
|
||||||
|
- "your next event is across town — the 40 min gap may not be enough"
|
||||||
|
- null (indoor guitar class with wind outside — weather doesn't affect the event)
|
||||||
|
- null
|
||||||
7
packages/aelis-source-caldav/src/prompts/insight.txt
Normal file
7
packages/aelis-source-caldav/src/prompts/insight.txt
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
One sentence of actionable insight the user can't already see from the event title, time, and location. Do not restate event details. Do not fabricate information you don't have. Return null if there's nothing non-obvious to say.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
- "you have 2 hours free before this starts"
|
||||||
|
- "all 8 attendees accepted — expect a full room"
|
||||||
|
- "third time this has been rescheduled"
|
||||||
|
- null
|
||||||
6
packages/aelis-source-caldav/src/prompts/preparation.txt
Normal file
6
packages/aelis-source-caldav/src/prompts/preparation.txt
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
A concrete preparation step — something the user should do, bring, or review before this event. Infer only from available event and feed data. Do not restate event details. Do not fabricate information you don't have. Return null if no useful preparation comes to mind.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
- "different building from your previous meeting — allow travel time"
|
||||||
|
- "recurring meeting you declined last week — check if you need to attend"
|
||||||
|
- null
|
||||||
4
packages/aelis-source-caldav/src/text.d.ts
vendored
Normal file
4
packages/aelis-source-caldav/src/text.d.ts
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
declare module "*.txt" {
|
||||||
|
const content: string
|
||||||
|
export default content
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user