mirror of
https://github.com/kennethnym/aris.git
synced 2026-03-20 17:11:17 +00:00
Compare commits
1 Commits
feat/compo
...
a26e35cc2f
| Author | SHA1 | Date | |
|---|---|---|---|
|
a26e35cc2f
|
@@ -7,11 +7,6 @@ BETTER_AUTH_SECRET=
|
|||||||
# Base URL of the backend
|
# Base URL of the backend
|
||||||
BETTER_AUTH_URL=http://localhost:3000
|
BETTER_AUTH_URL=http://localhost:3000
|
||||||
|
|
||||||
# OpenRouter (LLM feed enhancement)
|
|
||||||
OPENROUTER_API_KEY=
|
|
||||||
# Optional: override the default model (default: openai/gpt-4.1-mini)
|
|
||||||
# OPENROUTER_MODEL=openai/gpt-4.1-mini
|
|
||||||
|
|
||||||
# Apple WeatherKit credentials
|
# Apple WeatherKit credentials
|
||||||
WEATHERKIT_PRIVATE_KEY=
|
WEATHERKIT_PRIVATE_KEY=
|
||||||
WEATHERKIT_KEY_ID=
|
WEATHERKIT_KEY_ID=
|
||||||
|
|||||||
@@ -10,12 +10,9 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aelis/core": "workspace:*",
|
"@aelis/core": "workspace:*",
|
||||||
"@aelis/source-caldav": "workspace:*",
|
|
||||||
"@aelis/source-google-calendar": "workspace:*",
|
|
||||||
"@aelis/source-location": "workspace:*",
|
"@aelis/source-location": "workspace:*",
|
||||||
"@aelis/source-tfl": "workspace:*",
|
"@aelis/source-tfl": "workspace:*",
|
||||||
"@aelis/source-weatherkit": "workspace:*",
|
"@aelis/source-weatherkit": "workspace:*",
|
||||||
"@openrouter/sdk": "^0.9.11",
|
|
||||||
"arktype": "^2.1.29",
|
"arktype": "^2.1.29",
|
||||||
"better-auth": "^1",
|
"better-auth": "^1",
|
||||||
"hono": "^4",
|
"hono": "^4",
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ export async function getSessionFromHeaders(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Dev/test middleware that injects a fake user and session.
|
* Test-only 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,34 +69,8 @@ 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)
|
||||||
const now = new Date()
|
c.set("session", { id: "mock-session" } as AuthSession)
|
||||||
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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,51 +0,0 @@
|
|||||||
import type { FeedItem } from "@aelis/core"
|
|
||||||
|
|
||||||
import type { LlmClient } from "./llm-client.ts"
|
|
||||||
|
|
||||||
import { mergeEnhancement } from "./merge.ts"
|
|
||||||
import { buildPrompt, hasUnfilledSlots } from "./prompt-builder.ts"
|
|
||||||
|
|
||||||
/** Takes feed items, returns enhanced feed items. */
|
|
||||||
export type FeedEnhancer = (items: FeedItem[]) => Promise<FeedItem[]>
|
|
||||||
|
|
||||||
export interface FeedEnhancerConfig {
|
|
||||||
client: LlmClient
|
|
||||||
/** Defaults to Date.now — override for testing */
|
|
||||||
clock?: () => Date
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a FeedEnhancer that uses the provided LlmClient.
|
|
||||||
*
|
|
||||||
* Skips the LLM call when no items have unfilled slots.
|
|
||||||
* Returns items unchanged on LLM failure.
|
|
||||||
*/
|
|
||||||
export function createFeedEnhancer(config: FeedEnhancerConfig): FeedEnhancer {
|
|
||||||
const { client } = config
|
|
||||||
const clock = config.clock ?? (() => new Date())
|
|
||||||
|
|
||||||
return async function enhanceFeed(items) {
|
|
||||||
if (!hasUnfilledSlots(items)) {
|
|
||||||
return items
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentTime = clock()
|
|
||||||
const { systemPrompt, userMessage } = buildPrompt(items, currentTime)
|
|
||||||
|
|
||||||
let result
|
|
||||||
try {
|
|
||||||
result = await client.enhance({ systemPrompt, userMessage })
|
|
||||||
} catch (err) {
|
|
||||||
console.error("[enhancement] LLM call failed:", err)
|
|
||||||
result = null
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!result) {
|
|
||||||
return items
|
|
||||||
}
|
|
||||||
|
|
||||||
return mergeEnhancement(items, result, currentTime)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
import { OpenRouter } from "@openrouter/sdk"
|
|
||||||
|
|
||||||
import type { EnhancementResult } from "./schema.ts"
|
|
||||||
|
|
||||||
import { enhancementResultJsonSchema, parseEnhancementResult } from "./schema.ts"
|
|
||||||
|
|
||||||
const DEFAULT_MODEL = "openai/gpt-4.1-mini"
|
|
||||||
const DEFAULT_TIMEOUT_MS = 30_000
|
|
||||||
|
|
||||||
export interface LlmClientConfig {
|
|
||||||
apiKey: string
|
|
||||||
model?: string
|
|
||||||
timeoutMs?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LlmClientRequest {
|
|
||||||
systemPrompt: string
|
|
||||||
userMessage: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LlmClient {
|
|
||||||
enhance(request: LlmClientRequest): Promise<EnhancementResult | null>
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a reusable LLM client backed by OpenRouter.
|
|
||||||
* The OpenRouter SDK instance is created once and reused across calls.
|
|
||||||
*/
|
|
||||||
export function createLlmClient(config: LlmClientConfig): LlmClient {
|
|
||||||
const client = new OpenRouter({
|
|
||||||
apiKey: config.apiKey,
|
|
||||||
timeoutMs: config.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
|
||||||
})
|
|
||||||
const model = config.model ?? DEFAULT_MODEL
|
|
||||||
|
|
||||||
return {
|
|
||||||
async enhance(request) {
|
|
||||||
const response = await client.chat.send({
|
|
||||||
chatGenerationParams: {
|
|
||||||
model,
|
|
||||||
messages: [
|
|
||||||
{ role: "system" as const, content: request.systemPrompt },
|
|
||||||
{ role: "user" as const, content: request.userMessage },
|
|
||||||
],
|
|
||||||
responseFormat: {
|
|
||||||
type: "json_schema" as const,
|
|
||||||
jsonSchema: {
|
|
||||||
name: "enhancement_result",
|
|
||||||
strict: true,
|
|
||||||
schema: enhancementResultJsonSchema,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
stream: false,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const content = response.choices?.[0]?.message?.content
|
|
||||||
if (typeof content !== "string") {
|
|
||||||
console.warn("[enhancement] LLM returned no content in response")
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = parseEnhancementResult(content)
|
|
||||||
if (!result) {
|
|
||||||
console.warn("[enhancement] Failed to parse LLM response:", content)
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,150 +0,0 @@
|
|||||||
import type { FeedItem } from "@aelis/core"
|
|
||||||
|
|
||||||
import { describe, expect, test } from "bun:test"
|
|
||||||
|
|
||||||
import type { EnhancementResult } from "./schema.ts"
|
|
||||||
|
|
||||||
import { mergeEnhancement } from "./merge.ts"
|
|
||||||
|
|
||||||
function makeItem(overrides: Partial<FeedItem> = {}): FeedItem {
|
|
||||||
return {
|
|
||||||
id: "item-1",
|
|
||||||
type: "test",
|
|
||||||
timestamp: new Date("2025-01-01T00:00:00Z"),
|
|
||||||
data: { value: 42 },
|
|
||||||
...overrides,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const now = new Date("2025-06-01T12:00:00Z")
|
|
||||||
|
|
||||||
describe("mergeEnhancement", () => {
|
|
||||||
test("fills matching slots", () => {
|
|
||||||
const item = makeItem({
|
|
||||||
slots: {
|
|
||||||
insight: { description: "Weather insight", content: null },
|
|
||||||
},
|
|
||||||
})
|
|
||||||
const result: EnhancementResult = {
|
|
||||||
slotFills: {
|
|
||||||
"item-1": { insight: "Rain after 3pm" },
|
|
||||||
},
|
|
||||||
syntheticItems: [],
|
|
||||||
}
|
|
||||||
|
|
||||||
const merged = mergeEnhancement([item], result, now)
|
|
||||||
|
|
||||||
expect(merged).toHaveLength(1)
|
|
||||||
expect(merged[0]!.slots!.insight!.content).toBe("Rain after 3pm")
|
|
||||||
// Description preserved
|
|
||||||
expect(merged[0]!.slots!.insight!.description).toBe("Weather insight")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("does not mutate original items", () => {
|
|
||||||
const item = makeItem({
|
|
||||||
slots: {
|
|
||||||
insight: { description: "test", content: null },
|
|
||||||
},
|
|
||||||
})
|
|
||||||
const result: EnhancementResult = {
|
|
||||||
slotFills: { "item-1": { insight: "filled" } },
|
|
||||||
syntheticItems: [],
|
|
||||||
}
|
|
||||||
|
|
||||||
mergeEnhancement([item], result, now)
|
|
||||||
|
|
||||||
expect(item.slots!.insight!.content).toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
test("ignores fills for non-existent items", () => {
|
|
||||||
const item = makeItem()
|
|
||||||
const result: EnhancementResult = {
|
|
||||||
slotFills: { "non-existent": { insight: "text" } },
|
|
||||||
syntheticItems: [],
|
|
||||||
}
|
|
||||||
|
|
||||||
const merged = mergeEnhancement([item], result, now)
|
|
||||||
|
|
||||||
expect(merged).toHaveLength(1)
|
|
||||||
expect(merged[0]!.id).toBe("item-1")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("ignores fills for non-existent slots", () => {
|
|
||||||
const item = makeItem({
|
|
||||||
slots: {
|
|
||||||
insight: { description: "test", content: null },
|
|
||||||
},
|
|
||||||
})
|
|
||||||
const result: EnhancementResult = {
|
|
||||||
slotFills: { "item-1": { "non-existent-slot": "text" } },
|
|
||||||
syntheticItems: [],
|
|
||||||
}
|
|
||||||
|
|
||||||
const merged = mergeEnhancement([item], result, now)
|
|
||||||
|
|
||||||
expect(merged[0]!.slots!.insight!.content).toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
test("skips null fills", () => {
|
|
||||||
const item = makeItem({
|
|
||||||
slots: {
|
|
||||||
insight: { description: "test", content: null },
|
|
||||||
},
|
|
||||||
})
|
|
||||||
const result: EnhancementResult = {
|
|
||||||
slotFills: { "item-1": { insight: null } },
|
|
||||||
syntheticItems: [],
|
|
||||||
}
|
|
||||||
|
|
||||||
const merged = mergeEnhancement([item], result, now)
|
|
||||||
|
|
||||||
expect(merged[0]!.slots!.insight!.content).toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
test("passes through items without slots unchanged", () => {
|
|
||||||
const item = makeItem()
|
|
||||||
const result: EnhancementResult = {
|
|
||||||
slotFills: {},
|
|
||||||
syntheticItems: [],
|
|
||||||
}
|
|
||||||
|
|
||||||
const merged = mergeEnhancement([item], result, now)
|
|
||||||
|
|
||||||
expect(merged[0]).toBe(item)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("appends synthetic items with backfilled fields", () => {
|
|
||||||
const item = makeItem()
|
|
||||||
const result: EnhancementResult = {
|
|
||||||
slotFills: {},
|
|
||||||
syntheticItems: [
|
|
||||||
{
|
|
||||||
id: "briefing-morning",
|
|
||||||
type: "briefing",
|
|
||||||
text: "Light afternoon ahead.",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
const merged = mergeEnhancement([item], result, now)
|
|
||||||
|
|
||||||
expect(merged).toHaveLength(2)
|
|
||||||
expect(merged[1]!.id).toBe("briefing-morning")
|
|
||||||
expect(merged[1]!.type).toBe("briefing")
|
|
||||||
expect(merged[1]!.timestamp).toEqual(now)
|
|
||||||
expect(merged[1]!.data).toEqual({ text: "Light afternoon ahead." })
|
|
||||||
})
|
|
||||||
|
|
||||||
test("handles empty enhancement result", () => {
|
|
||||||
const item = makeItem()
|
|
||||||
const result: EnhancementResult = {
|
|
||||||
slotFills: {},
|
|
||||||
syntheticItems: [],
|
|
||||||
}
|
|
||||||
|
|
||||||
const merged = mergeEnhancement([item], result, now)
|
|
||||||
|
|
||||||
expect(merged).toHaveLength(1)
|
|
||||||
expect(merged[0]).toBe(item)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
import type { FeedItem } from "@aelis/core"
|
|
||||||
|
|
||||||
import type { EnhancementResult } from "./schema.ts"
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Merges an EnhancementResult into feed items.
|
|
||||||
*
|
|
||||||
* - Writes slot content from slotFills into matching items
|
|
||||||
* - Appends synthetic items to the list
|
|
||||||
* - Returns a new array (no mutation)
|
|
||||||
* - Ignores fills for items/slots that don't exist
|
|
||||||
*/
|
|
||||||
export function mergeEnhancement(items: FeedItem[], result: EnhancementResult, currentTime: Date): FeedItem[] {
|
|
||||||
const merged = items.map((item) => {
|
|
||||||
const fills = result.slotFills[item.id]
|
|
||||||
if (!fills || !item.slots) return item
|
|
||||||
|
|
||||||
const mergedSlots = { ...item.slots }
|
|
||||||
let changed = false
|
|
||||||
|
|
||||||
for (const [slotName, content] of Object.entries(fills)) {
|
|
||||||
if (slotName in mergedSlots && content !== null) {
|
|
||||||
mergedSlots[slotName] = { ...mergedSlots[slotName]!, content }
|
|
||||||
changed = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return changed ? { ...item, slots: mergedSlots } : item
|
|
||||||
})
|
|
||||||
|
|
||||||
for (const synthetic of result.syntheticItems) {
|
|
||||||
merged.push({
|
|
||||||
id: synthetic.id,
|
|
||||||
type: synthetic.type,
|
|
||||||
timestamp: currentTime,
|
|
||||||
data: { text: synthetic.text },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return merged
|
|
||||||
}
|
|
||||||
@@ -1,167 +0,0 @@
|
|||||||
import type { FeedItem } from "@aelis/core"
|
|
||||||
|
|
||||||
import { describe, expect, test } from "bun:test"
|
|
||||||
|
|
||||||
import { buildPrompt, hasUnfilledSlots } from "./prompt-builder.ts"
|
|
||||||
|
|
||||||
function makeItem(overrides: Partial<FeedItem> = {}): FeedItem {
|
|
||||||
return {
|
|
||||||
id: "item-1",
|
|
||||||
type: "test",
|
|
||||||
timestamp: new Date("2025-01-01T00:00:00Z"),
|
|
||||||
data: { value: 42 },
|
|
||||||
...overrides,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseUserMessage(userMessage: string): Record<string, unknown> {
|
|
||||||
return JSON.parse(userMessage)
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("hasUnfilledSlots", () => {
|
|
||||||
test("returns false for items without slots", () => {
|
|
||||||
expect(hasUnfilledSlots([makeItem()])).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("returns false for items with all slots filled", () => {
|
|
||||||
const item = makeItem({
|
|
||||||
slots: {
|
|
||||||
insight: { description: "test", content: "filled" },
|
|
||||||
},
|
|
||||||
})
|
|
||||||
expect(hasUnfilledSlots([item])).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("returns true when at least one slot is unfilled", () => {
|
|
||||||
const item = makeItem({
|
|
||||||
slots: {
|
|
||||||
insight: { description: "test", content: null },
|
|
||||||
},
|
|
||||||
})
|
|
||||||
expect(hasUnfilledSlots([item])).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("returns false for empty array", () => {
|
|
||||||
expect(hasUnfilledSlots([])).toBe(false)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("buildPrompt", () => {
|
|
||||||
test("puts items with unfilled slots in items", () => {
|
|
||||||
const item = makeItem({
|
|
||||||
slots: {
|
|
||||||
insight: { description: "Weather insight", content: null },
|
|
||||||
filled: { description: "Already done", content: "done" },
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const { userMessage } = buildPrompt([item], new Date("2025-06-01T12:00:00Z"))
|
|
||||||
const parsed = parseUserMessage(userMessage)
|
|
||||||
|
|
||||||
expect(parsed.items).toHaveLength(1)
|
|
||||||
expect((parsed.items as Array<Record<string, unknown>>)[0]!.id).toBe("item-1")
|
|
||||||
expect((parsed.items as Array<Record<string, unknown>>)[0]!.slots).toEqual({ insight: "Weather insight" })
|
|
||||||
expect((parsed.items as Array<Record<string, unknown>>)[0]!.type).toBeUndefined()
|
|
||||||
expect(parsed.context).toHaveLength(0)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("puts slotless items in context", () => {
|
|
||||||
const withSlots = makeItem({
|
|
||||||
id: "with-slots",
|
|
||||||
slots: { insight: { description: "test", content: null } },
|
|
||||||
})
|
|
||||||
const withoutSlots = makeItem({ id: "no-slots" })
|
|
||||||
|
|
||||||
const { userMessage } = buildPrompt([withSlots, withoutSlots], new Date("2025-06-01T12:00:00Z"))
|
|
||||||
const parsed = parseUserMessage(userMessage)
|
|
||||||
|
|
||||||
expect(parsed.items).toHaveLength(1)
|
|
||||||
expect((parsed.items as Array<Record<string, unknown>>)[0]!.id).toBe("with-slots")
|
|
||||||
expect(parsed.context).toHaveLength(1)
|
|
||||||
expect((parsed.context as Array<Record<string, unknown>>)[0]!.id).toBe("no-slots")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("includes time in ISO format", () => {
|
|
||||||
const { userMessage } = buildPrompt([], new Date("2025-06-01T12:00:00Z"))
|
|
||||||
const parsed = parseUserMessage(userMessage)
|
|
||||||
|
|
||||||
expect(parsed.time).toBe("2025-06-01T12:00:00.000Z")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("system prompt is non-empty", () => {
|
|
||||||
const { systemPrompt } = buildPrompt([], new Date())
|
|
||||||
expect(systemPrompt.length).toBeGreaterThan(0)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("includes schedule in system prompt", () => {
|
|
||||||
const calEvent = makeItem({
|
|
||||||
id: "cal-1",
|
|
||||||
type: "caldav-event",
|
|
||||||
data: {
|
|
||||||
title: "Team standup",
|
|
||||||
startDate: "2025-06-01T10:00:00Z",
|
|
||||||
endDate: "2025-06-01T10:30:00Z",
|
|
||||||
isAllDay: false,
|
|
||||||
location: null,
|
|
||||||
},
|
|
||||||
slots: {
|
|
||||||
insight: { description: "test", content: null },
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const { systemPrompt } = buildPrompt([calEvent], new Date("2025-06-01T12:00:00Z"))
|
|
||||||
|
|
||||||
expect(systemPrompt).toContain("Schedule:\n")
|
|
||||||
expect(systemPrompt).toContain("Team standup")
|
|
||||||
expect(systemPrompt).toContain("10:00")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("includes location in schedule", () => {
|
|
||||||
const calEvent = makeItem({
|
|
||||||
id: "cal-1",
|
|
||||||
type: "caldav-event",
|
|
||||||
data: {
|
|
||||||
title: "Therapy",
|
|
||||||
startDate: "2025-06-02T18:00:00Z",
|
|
||||||
endDate: "2025-06-02T19:00:00Z",
|
|
||||||
isAllDay: false,
|
|
||||||
location: "92 Tooley Street, London",
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const { systemPrompt } = buildPrompt([calEvent], new Date("2025-06-01T12:00:00Z"))
|
|
||||||
|
|
||||||
expect(systemPrompt).toContain("Therapy @ 92 Tooley Street, London")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("includes week calendar but omits schedule when no calendar items", () => {
|
|
||||||
const weatherItem = makeItem({
|
|
||||||
type: "weather-current",
|
|
||||||
data: { temperature: 14 },
|
|
||||||
})
|
|
||||||
|
|
||||||
const { systemPrompt } = buildPrompt([weatherItem], new Date("2025-06-01T12:00:00Z"))
|
|
||||||
|
|
||||||
expect(systemPrompt).toContain("Week:")
|
|
||||||
expect(systemPrompt).not.toContain("Schedule:")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("user message is pure JSON", () => {
|
|
||||||
const calEvent = makeItem({
|
|
||||||
id: "cal-1",
|
|
||||||
type: "caldav-event",
|
|
||||||
data: {
|
|
||||||
title: "Budget Review",
|
|
||||||
startTime: "2025-06-01T14:00:00Z",
|
|
||||||
endTime: "2025-06-01T15:00:00Z",
|
|
||||||
isAllDay: false,
|
|
||||||
location: "https://meet.google.com/abc",
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const { userMessage } = buildPrompt([calEvent], new Date("2025-06-01T12:00:00Z"))
|
|
||||||
|
|
||||||
expect(userMessage.startsWith("{")).toBe(true)
|
|
||||||
expect(() => JSON.parse(userMessage)).not.toThrow()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,218 +0,0 @@
|
|||||||
import type { FeedItem } from "@aelis/core"
|
|
||||||
|
|
||||||
import { CalDavFeedItemType } from "@aelis/source-caldav"
|
|
||||||
import { CalendarFeedItemType } from "@aelis/source-google-calendar"
|
|
||||||
|
|
||||||
import systemPromptBase from "./prompts/system.txt"
|
|
||||||
|
|
||||||
const CALENDAR_ITEM_TYPES = new Set<string>([
|
|
||||||
CalDavFeedItemType.Event,
|
|
||||||
CalendarFeedItemType.Event,
|
|
||||||
CalendarFeedItemType.AllDay,
|
|
||||||
])
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Builds the system prompt and user message for the enhancement harness.
|
|
||||||
*
|
|
||||||
* Includes a pre-computed mini calendar so the LLM doesn't have to
|
|
||||||
* parse timestamps to understand the user's schedule.
|
|
||||||
*/
|
|
||||||
export function buildPrompt(
|
|
||||||
items: FeedItem[],
|
|
||||||
currentTime: Date,
|
|
||||||
): { systemPrompt: string; userMessage: string } {
|
|
||||||
const schedule = buildSchedule(items, currentTime)
|
|
||||||
|
|
||||||
const enhanceItems: Array<{
|
|
||||||
id: string
|
|
||||||
data: Record<string, unknown>
|
|
||||||
slots: Record<string, string>
|
|
||||||
}> = []
|
|
||||||
const contextItems: Array<{
|
|
||||||
id: string
|
|
||||||
type: string
|
|
||||||
data: Record<string, unknown>
|
|
||||||
}> = []
|
|
||||||
|
|
||||||
for (const item of items) {
|
|
||||||
const hasUnfilledSlots =
|
|
||||||
item.slots &&
|
|
||||||
Object.values(item.slots).some((slot) => slot.content === null)
|
|
||||||
|
|
||||||
if (hasUnfilledSlots) {
|
|
||||||
enhanceItems.push({
|
|
||||||
id: item.id,
|
|
||||||
data: item.data,
|
|
||||||
slots: Object.fromEntries(
|
|
||||||
Object.entries(item.slots!)
|
|
||||||
.filter(([, slot]) => slot.content === null)
|
|
||||||
.map(([name, slot]) => [name, slot.description]),
|
|
||||||
),
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
contextItems.push({
|
|
||||||
id: item.id,
|
|
||||||
type: item.type,
|
|
||||||
data: item.data,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const userMessage = JSON.stringify({
|
|
||||||
time: currentTime.toISOString(),
|
|
||||||
items: enhanceItems,
|
|
||||||
context: contextItems,
|
|
||||||
})
|
|
||||||
|
|
||||||
const weekCalendar = buildWeekCalendar(currentTime)
|
|
||||||
let systemPrompt = systemPromptBase
|
|
||||||
systemPrompt += `\n\nWeek:\n${weekCalendar}`
|
|
||||||
if (schedule) {
|
|
||||||
systemPrompt += `\n\nSchedule:\n${schedule}`
|
|
||||||
}
|
|
||||||
|
|
||||||
return { systemPrompt, userMessage }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns true if any item has at least one unfilled slot.
|
|
||||||
*/
|
|
||||||
export function hasUnfilledSlots(items: FeedItem[]): boolean {
|
|
||||||
return items.some(
|
|
||||||
(item) =>
|
|
||||||
item.slots &&
|
|
||||||
Object.values(item.slots).some((slot) => slot.content === null),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// -- Helpers --
|
|
||||||
|
|
||||||
interface CalendarEntry {
|
|
||||||
date: Date
|
|
||||||
title: string
|
|
||||||
location: string | null
|
|
||||||
isAllDay: boolean
|
|
||||||
startTime: Date
|
|
||||||
endTime: Date
|
|
||||||
}
|
|
||||||
|
|
||||||
function toValidDate(value: unknown): Date | null {
|
|
||||||
if (value instanceof Date) return Number.isNaN(value.getTime()) ? null : value
|
|
||||||
if (typeof value === "string" || typeof value === "number") {
|
|
||||||
const date = new Date(value)
|
|
||||||
return Number.isNaN(date.getTime()) ? null : date
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractCalendarEntry(item: FeedItem): CalendarEntry | null {
|
|
||||||
if (!CALENDAR_ITEM_TYPES.has(item.type)) return null
|
|
||||||
|
|
||||||
const d = item.data
|
|
||||||
const title = d.title
|
|
||||||
if (typeof title !== "string" || !title) return null
|
|
||||||
|
|
||||||
// CalDAV uses startDate/endDate, Google Calendar uses startTime/endTime
|
|
||||||
const startTime = toValidDate(d.startDate ?? d.startTime)
|
|
||||||
if (!startTime) return null
|
|
||||||
|
|
||||||
const endTime = toValidDate(d.endDate ?? d.endTime) ?? startTime
|
|
||||||
|
|
||||||
return {
|
|
||||||
date: startTime,
|
|
||||||
title,
|
|
||||||
location: typeof d.location === "string" ? d.location : null,
|
|
||||||
isAllDay: typeof d.isAllDay === "boolean" ? d.isAllDay : false,
|
|
||||||
startTime,
|
|
||||||
endTime,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const DAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"] as const
|
|
||||||
const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"] as const
|
|
||||||
|
|
||||||
function pad2(n: number): string {
|
|
||||||
return n.toString().padStart(2, "0")
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatTime(date: Date): string {
|
|
||||||
return `${pad2(date.getUTCHours())}:${pad2(date.getUTCMinutes())}`
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDayShort(date: Date): string {
|
|
||||||
return `${DAYS[date.getUTCDay()]}, ${date.getUTCDate()} ${MONTHS[date.getUTCMonth()]}`
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDayLabel(date: Date, currentTime: Date): string {
|
|
||||||
const currentDay = Date.UTC(currentTime.getUTCFullYear(), currentTime.getUTCMonth(), currentTime.getUTCDate())
|
|
||||||
const targetDay = Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate())
|
|
||||||
const diffDays = Math.round((targetDay - currentDay) / (1000 * 60 * 60 * 24))
|
|
||||||
|
|
||||||
const dayName = formatDayShort(date)
|
|
||||||
|
|
||||||
if (diffDays === 0) return `Today: ${dayName}`
|
|
||||||
if (diffDays === 1) return `Tomorrow: ${dayName}`
|
|
||||||
return dayName
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Builds a week overview mapping day names to dates,
|
|
||||||
* so the LLM can easily match ISO timestamps to days.
|
|
||||||
*/
|
|
||||||
function buildWeekCalendar(currentTime: Date): string {
|
|
||||||
const lines: string[] = []
|
|
||||||
for (let i = 0; i < 7; i++) {
|
|
||||||
const date = new Date(currentTime)
|
|
||||||
date.setUTCDate(date.getUTCDate() + i)
|
|
||||||
const label = i === 0 ? "Today" : i === 1 ? "Tomorrow" : ""
|
|
||||||
const dayStr = formatDayShort(date)
|
|
||||||
const iso = date.toISOString().slice(0, 10)
|
|
||||||
const prefix = label ? `${label}: ` : ""
|
|
||||||
lines.push(`${prefix}${dayStr} = ${iso}`)
|
|
||||||
}
|
|
||||||
return lines.join("\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Builds a compact text calendar from all calendar-type items.
|
|
||||||
* Groups events by day relative to currentTime.
|
|
||||||
*/
|
|
||||||
function buildSchedule(items: FeedItem[], currentTime: Date): string {
|
|
||||||
const entries: CalendarEntry[] = []
|
|
||||||
for (const item of items) {
|
|
||||||
const entry = extractCalendarEntry(item)
|
|
||||||
if (entry) entries.push(entry)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (entries.length === 0) return ""
|
|
||||||
|
|
||||||
entries.sort((a, b) => a.startTime.getTime() - b.startTime.getTime())
|
|
||||||
|
|
||||||
const byDay = new Map<string, CalendarEntry[]>()
|
|
||||||
for (const entry of entries) {
|
|
||||||
const key = entry.date.toISOString().slice(0, 10)
|
|
||||||
const group = byDay.get(key)
|
|
||||||
if (group) {
|
|
||||||
group.push(entry)
|
|
||||||
} else {
|
|
||||||
byDay.set(key, [entry])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const lines: string[] = []
|
|
||||||
for (const [, dayEntries] of byDay) {
|
|
||||||
lines.push(formatDayLabel(dayEntries[0]!.startTime, currentTime))
|
|
||||||
for (const entry of dayEntries) {
|
|
||||||
if (entry.isAllDay) {
|
|
||||||
const loc = entry.location ? ` @ ${entry.location}` : ""
|
|
||||||
lines.push(` all day ${entry.title}${loc}`)
|
|
||||||
} else {
|
|
||||||
const timeRange = `${formatTime(entry.startTime)}–${formatTime(entry.endTime)}`
|
|
||||||
const loc = entry.location ? ` @ ${entry.location}` : ""
|
|
||||||
lines.push(` ${timeRange} ${entry.title}${loc}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return lines.join("\n")
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
You are AELIS, a personal assistant. You enhance a user's feed by filling slots and optionally generating synthetic items.
|
|
||||||
|
|
||||||
The user message is a JSON object with:
|
|
||||||
- "items": feed items with data and named slots to fill. Each slot has a description of what to write.
|
|
||||||
- "context": other feed items (no slots) for cross-source reasoning.
|
|
||||||
- "time": current ISO timestamp.
|
|
||||||
|
|
||||||
Your output has two fields:
|
|
||||||
- "slotFills": map of item ID → slot name → short text (or null if you can't fill it or cannot provide answer). Each item ID appears ONCE with ALL its slots in a single object.
|
|
||||||
- "syntheticItems": array of { id, type, text } for new items (briefings, nudges, insights). Only when genuinely useful and when not redundant.
|
|
||||||
|
|
||||||
Rules:
|
|
||||||
- DO NOT USE EMDASH OR DASH OR ATTEMPT TO USE SYMBOLS TO CIRCUMVENT THIS RULE.
|
|
||||||
- One sentence per slot. Two max if absolutely necessary. Be direct.
|
|
||||||
- Say "I" not "we."
|
|
||||||
- Hedge when inferring. Don't state guesses as facts.
|
|
||||||
- Use the week and schedule below to understand when events happen. Match weather data to the correct date.
|
|
||||||
- Look for connections across items.
|
|
||||||
- Don't pad — return null for slots you can't meaningfully fill, and skip synthetic items if there's nothing useful to add.
|
|
||||||
- Never fabricate information not present in the feed. If you don't have data to support a fill, return null.
|
|
||||||
- Read each slot's description carefully — it defines when to return null.
|
|
||||||
@@ -1,176 +0,0 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
|
||||||
|
|
||||||
import {
|
|
||||||
emptyEnhancementResult,
|
|
||||||
enhancementResultJsonSchema,
|
|
||||||
parseEnhancementResult,
|
|
||||||
} from "./schema.ts"
|
|
||||||
|
|
||||||
describe("parseEnhancementResult", () => {
|
|
||||||
test("parses valid result", () => {
|
|
||||||
const input = JSON.stringify({
|
|
||||||
slotFills: {
|
|
||||||
"weather-1": {
|
|
||||||
insight: "Rain after 3pm",
|
|
||||||
"cross-source": null,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
syntheticItems: [
|
|
||||||
{
|
|
||||||
id: "briefing-morning",
|
|
||||||
type: "briefing",
|
|
||||||
text: "Light afternoon ahead.",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})
|
|
||||||
|
|
||||||
const result = parseEnhancementResult(input)
|
|
||||||
|
|
||||||
expect(result).not.toBeNull()
|
|
||||||
expect(result!.slotFills["weather-1"]!.insight).toBe("Rain after 3pm")
|
|
||||||
expect(result!.slotFills["weather-1"]!["cross-source"]).toBeNull()
|
|
||||||
expect(result!.syntheticItems).toHaveLength(1)
|
|
||||||
expect(result!.syntheticItems[0]!.id).toBe("briefing-morning")
|
|
||||||
expect(result!.syntheticItems[0]!.text).toBe("Light afternoon ahead.")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("parses empty result", () => {
|
|
||||||
const input = JSON.stringify({
|
|
||||||
slotFills: {},
|
|
||||||
syntheticItems: [],
|
|
||||||
})
|
|
||||||
|
|
||||||
const result = parseEnhancementResult(input)
|
|
||||||
|
|
||||||
expect(result).not.toBeNull()
|
|
||||||
expect(Object.keys(result!.slotFills)).toHaveLength(0)
|
|
||||||
expect(result!.syntheticItems).toHaveLength(0)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("returns null for invalid JSON", () => {
|
|
||||||
expect(parseEnhancementResult("not json")).toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
test("returns null for non-object", () => {
|
|
||||||
expect(parseEnhancementResult('"hello"')).toBeNull()
|
|
||||||
expect(parseEnhancementResult("42")).toBeNull()
|
|
||||||
expect(parseEnhancementResult("null")).toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
test("returns null when slotFills is missing", () => {
|
|
||||||
const input = JSON.stringify({ syntheticItems: [] })
|
|
||||||
expect(parseEnhancementResult(input)).toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
test("returns null when syntheticItems is missing", () => {
|
|
||||||
const input = JSON.stringify({ slotFills: {} })
|
|
||||||
expect(parseEnhancementResult(input)).toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
test("returns null when slotFills has non-string values", () => {
|
|
||||||
const input = JSON.stringify({
|
|
||||||
slotFills: { "item-1": { slot: 42 } },
|
|
||||||
syntheticItems: [],
|
|
||||||
})
|
|
||||||
expect(parseEnhancementResult(input)).toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
test("returns null when syntheticItem is missing required fields", () => {
|
|
||||||
const input = JSON.stringify({
|
|
||||||
slotFills: {},
|
|
||||||
syntheticItems: [{ id: "x" }],
|
|
||||||
})
|
|
||||||
expect(parseEnhancementResult(input)).toBeNull()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("emptyEnhancementResult", () => {
|
|
||||||
test("returns empty slotFills and syntheticItems", () => {
|
|
||||||
const result = emptyEnhancementResult()
|
|
||||||
expect(result.slotFills).toEqual({})
|
|
||||||
expect(result.syntheticItems).toEqual([])
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("schema sync", () => {
|
|
||||||
const referencePayloads = [
|
|
||||||
{
|
|
||||||
name: "full payload with null slot fill",
|
|
||||||
payload: {
|
|
||||||
slotFills: {
|
|
||||||
"weather-1": { insight: "Rain after 3pm", crossSource: null },
|
|
||||||
"cal-2": { summary: "Busy morning" },
|
|
||||||
},
|
|
||||||
syntheticItems: [
|
|
||||||
{ id: "briefing-morning", type: "briefing", text: "Light day ahead." },
|
|
||||||
{ id: "nudge-umbrella", type: "nudge", text: "Bring an umbrella." },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "empty collections",
|
|
||||||
payload: { slotFills: {}, syntheticItems: [] },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "slot fills only",
|
|
||||||
payload: {
|
|
||||||
slotFills: { "item-1": { slot: "filled" } },
|
|
||||||
syntheticItems: [],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "synthetic items only",
|
|
||||||
payload: {
|
|
||||||
slotFills: {},
|
|
||||||
syntheticItems: [{ id: "insight-1", type: "insight", text: "Something." }],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
for (const { name, payload } of referencePayloads) {
|
|
||||||
test(`arktype and JSON Schema agree on: ${name}`, () => {
|
|
||||||
// arktype accepts it
|
|
||||||
const parsed = parseEnhancementResult(JSON.stringify(payload))
|
|
||||||
expect(parsed).not.toBeNull()
|
|
||||||
|
|
||||||
// JSON Schema structure matches
|
|
||||||
const jsonSchema = enhancementResultJsonSchema
|
|
||||||
expect(Object.keys(jsonSchema.properties).sort()).toEqual(
|
|
||||||
Object.keys(payload).sort(),
|
|
||||||
)
|
|
||||||
expect([...jsonSchema.required].sort()).toEqual(Object.keys(payload).sort())
|
|
||||||
|
|
||||||
// syntheticItems item schema has the right required fields
|
|
||||||
const itemSchema = jsonSchema.properties.syntheticItems.items
|
|
||||||
expect([...itemSchema.required].sort()).toEqual(["id", "text", "type"])
|
|
||||||
|
|
||||||
// Verify each synthetic item has exactly the fields the JSON Schema expects
|
|
||||||
for (const item of payload.syntheticItems) {
|
|
||||||
expect(Object.keys(item).sort()).toEqual([...itemSchema.required].sort())
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
test("JSON Schema rejects what arktype rejects: missing required field", () => {
|
|
||||||
// Missing syntheticItems
|
|
||||||
expect(parseEnhancementResult(JSON.stringify({ slotFills: {} }))).toBeNull()
|
|
||||||
|
|
||||||
// JSON Schema also requires it
|
|
||||||
expect(enhancementResultJsonSchema.required).toContain("syntheticItems")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("JSON Schema rejects what arktype rejects: wrong slot fill value type", () => {
|
|
||||||
const bad = { slotFills: { "item-1": { slot: 42 } }, syntheticItems: [] }
|
|
||||||
|
|
||||||
// arktype rejects it
|
|
||||||
expect(parseEnhancementResult(JSON.stringify(bad))).toBeNull()
|
|
||||||
|
|
||||||
// JSON Schema only allows string or null for slot values
|
|
||||||
const slotValueTypes =
|
|
||||||
enhancementResultJsonSchema.properties.slotFills.additionalProperties
|
|
||||||
.additionalProperties.type
|
|
||||||
expect(slotValueTypes).toContain("string")
|
|
||||||
expect(slotValueTypes).toContain("null")
|
|
||||||
expect(slotValueTypes).not.toContain("number")
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
import { type } from "arktype"
|
|
||||||
|
|
||||||
const SyntheticItem = type({
|
|
||||||
id: "string",
|
|
||||||
type: "string",
|
|
||||||
text: "string",
|
|
||||||
})
|
|
||||||
|
|
||||||
const EnhancementResult = type({
|
|
||||||
slotFills: "Record<string, Record<string, string | null>>",
|
|
||||||
syntheticItems: SyntheticItem.array(),
|
|
||||||
})
|
|
||||||
|
|
||||||
export type SyntheticItem = typeof SyntheticItem.infer
|
|
||||||
export type EnhancementResult = typeof EnhancementResult.infer
|
|
||||||
|
|
||||||
/**
|
|
||||||
* JSON Schema passed to OpenRouter's structured output.
|
|
||||||
* OpenRouter doesn't support arktype, so this is maintained separately.
|
|
||||||
*
|
|
||||||
* ⚠️ Must stay in sync with EnhancementResult above.
|
|
||||||
* If you add/remove fields, update both schemas.
|
|
||||||
*/
|
|
||||||
export const enhancementResultJsonSchema = {
|
|
||||||
type: "object",
|
|
||||||
properties: {
|
|
||||||
slotFills: {
|
|
||||||
type: "object",
|
|
||||||
description:
|
|
||||||
"Map of feed item ID to an object of slot name to filled text content. Use null for slots that cannot be meaningfully filled.",
|
|
||||||
additionalProperties: {
|
|
||||||
type: "object",
|
|
||||||
additionalProperties: {
|
|
||||||
type: ["string", "null"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
syntheticItems: {
|
|
||||||
type: "array",
|
|
||||||
description:
|
|
||||||
"New feed items to inject (briefings, nudges, cross-source insights). Keep these short and actionable.",
|
|
||||||
items: {
|
|
||||||
type: "object",
|
|
||||||
properties: {
|
|
||||||
id: {
|
|
||||||
type: "string",
|
|
||||||
description: "Unique ID, e.g. 'briefing-morning'",
|
|
||||||
},
|
|
||||||
type: {
|
|
||||||
type: "string",
|
|
||||||
description: "One of: 'briefing', 'nudge', 'insight'",
|
|
||||||
},
|
|
||||||
text: {
|
|
||||||
type: "string",
|
|
||||||
description: "Display text, 1-3 sentences",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
required: ["id", "type", "text"],
|
|
||||||
additionalProperties: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
required: ["slotFills", "syntheticItems"],
|
|
||||||
additionalProperties: false,
|
|
||||||
} as const
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parses a JSON string into an EnhancementResult.
|
|
||||||
* Returns null if the input is malformed.
|
|
||||||
*/
|
|
||||||
export function parseEnhancementResult(json: string): EnhancementResult | null {
|
|
||||||
let parsed: unknown
|
|
||||||
try {
|
|
||||||
parsed = JSON.parse(json)
|
|
||||||
} catch {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = EnhancementResult(parsed)
|
|
||||||
if (result instanceof type.errors) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
export function emptyEnhancementResult(): EnhancementResult {
|
|
||||||
return { slotFills: {}, syntheticItems: [] }
|
|
||||||
}
|
|
||||||
@@ -47,7 +47,7 @@ function buildTestApp(sessionManager: UserSessionManager, userId?: string) {
|
|||||||
|
|
||||||
describe("GET /api/feed", () => {
|
describe("GET /api/feed", () => {
|
||||||
test("returns 401 without auth", async () => {
|
test("returns 401 without auth", async () => {
|
||||||
const manager = new UserSessionManager({ providers: [] })
|
const manager = new UserSessionManager([])
|
||||||
const app = buildTestApp(manager)
|
const app = buildTestApp(manager)
|
||||||
|
|
||||||
const res = await app.request("/api/feed")
|
const res = await app.request("/api/feed")
|
||||||
@@ -65,9 +65,7 @@ describe("GET /api/feed", () => {
|
|||||||
data: { value: 42 },
|
data: { value: 42 },
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
const manager = new UserSessionManager({
|
const manager = new UserSessionManager([() => createStubSource("test", items)])
|
||||||
providers: [() => createStubSource("test", items)],
|
|
||||||
})
|
|
||||||
const app = buildTestApp(manager, "user-1")
|
const app = buildTestApp(manager, "user-1")
|
||||||
|
|
||||||
// Prime the cache
|
// Prime the cache
|
||||||
@@ -97,9 +95,7 @@ describe("GET /api/feed", () => {
|
|||||||
data: { fresh: true },
|
data: { fresh: true },
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
const manager = new UserSessionManager({
|
const manager = new UserSessionManager([() => createStubSource("test", items)])
|
||||||
providers: [() => createStubSource("test", items)],
|
|
||||||
})
|
|
||||||
const app = buildTestApp(manager, "user-1")
|
const app = buildTestApp(manager, "user-1")
|
||||||
|
|
||||||
// No prior refresh — lastFeed() returns null, handler should call refresh()
|
// No prior refresh — lastFeed() returns null, handler should call refresh()
|
||||||
@@ -129,7 +125,7 @@ describe("GET /api/feed", () => {
|
|||||||
throw new Error("connection timeout")
|
throw new Error("connection timeout")
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
const manager = new UserSessionManager({ providers: [() => failingSource] })
|
const manager = new UserSessionManager([() => failingSource])
|
||||||
const app = buildTestApp(manager, "user-1")
|
const app = buildTestApp(manager, "user-1")
|
||||||
|
|
||||||
const res = await app.request("/api/feed")
|
const res = await app.request("/api/feed")
|
||||||
|
|||||||
@@ -5,11 +5,7 @@ import { createMiddleware } from "hono/factory"
|
|||||||
import type { AuthSessionMiddleware } from "../auth/session-middleware.ts"
|
import type { AuthSessionMiddleware } from "../auth/session-middleware.ts"
|
||||||
import type { UserSessionManager } from "../session/index.ts"
|
import type { UserSessionManager } from "../session/index.ts"
|
||||||
|
|
||||||
type Env = {
|
type Env = { Variables: { sessionManager: UserSessionManager } }
|
||||||
Variables: {
|
|
||||||
sessionManager: UserSessionManager
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
interface FeedHttpHandlersDeps {
|
interface FeedHttpHandlersDeps {
|
||||||
sessionManager: UserSessionManager
|
sessionManager: UserSessionManager
|
||||||
@@ -33,7 +29,7 @@ async function handleGetFeed(c: Context<Env>) {
|
|||||||
const sessionManager = c.get("sessionManager")
|
const sessionManager = c.get("sessionManager")
|
||||||
const session = sessionManager.getOrCreate(user.id)
|
const session = sessionManager.getOrCreate(user.id)
|
||||||
|
|
||||||
const feed = await session.feed()
|
const feed = session.engine.lastFeed() ?? (await session.engine.refresh())
|
||||||
|
|
||||||
return c.json({
|
return c.json({
|
||||||
items: feed.items,
|
items: feed.items,
|
||||||
|
|||||||
@@ -2,30 +2,14 @@ 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 { mockAuthSessionMiddleware, requireSession } from "./auth/session-middleware.ts"
|
import { requireSession } from "./auth/session-middleware.ts"
|
||||||
import { createFeedEnhancer } from "./enhancement/enhance-feed.ts"
|
|
||||||
import { createLlmClient } from "./enhancement/llm-client.ts"
|
|
||||||
import { registerFeedHttpHandlers } from "./feed/http.ts"
|
import { registerFeedHttpHandlers } from "./feed/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"
|
||||||
|
|
||||||
function main() {
|
function main() {
|
||||||
const openrouterApiKey = process.env.OPENROUTER_API_KEY
|
const sessionManager = new UserSessionManager([
|
||||||
const feedEnhancer = openrouterApiKey
|
|
||||||
? createFeedEnhancer({
|
|
||||||
client: createLlmClient({
|
|
||||||
apiKey: openrouterApiKey,
|
|
||||||
model: process.env.OPENROUTER_MODEL || undefined,
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
: null
|
|
||||||
if (!feedEnhancer) {
|
|
||||||
console.warn("[enhancement] OPENROUTER_API_KEY not set — feed enhancement disabled")
|
|
||||||
}
|
|
||||||
|
|
||||||
const sessionManager = new UserSessionManager({
|
|
||||||
providers: [
|
|
||||||
() => new LocationSource(),
|
() => new LocationSource(),
|
||||||
new WeatherSourceProvider({
|
new WeatherSourceProvider({
|
||||||
credentials: {
|
credentials: {
|
||||||
@@ -35,25 +19,14 @@ function main() {
|
|||||||
serviceId: process.env.WEATHERKIT_SERVICE_ID!,
|
serviceId: process.env.WEATHERKIT_SERVICE_ID!,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
],
|
])
|
||||||
feedEnhancer,
|
|
||||||
})
|
|
||||||
|
|
||||||
const app = new Hono()
|
const app = new Hono()
|
||||||
|
|
||||||
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, { sessionManager, authSessionMiddleware: requireSession })
|
||||||
|
|
||||||
registerFeedHttpHandlers(app, {
|
|
||||||
sessionManager,
|
|
||||||
authSessionMiddleware,
|
|
||||||
})
|
|
||||||
registerLocationHttpHandlers(app, { sessionManager })
|
registerLocationHttpHandlers(app, { sessionManager })
|
||||||
|
|
||||||
return app
|
return app
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ const mockWeatherClient: WeatherKitClient = {
|
|||||||
|
|
||||||
describe("UserSessionManager", () => {
|
describe("UserSessionManager", () => {
|
||||||
test("getOrCreate creates session on first call", () => {
|
test("getOrCreate creates session on first call", () => {
|
||||||
const manager = new UserSessionManager({ providers: [() => new LocationSource()] })
|
const manager = new UserSessionManager([() => new LocationSource()])
|
||||||
|
|
||||||
const session = manager.getOrCreate("user-1")
|
const session = manager.getOrCreate("user-1")
|
||||||
|
|
||||||
@@ -21,7 +21,7 @@ describe("UserSessionManager", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test("getOrCreate returns same session for same user", () => {
|
test("getOrCreate returns same session for same user", () => {
|
||||||
const manager = new UserSessionManager({ providers: [() => new LocationSource()] })
|
const manager = new UserSessionManager([() => new LocationSource()])
|
||||||
|
|
||||||
const session1 = manager.getOrCreate("user-1")
|
const session1 = manager.getOrCreate("user-1")
|
||||||
const session2 = manager.getOrCreate("user-1")
|
const session2 = manager.getOrCreate("user-1")
|
||||||
@@ -30,7 +30,7 @@ describe("UserSessionManager", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test("getOrCreate returns different sessions for different users", () => {
|
test("getOrCreate returns different sessions for different users", () => {
|
||||||
const manager = new UserSessionManager({ providers: [() => new LocationSource()] })
|
const manager = new UserSessionManager([() => new LocationSource()])
|
||||||
|
|
||||||
const session1 = manager.getOrCreate("user-1")
|
const session1 = manager.getOrCreate("user-1")
|
||||||
const session2 = manager.getOrCreate("user-2")
|
const session2 = manager.getOrCreate("user-2")
|
||||||
@@ -39,7 +39,7 @@ describe("UserSessionManager", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test("each user gets independent source instances", () => {
|
test("each user gets independent source instances", () => {
|
||||||
const manager = new UserSessionManager({ providers: [() => new LocationSource()] })
|
const manager = new UserSessionManager([() => new LocationSource()])
|
||||||
|
|
||||||
const session1 = manager.getOrCreate("user-1")
|
const session1 = manager.getOrCreate("user-1")
|
||||||
const session2 = manager.getOrCreate("user-2")
|
const session2 = manager.getOrCreate("user-2")
|
||||||
@@ -51,7 +51,7 @@ describe("UserSessionManager", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test("remove destroys session and allows re-creation", () => {
|
test("remove destroys session and allows re-creation", () => {
|
||||||
const manager = new UserSessionManager({ providers: [() => new LocationSource()] })
|
const manager = new UserSessionManager([() => new LocationSource()])
|
||||||
|
|
||||||
const session1 = manager.getOrCreate("user-1")
|
const session1 = manager.getOrCreate("user-1")
|
||||||
manager.remove("user-1")
|
manager.remove("user-1")
|
||||||
@@ -61,13 +61,13 @@ describe("UserSessionManager", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test("remove is no-op for unknown user", () => {
|
test("remove is no-op for unknown user", () => {
|
||||||
const manager = new UserSessionManager({ providers: [() => new LocationSource()] })
|
const manager = new UserSessionManager([() => new LocationSource()])
|
||||||
|
|
||||||
expect(() => manager.remove("unknown")).not.toThrow()
|
expect(() => manager.remove("unknown")).not.toThrow()
|
||||||
})
|
})
|
||||||
|
|
||||||
test("accepts function providers", async () => {
|
test("accepts function providers", async () => {
|
||||||
const manager = new UserSessionManager({ providers: [() => new LocationSource()] })
|
const manager = new UserSessionManager([() => new LocationSource()])
|
||||||
|
|
||||||
const session = manager.getOrCreate("user-1")
|
const session = manager.getOrCreate("user-1")
|
||||||
const result = await session.engine.refresh()
|
const result = await session.engine.refresh()
|
||||||
@@ -77,9 +77,7 @@ describe("UserSessionManager", () => {
|
|||||||
|
|
||||||
test("accepts object providers", () => {
|
test("accepts object providers", () => {
|
||||||
const provider = new WeatherSourceProvider({ client: mockWeatherClient })
|
const provider = new WeatherSourceProvider({ client: mockWeatherClient })
|
||||||
const manager = new UserSessionManager({
|
const manager = new UserSessionManager([() => new LocationSource(), provider])
|
||||||
providers: [() => new LocationSource(), provider],
|
|
||||||
})
|
|
||||||
|
|
||||||
const session = manager.getOrCreate("user-1")
|
const session = manager.getOrCreate("user-1")
|
||||||
|
|
||||||
@@ -88,9 +86,7 @@ describe("UserSessionManager", () => {
|
|||||||
|
|
||||||
test("accepts mixed providers", () => {
|
test("accepts mixed providers", () => {
|
||||||
const provider = new WeatherSourceProvider({ client: mockWeatherClient })
|
const provider = new WeatherSourceProvider({ client: mockWeatherClient })
|
||||||
const manager = new UserSessionManager({
|
const manager = new UserSessionManager([() => new LocationSource(), provider])
|
||||||
providers: [() => new LocationSource(), provider],
|
|
||||||
})
|
|
||||||
|
|
||||||
const session = manager.getOrCreate("user-1")
|
const session = manager.getOrCreate("user-1")
|
||||||
|
|
||||||
@@ -99,7 +95,7 @@ describe("UserSessionManager", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test("refresh returns feed result through session", async () => {
|
test("refresh returns feed result through session", async () => {
|
||||||
const manager = new UserSessionManager({ providers: [() => new LocationSource()] })
|
const manager = new UserSessionManager([() => new LocationSource()])
|
||||||
|
|
||||||
const session = manager.getOrCreate("user-1")
|
const session = manager.getOrCreate("user-1")
|
||||||
const result = await session.engine.refresh()
|
const result = await session.engine.refresh()
|
||||||
@@ -111,7 +107,7 @@ describe("UserSessionManager", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test("location update via executeAction works", async () => {
|
test("location update via executeAction works", async () => {
|
||||||
const manager = new UserSessionManager({ providers: [() => new LocationSource()] })
|
const manager = new UserSessionManager([() => new LocationSource()])
|
||||||
|
|
||||||
const session = manager.getOrCreate("user-1")
|
const session = manager.getOrCreate("user-1")
|
||||||
await session.engine.executeAction("aelis.location", "update-location", {
|
await session.engine.executeAction("aelis.location", "update-location", {
|
||||||
@@ -126,7 +122,7 @@ describe("UserSessionManager", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test("subscribe receives updates after location push", async () => {
|
test("subscribe receives updates after location push", async () => {
|
||||||
const manager = new UserSessionManager({ providers: [() => new LocationSource()] })
|
const manager = new UserSessionManager([() => new LocationSource()])
|
||||||
const callback = mock()
|
const callback = mock()
|
||||||
|
|
||||||
const session = manager.getOrCreate("user-1")
|
const session = manager.getOrCreate("user-1")
|
||||||
@@ -146,7 +142,7 @@ describe("UserSessionManager", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test("remove stops reactive updates", async () => {
|
test("remove stops reactive updates", async () => {
|
||||||
const manager = new UserSessionManager({ providers: [() => new LocationSource()] })
|
const manager = new UserSessionManager([() => new LocationSource()])
|
||||||
const callback = mock()
|
const callback = mock()
|
||||||
|
|
||||||
const session = manager.getOrCreate("user-1")
|
const session = manager.getOrCreate("user-1")
|
||||||
|
|||||||
@@ -1,21 +1,13 @@
|
|||||||
import type { FeedEnhancer } from "../enhancement/enhance-feed.ts"
|
|
||||||
import type { FeedSourceProviderInput } from "./feed-source-provider.ts"
|
import type { FeedSourceProviderInput } from "./feed-source-provider.ts"
|
||||||
|
|
||||||
import { UserSession } from "./user-session.ts"
|
import { UserSession } from "./user-session.ts"
|
||||||
|
|
||||||
export interface UserSessionManagerConfig {
|
|
||||||
providers: FeedSourceProviderInput[]
|
|
||||||
feedEnhancer?: FeedEnhancer | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export class UserSessionManager {
|
export class UserSessionManager {
|
||||||
private sessions = new Map<string, UserSession>()
|
private sessions = new Map<string, UserSession>()
|
||||||
private readonly providers: FeedSourceProviderInput[]
|
private readonly providers: FeedSourceProviderInput[]
|
||||||
private readonly feedEnhancer: FeedEnhancer | null
|
|
||||||
|
|
||||||
constructor(config: UserSessionManagerConfig) {
|
constructor(providers: FeedSourceProviderInput[]) {
|
||||||
this.providers = config.providers
|
this.providers = providers
|
||||||
this.feedEnhancer = config.feedEnhancer ?? null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
getOrCreate(userId: string): UserSession {
|
getOrCreate(userId: string): UserSession {
|
||||||
@@ -24,7 +16,7 @@ export class UserSessionManager {
|
|||||||
const sources = this.providers.map((p) =>
|
const sources = this.providers.map((p) =>
|
||||||
typeof p === "function" ? p(userId) : p.feedSourceForUser(userId),
|
typeof p === "function" ? p(userId) : p.feedSourceForUser(userId),
|
||||||
)
|
)
|
||||||
session = new UserSession(sources, this.feedEnhancer)
|
session = new UserSession(sources)
|
||||||
this.sessions.set(userId, session)
|
this.sessions.set(userId, session)
|
||||||
}
|
}
|
||||||
return session
|
return session
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import type { ActionDefinition, ContextEntry, FeedItem, FeedSource } from "@aelis/core"
|
import type { ActionDefinition, ContextEntry, FeedSource } from "@aelis/core"
|
||||||
|
|
||||||
import { LocationSource } from "@aelis/source-location"
|
import { LocationSource } from "@aelis/source-location"
|
||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
|
|
||||||
import { UserSession } from "./user-session.ts"
|
import { UserSession } from "./user-session.ts"
|
||||||
|
|
||||||
function createStubSource(id: string, items: FeedItem[] = []): FeedSource {
|
function createStubSource(id: string): FeedSource {
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
async listActions(): Promise<Record<string, ActionDefinition>> {
|
async listActions(): Promise<Record<string, ActionDefinition>> {
|
||||||
@@ -18,7 +18,7 @@ function createStubSource(id: string, items: FeedItem[] = []): FeedSource {
|
|||||||
return null
|
return null
|
||||||
},
|
},
|
||||||
async fetchItems() {
|
async fetchItems() {
|
||||||
return items
|
return []
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -70,141 +70,3 @@ describe("UserSession", () => {
|
|||||||
expect(location.lastLocation!.lat).toBe(51.5)
|
expect(location.lastLocation!.lat).toBe(51.5)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("UserSession.feed", () => {
|
|
||||||
test("returns feed items without enhancer", async () => {
|
|
||||||
const items: FeedItem[] = [
|
|
||||||
{
|
|
||||||
id: "item-1",
|
|
||||||
type: "test",
|
|
||||||
timestamp: new Date("2025-01-01T00:00:00.000Z"),
|
|
||||||
data: { value: 42 },
|
|
||||||
},
|
|
||||||
]
|
|
||||||
const session = new UserSession([createStubSource("test", items)])
|
|
||||||
|
|
||||||
const result = await session.feed()
|
|
||||||
|
|
||||||
expect(result.items).toHaveLength(1)
|
|
||||||
expect(result.items[0]!.id).toBe("item-1")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("returns enhanced items when enhancer is provided", async () => {
|
|
||||||
const items: FeedItem[] = [
|
|
||||||
{
|
|
||||||
id: "item-1",
|
|
||||||
type: "test",
|
|
||||||
timestamp: new Date("2025-01-01T00:00:00.000Z"),
|
|
||||||
data: { value: 42 },
|
|
||||||
},
|
|
||||||
]
|
|
||||||
const enhancer = async (feedItems: FeedItem[]) =>
|
|
||||||
feedItems.map((item) => ({ ...item, data: { ...item.data, enhanced: true } }))
|
|
||||||
|
|
||||||
const session = new UserSession([createStubSource("test", items)], enhancer)
|
|
||||||
|
|
||||||
const result = await session.feed()
|
|
||||||
|
|
||||||
expect(result.items).toHaveLength(1)
|
|
||||||
expect(result.items[0]!.data.enhanced).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("caches enhanced items on subsequent calls", async () => {
|
|
||||||
const items: FeedItem[] = [
|
|
||||||
{
|
|
||||||
id: "item-1",
|
|
||||||
type: "test",
|
|
||||||
timestamp: new Date("2025-01-01T00:00:00.000Z"),
|
|
||||||
data: { value: 42 },
|
|
||||||
},
|
|
||||||
]
|
|
||||||
let enhancerCallCount = 0
|
|
||||||
const enhancer = async (feedItems: FeedItem[]) => {
|
|
||||||
enhancerCallCount++
|
|
||||||
return feedItems.map((item) => ({ ...item, data: { ...item.data, enhanced: true } }))
|
|
||||||
}
|
|
||||||
|
|
||||||
const session = new UserSession([createStubSource("test", items)], enhancer)
|
|
||||||
|
|
||||||
const result1 = await session.feed()
|
|
||||||
expect(result1.items[0]!.data.enhanced).toBe(true)
|
|
||||||
expect(enhancerCallCount).toBe(1)
|
|
||||||
|
|
||||||
const result2 = await session.feed()
|
|
||||||
expect(result2.items[0]!.data.enhanced).toBe(true)
|
|
||||||
expect(enhancerCallCount).toBe(1)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("re-enhances after engine refresh with new data", async () => {
|
|
||||||
let currentItems: FeedItem[] = [
|
|
||||||
{
|
|
||||||
id: "item-1",
|
|
||||||
type: "test",
|
|
||||||
timestamp: new Date("2025-01-01T00:00:00.000Z"),
|
|
||||||
data: { version: 1 },
|
|
||||||
},
|
|
||||||
]
|
|
||||||
const source = createStubSource("test", currentItems)
|
|
||||||
// Make fetchItems dynamic so refresh returns new data
|
|
||||||
source.fetchItems = async () => currentItems
|
|
||||||
|
|
||||||
const enhancedVersions: number[] = []
|
|
||||||
const enhancer = async (feedItems: FeedItem[]) => {
|
|
||||||
const version = feedItems[0]!.data.version as number
|
|
||||||
enhancedVersions.push(version)
|
|
||||||
return feedItems.map((item) => ({
|
|
||||||
...item,
|
|
||||||
data: { ...item.data, enhanced: true },
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
const session = new UserSession([source], enhancer)
|
|
||||||
|
|
||||||
// First feed triggers refresh + enhancement
|
|
||||||
const result1 = await session.feed()
|
|
||||||
expect(result1.items[0]!.data.version).toBe(1)
|
|
||||||
expect(result1.items[0]!.data.enhanced).toBe(true)
|
|
||||||
|
|
||||||
// Update source data and trigger engine refresh
|
|
||||||
currentItems = [
|
|
||||||
{
|
|
||||||
id: "item-1",
|
|
||||||
type: "test",
|
|
||||||
timestamp: new Date("2025-01-02T00:00:00.000Z"),
|
|
||||||
data: { version: 2 },
|
|
||||||
},
|
|
||||||
]
|
|
||||||
await session.engine.refresh()
|
|
||||||
|
|
||||||
// Wait for subscriber-triggered background enhancement
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
|
||||||
|
|
||||||
// feed() should now serve re-enhanced items with version 2
|
|
||||||
const result2 = await session.feed()
|
|
||||||
expect(result2.items[0]!.data.version).toBe(2)
|
|
||||||
expect(result2.items[0]!.data.enhanced).toBe(true)
|
|
||||||
expect(enhancedVersions).toEqual([1, 2])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("falls back to unenhanced items when enhancer throws", async () => {
|
|
||||||
const items: FeedItem[] = [
|
|
||||||
{
|
|
||||||
id: "item-1",
|
|
||||||
type: "test",
|
|
||||||
timestamp: new Date("2025-01-01T00:00:00.000Z"),
|
|
||||||
data: { value: 42 },
|
|
||||||
},
|
|
||||||
]
|
|
||||||
const enhancer = async () => {
|
|
||||||
throw new Error("enhancement exploded")
|
|
||||||
}
|
|
||||||
|
|
||||||
const session = new UserSession([createStubSource("test", items)], enhancer)
|
|
||||||
|
|
||||||
const result = await session.feed()
|
|
||||||
|
|
||||||
expect(result.items).toHaveLength(1)
|
|
||||||
expect(result.items[0]!.id).toBe("item-1")
|
|
||||||
expect(result.items[0]!.data.value).toBe(42)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|||||||
@@ -1,104 +1,24 @@
|
|||||||
import { FeedEngine, type FeedItem, type FeedResult, type FeedSource } from "@aelis/core"
|
import { FeedEngine, type FeedSource } from "@aelis/core"
|
||||||
|
|
||||||
import type { FeedEnhancer } from "../enhancement/enhance-feed.ts"
|
|
||||||
|
|
||||||
export class UserSession {
|
export class UserSession {
|
||||||
readonly engine: FeedEngine
|
readonly engine: FeedEngine
|
||||||
private sources = new Map<string, FeedSource>()
|
private sources = new Map<string, FeedSource>()
|
||||||
private readonly enhancer: FeedEnhancer | null
|
|
||||||
private enhancedItems: FeedItem[] | null = null
|
|
||||||
/** The FeedResult that enhancedItems was derived from. */
|
|
||||||
private enhancedSource: FeedResult | null = null
|
|
||||||
private enhancingPromise: Promise<void> | null = null
|
|
||||||
private unsubscribe: (() => void) | null = null
|
|
||||||
|
|
||||||
constructor(sources: FeedSource[], enhancer?: FeedEnhancer | null) {
|
constructor(sources: FeedSource[]) {
|
||||||
this.engine = new FeedEngine()
|
this.engine = new FeedEngine()
|
||||||
this.enhancer = enhancer ?? null
|
|
||||||
for (const source of sources) {
|
for (const source of sources) {
|
||||||
this.sources.set(source.id, source)
|
this.sources.set(source.id, source)
|
||||||
this.engine.register(source)
|
this.engine.register(source)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.enhancer) {
|
|
||||||
this.unsubscribe = this.engine.subscribe((result) => {
|
|
||||||
this.invalidateEnhancement()
|
|
||||||
this.runEnhancement(result)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
this.engine.start()
|
this.engine.start()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the current feed, refreshing if the engine cache expired.
|
|
||||||
* Enhancement runs eagerly on engine updates; this method awaits
|
|
||||||
* any in-flight enhancement or triggers one if needed.
|
|
||||||
*/
|
|
||||||
async feed(): Promise<FeedResult> {
|
|
||||||
const cached = this.engine.lastFeed()
|
|
||||||
const result = cached ?? (await this.engine.refresh())
|
|
||||||
|
|
||||||
if (!this.enhancer) {
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wait for any in-flight background enhancement to finish
|
|
||||||
if (this.enhancingPromise) {
|
|
||||||
await this.enhancingPromise
|
|
||||||
}
|
|
||||||
|
|
||||||
// Serve cached enhancement only if it matches the current engine result
|
|
||||||
if (this.enhancedItems && this.enhancedSource === result) {
|
|
||||||
return { ...result, items: this.enhancedItems }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stale or missing — re-enhance
|
|
||||||
await this.runEnhancement(result)
|
|
||||||
|
|
||||||
if (this.enhancedItems) {
|
|
||||||
return { ...result, items: this.enhancedItems }
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
getSource<T extends FeedSource>(sourceId: string): T | undefined {
|
getSource<T extends FeedSource>(sourceId: string): T | undefined {
|
||||||
return this.sources.get(sourceId) as T | undefined
|
return this.sources.get(sourceId) as T | undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
destroy(): void {
|
destroy(): void {
|
||||||
this.unsubscribe?.()
|
|
||||||
this.unsubscribe = null
|
|
||||||
this.engine.stop()
|
this.engine.stop()
|
||||||
this.sources.clear()
|
this.sources.clear()
|
||||||
this.invalidateEnhancement()
|
|
||||||
this.enhancingPromise = null
|
|
||||||
}
|
|
||||||
|
|
||||||
private invalidateEnhancement(): void {
|
|
||||||
this.enhancedItems = null
|
|
||||||
this.enhancedSource = null
|
|
||||||
}
|
|
||||||
|
|
||||||
private runEnhancement(result: FeedResult): Promise<void> {
|
|
||||||
const promise = this.enhance(result)
|
|
||||||
this.enhancingPromise = promise
|
|
||||||
promise.finally(() => {
|
|
||||||
if (this.enhancingPromise === promise) {
|
|
||||||
this.enhancingPromise = null
|
|
||||||
}
|
|
||||||
})
|
|
||||||
return promise
|
|
||||||
}
|
|
||||||
|
|
||||||
private async enhance(result: FeedResult): Promise<void> {
|
|
||||||
try {
|
|
||||||
this.enhancedItems = await this.enhancer!(result.items)
|
|
||||||
this.enhancedSource = result
|
|
||||||
} catch (err) {
|
|
||||||
console.error("[enhancement] Unexpected error:", err)
|
|
||||||
this.invalidateEnhancement()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
36
apps/aelis-client/src/app/(tabs)/_layout.tsx
Normal file
36
apps/aelis-client/src/app/(tabs)/_layout.tsx
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
114
apps/aelis-client/src/app/(tabs)/explore.tsx
Normal file
114
apps/aelis-client/src/app/(tabs)/explore.tsx
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
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,
|
||||||
|
},
|
||||||
|
})
|
||||||
96
apps/aelis-client/src/app/(tabs)/index.tsx
Normal file
96
apps/aelis-client/src/app/(tabs)/index.tsx
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
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,45 +1,23 @@
|
|||||||
import "react-native-reanimated"
|
import { DarkTheme, DefaultTheme, ThemeProvider } from "@react-navigation/native"
|
||||||
import { Stack } from "expo-router"
|
import { Stack } from "expo-router"
|
||||||
import { StatusBar } from "expo-status-bar"
|
import { StatusBar } from "expo-status-bar"
|
||||||
import { useColorScheme } from "react-native"
|
import "react-native-reanimated"
|
||||||
import tw, { useDeviceContext } from "twrnc"
|
import { useColorScheme } from "@/hooks/use-color-scheme"
|
||||||
|
|
||||||
|
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>
|
||||||
screenOptions={{
|
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
|
||||||
headerShown: false,
|
<Stack.Screen name="modal" options={{ presentation: "modal", title: "Modal" }} />
|
||||||
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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,48 +0,0 @@
|
|||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
import { Link } from "expo-router"
|
|
||||||
import { Pressable } from "react-native"
|
|
||||||
import { SafeAreaView } from "react-native-safe-area-context"
|
|
||||||
import tw from "twrnc"
|
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
import { FeedCard } from "@/components/ui/feed-card"
|
|
||||||
import { MonospaceText } from "@/components/ui/monospace-text"
|
|
||||||
import { SansSerifText } from "@/components/ui/sans-serif-text"
|
|
||||||
import { SerifText } from "@/components/ui/serif-text"
|
|
||||||
|
|
||||||
export default function HomeScreen() {
|
|
||||||
return (
|
|
||||||
<SafeAreaView style={tw`bg-stone-100 dark:bg-stone-900 flex-1 px-5 pt-6 gap-4`}>
|
|
||||||
<FeedCard>
|
|
||||||
<SerifText style={tw`text-4xl`}>Hello world asdsadsa</SerifText>
|
|
||||||
<SansSerifText style={tw`text-4xl font-bold`}>Hello world</SansSerifText>
|
|
||||||
<MonospaceText style={tw`text-4xl`}>asdjsakljdl</MonospaceText>
|
|
||||||
<Button style={tw`self-start`} label="Test" />
|
|
||||||
</FeedCard>
|
|
||||||
<Link href="/components" asChild>
|
|
||||||
<Pressable>
|
|
||||||
<SansSerifText style={tw`text-teal-600`}>View component library</SansSerifText>
|
|
||||||
</Pressable>
|
|
||||||
</Link>
|
|
||||||
</SafeAreaView>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
29
apps/aelis-client/src/app/modal.tsx
Normal file
29
apps/aelis-client/src/app/modal.tsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
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,
|
||||||
|
},
|
||||||
|
})
|
||||||
25
apps/aelis-client/src/components/external-link.tsx
Normal file
25
apps/aelis-client/src/components/external-link.tsx
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
18
apps/aelis-client/src/components/haptic-tab.tsx
Normal file
18
apps/aelis-client/src/components/haptic-tab.tsx
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
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)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
20
apps/aelis-client/src/components/hello-wave.tsx
Normal file
20
apps/aelis-client/src/components/hello-wave.tsx
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
82
apps/aelis-client/src/components/parallax-scroll-view.tsx
Normal file
82
apps/aelis-client/src/components/parallax-scroll-view.tsx
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
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",
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
60
apps/aelis-client/src/components/themed-text.tsx
Normal file
60
apps/aelis-client/src/components/themed-text.tsx
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
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",
|
||||||
|
},
|
||||||
|
})
|
||||||
14
apps/aelis-client/src/components/themed-view.tsx
Normal file
14
apps/aelis-client/src/components/themed-view.tsx
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
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} />
|
||||||
|
}
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
import Feather from "@expo/vector-icons/Feather"
|
|
||||||
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={<Feather name="plus" size={18} color="#e7e5e4" />}
|
|
||||||
/>
|
|
||||||
</Section>
|
|
||||||
<Section title="Trailing icon">
|
|
||||||
<Button
|
|
||||||
style={tw`self-start`}
|
|
||||||
label="Next"
|
|
||||||
trailingIcon={<Feather name="arrow-right" size={18} color="#e7e5e4" />}
|
|
||||||
/>
|
|
||||||
</Section>
|
|
||||||
<Section title="Both icons">
|
|
||||||
<Button
|
|
||||||
style={tw`self-start`}
|
|
||||||
label="Download"
|
|
||||||
leadingIcon={<Feather name="download" size={18} color="#e7e5e4" />}
|
|
||||||
trailingIcon={<Feather name="chevron-down" size={18} color="#e7e5e4" />}
|
|
||||||
/>
|
|
||||||
</Section>
|
|
||||||
</View>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const buttonShowcase: Showcase = {
|
|
||||||
title: "Button",
|
|
||||||
component: ButtonShowcase,
|
|
||||||
}
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
import { type PressableProps, Pressable, View } from "react-native"
|
|
||||||
import tw from "twrnc"
|
|
||||||
|
|
||||||
import { SansSerifText } from "./sans-serif-text"
|
|
||||||
|
|
||||||
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-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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
46
apps/aelis-client/src/components/ui/collapsible.tsx
Normal file
46
apps/aelis-client/src/components/ui/collapsible.tsx
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
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,
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
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,
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
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} />
|
|
||||||
}
|
|
||||||
32
apps/aelis-client/src/components/ui/icon-symbol.ios.tsx
Normal file
32
apps/aelis-client/src/components/ui/icon-symbol.ios.tsx
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
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,
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
41
apps/aelis-client/src/components/ui/icon-symbol.tsx
Normal file
41
apps/aelis-client/src/components/ui/icon-symbol.tsx
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
// 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} />
|
||||||
|
}
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
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,
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
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,
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
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,
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
53
apps/aelis-client/src/constants/theme.ts
Normal file
53
apps/aelis-client/src/constants/theme.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
/**
|
||||||
|
* 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
apps/aelis-client/src/hooks/use-color-scheme.ts
Normal file
1
apps/aelis-client/src/hooks/use-color-scheme.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { useColorScheme } from "react-native"
|
||||||
21
apps/aelis-client/src/hooks/use-color-scheme.web.ts
Normal file
21
apps/aelis-client/src/hooks/use-color-scheme.web.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
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"
|
||||||
|
}
|
||||||
21
apps/aelis-client/src/hooks/use-theme-color.ts
Normal file
21
apps/aelis-client/src/hooks/use-theme-color.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
/**
|
||||||
|
* 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]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
.react-router
|
|
||||||
build
|
|
||||||
node_modules
|
|
||||||
.env
|
|
||||||
README.md
|
|
||||||
7
apps/waitlist-website/.gitignore
vendored
7
apps/waitlist-website/.gitignore
vendored
@@ -1,7 +0,0 @@
|
|||||||
.DS_Store
|
|
||||||
.env
|
|
||||||
/node_modules/
|
|
||||||
|
|
||||||
# React Router
|
|
||||||
/.react-router/
|
|
||||||
/build/
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
FROM oven/bun:1 AS development-dependencies-env
|
|
||||||
COPY . /app
|
|
||||||
WORKDIR /app
|
|
||||||
RUN bun install
|
|
||||||
|
|
||||||
FROM oven/bun:1 AS production-dependencies-env
|
|
||||||
COPY ./package.json /app/
|
|
||||||
WORKDIR /app
|
|
||||||
RUN bun install --production
|
|
||||||
|
|
||||||
FROM oven/bun:1 AS build-env
|
|
||||||
COPY . /app/
|
|
||||||
COPY --from=development-dependencies-env /app/node_modules /app/node_modules
|
|
||||||
WORKDIR /app
|
|
||||||
RUN bun run build
|
|
||||||
|
|
||||||
FROM node:20-alpine
|
|
||||||
COPY ./package.json /app/
|
|
||||||
COPY --from=production-dependencies-env /app/node_modules /app/node_modules
|
|
||||||
COPY --from=build-env /app/build /app/build
|
|
||||||
WORKDIR /app
|
|
||||||
CMD ["npm", "run", "start"]
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
# Welcome to React Router!
|
|
||||||
|
|
||||||
A modern, production-ready template for building full-stack React applications using React Router.
|
|
||||||
|
|
||||||
[](https://stackblitz.com/github/remix-run/react-router-templates/tree/main/default)
|
|
||||||
|
|
||||||
## Features
|
|
||||||
|
|
||||||
- 🚀 Server-side rendering
|
|
||||||
- ⚡️ Hot Module Replacement (HMR)
|
|
||||||
- 📦 Asset bundling and optimization
|
|
||||||
- 🔄 Data loading and mutations
|
|
||||||
- 🔒 TypeScript by default
|
|
||||||
- 🎉 TailwindCSS for styling
|
|
||||||
- 📖 [React Router docs](https://reactrouter.com/)
|
|
||||||
|
|
||||||
## Getting Started
|
|
||||||
|
|
||||||
### Installation
|
|
||||||
|
|
||||||
Install the dependencies:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm install
|
|
||||||
```
|
|
||||||
|
|
||||||
### Development
|
|
||||||
|
|
||||||
Start the development server with HMR:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run dev
|
|
||||||
```
|
|
||||||
|
|
||||||
Your application will be available at `http://localhost:5173`.
|
|
||||||
|
|
||||||
## Building for Production
|
|
||||||
|
|
||||||
Create a production build:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run build
|
|
||||||
```
|
|
||||||
|
|
||||||
## Deployment
|
|
||||||
|
|
||||||
### Docker Deployment
|
|
||||||
|
|
||||||
To build and run using Docker:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker build -t my-app .
|
|
||||||
|
|
||||||
# Run the container
|
|
||||||
docker run -p 3000:3000 my-app
|
|
||||||
```
|
|
||||||
|
|
||||||
The containerized application can be deployed to any platform that supports Docker, including:
|
|
||||||
|
|
||||||
- AWS ECS
|
|
||||||
- Google Cloud Run
|
|
||||||
- Azure Container Apps
|
|
||||||
- Digital Ocean App Platform
|
|
||||||
- Fly.io
|
|
||||||
- Railway
|
|
||||||
|
|
||||||
### DIY Deployment
|
|
||||||
|
|
||||||
If you're familiar with deploying Node applications, the built-in app server is production-ready.
|
|
||||||
|
|
||||||
Make sure to deploy the output of `npm run build`
|
|
||||||
|
|
||||||
```
|
|
||||||
├── package.json
|
|
||||||
├── package-lock.json (or pnpm-lock.yaml, or bun.lockb)
|
|
||||||
├── build/
|
|
||||||
│ ├── client/ # Static assets
|
|
||||||
│ └── server/ # Server-side code
|
|
||||||
```
|
|
||||||
|
|
||||||
## Styling
|
|
||||||
|
|
||||||
This template comes with [Tailwind CSS](https://tailwindcss.com/) already configured for a simple default starting experience. You can use whatever CSS framework you prefer.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
Built with ❤️ using React Router.
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
@import url("https://fonts.googleapis.com/css2?family=Source+Serif+4:ital,opsz,wght@0,8..60,200..900;1,8..60,200..900&display=swap");
|
|
||||||
@import "tailwindcss";
|
|
||||||
|
|
||||||
@source "../node_modules/streamdown/dist/*.js";
|
|
||||||
|
|
||||||
@theme {
|
|
||||||
--font-sans:
|
|
||||||
"Inter", ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji",
|
|
||||||
"Segoe UI Symbol", "Noto Color Emoji";
|
|
||||||
--font-serif: "Source Serif 4", ui-serif, serif;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root,
|
|
||||||
html,
|
|
||||||
body {
|
|
||||||
@apply w-full h-full;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes popover-in {
|
|
||||||
from {
|
|
||||||
opacity: 0;
|
|
||||||
transform: scale(0.95) translateY(4px);
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
transform: scale(1) translateY(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes popover-out {
|
|
||||||
from {
|
|
||||||
opacity: 1;
|
|
||||||
transform: scale(1) translateY(0);
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
opacity: 0;
|
|
||||||
transform: scale(0.95) translateY(4px);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
html,
|
|
||||||
body {
|
|
||||||
-webkit-font-smoothing: antialiased;
|
|
||||||
-moz-osx-font-smoothing: grayscale;
|
|
||||||
|
|
||||||
@apply bg-stone-50 dark:bg-stone-900 text-stone-900 dark:text-stone-200 selection:bg-teal-600 dark:selection:bg-teal-500 selection:text-stone-50 dark:selection:text-stone-900;
|
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
color-scheme: dark;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,117 +0,0 @@
|
|||||||
import clsx from "clsx"
|
|
||||||
import { ArrowUpIcon, FileIcon, ImageIcon, PlusIcon, XIcon } from "lucide-react"
|
|
||||||
import { motion, useAnimate } from "motion/react"
|
|
||||||
import { useEffect, useRef, useState } from "react"
|
|
||||||
import { Button, Menu, MenuItem, MenuTrigger, Popover } from "react-aria-components"
|
|
||||||
|
|
||||||
export function ChatBox({
|
|
||||||
className,
|
|
||||||
validate,
|
|
||||||
onSubmit,
|
|
||||||
}: {
|
|
||||||
className?: string
|
|
||||||
validate?: (value: string) => boolean
|
|
||||||
onSubmit: (email: string) => void
|
|
||||||
disabled?: boolean
|
|
||||||
}) {
|
|
||||||
const [scope, animate] = useAnimate()
|
|
||||||
const [shouldShowInvalid, setShouldShowInvalid] = useState(false)
|
|
||||||
const clearInvalidStateTimeout = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
||||||
|
|
||||||
useEffect(
|
|
||||||
() => () => {
|
|
||||||
if (clearInvalidStateTimeout.current) {
|
|
||||||
clearTimeout(clearInvalidStateTimeout.current)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[],
|
|
||||||
)
|
|
||||||
|
|
||||||
function showInvalidState() {
|
|
||||||
animate(scope.current, { x: [0, -6, 6, -4, 4, -2, 2, 0] }, { duration: 0.4, ease: "easeOut" })
|
|
||||||
if (clearInvalidStateTimeout.current) {
|
|
||||||
clearTimeout(clearInvalidStateTimeout.current)
|
|
||||||
}
|
|
||||||
setShouldShowInvalid(true)
|
|
||||||
clearInvalidStateTimeout.current = setTimeout(() => {
|
|
||||||
setShouldShowInvalid(false)
|
|
||||||
}, 500)
|
|
||||||
}
|
|
||||||
|
|
||||||
const onFormSubmit = (e: React.FormEvent<HTMLFormElement>) => {
|
|
||||||
e.preventDefault()
|
|
||||||
const formData = new FormData(e.currentTarget)
|
|
||||||
const email = formData.get("liame")
|
|
||||||
if (typeof email === "string") {
|
|
||||||
const trimmed = email.trim()
|
|
||||||
if (trimmed.length === 0) {
|
|
||||||
showInvalidState()
|
|
||||||
} else if (validate && !validate(trimmed)) {
|
|
||||||
showInvalidState()
|
|
||||||
} else {
|
|
||||||
onSubmit(trimmed)
|
|
||||||
e.currentTarget.reset()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<motion.form
|
|
||||||
ref={scope}
|
|
||||||
onSubmit={onFormSubmit}
|
|
||||||
className={`min-h-20 px-3 pt-2 pb-1.5 flex flex-col justify-between rounded-lg bg-stone-100 dark:bg-stone-800 border border-stone-200 dark:border-stone-700 ${className ?? ""} shadow-xs hover:shadow-sm`}
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
name="liame"
|
|
||||||
className="w-full bg-transparent outline-none focus:outline-none ring-0 focus:ring-0"
|
|
||||||
/>
|
|
||||||
<div className="w-full flex justify-between">
|
|
||||||
<MenuTrigger>
|
|
||||||
<Button className="bg-transparent hover:bg-stone-200 dark:hover:bg-stone-700 active:bg-stone-300 dark:active:bg-stone-600 data-[pressed]:bg-stone-200 dark:data-[pressed]:bg-stone-700 rounded-full flex items-center justify-center p-1 -ml-1.5 active:inset-shadow-sm outline-none transition-transform duration-200 data-[pressed]:rotate-45">
|
|
||||||
<PlusIcon size={16} />
|
|
||||||
</Button>
|
|
||||||
<Popover
|
|
||||||
offset={4}
|
|
||||||
className="origin-bottom-left rounded-lg border border-stone-200 dark:border-stone-700 bg-stone-100 dark:bg-stone-800 shadow-lg p-1 min-w-40 outline-none data-[entering]:animate-[popover-in_150ms_ease-out] data-[exiting]:animate-[popover-out_100ms_ease-in]"
|
|
||||||
placement="top start"
|
|
||||||
>
|
|
||||||
<AttachmentMenu />
|
|
||||||
</Popover>
|
|
||||||
</MenuTrigger>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={shouldShowInvalid}
|
|
||||||
className={clsx(
|
|
||||||
"transition-all rounded-full flex items-center justify-center p-1 -mr-1.5 active:scale-95",
|
|
||||||
shouldShowInvalid
|
|
||||||
? "bg-red-400 hover:bg-red-300 text-stone-200 dark:text-stone-700"
|
|
||||||
: "bg-teal-600 hover:bg-teal-500 active:bg-teal-600 text-stone-200",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{shouldShowInvalid ? <XIcon size={16} /> : <ArrowUpIcon size={16} />}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</motion.form>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function AttachmentMenu() {
|
|
||||||
return (
|
|
||||||
<Menu className="outline-none">
|
|
||||||
<MenuItem
|
|
||||||
className="flex items-center gap-2 px-2 py-1 rounded-md cursor-default outline-none hover:bg-stone-200 dark:hover:bg-stone-700 focus:bg-stone-200 dark:focus:bg-stone-700"
|
|
||||||
onAction={() => {}}
|
|
||||||
>
|
|
||||||
<ImageIcon size={14} />
|
|
||||||
Photos
|
|
||||||
</MenuItem>
|
|
||||||
<MenuItem
|
|
||||||
className="flex items-center gap-2 px-2 py-1 rounded-md cursor-default outline-none hover:bg-stone-200 dark:hover:bg-stone-700 focus:bg-stone-200 dark:focus:bg-stone-700"
|
|
||||||
onAction={() => {}}
|
|
||||||
>
|
|
||||||
<FileIcon size={14} />
|
|
||||||
Files
|
|
||||||
</MenuItem>
|
|
||||||
</Menu>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
export interface UserMessage {
|
|
||||||
role: "user"
|
|
||||||
message: string
|
|
||||||
bubbleLayoutId?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SystemMessage {
|
|
||||||
role: "system"
|
|
||||||
message: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export type Message = UserMessage | SystemMessage
|
|
||||||
|
|
||||||
function timeOfDay() {
|
|
||||||
const hours = new Date().getHours()
|
|
||||||
if (hours >= 5 && hours < 12) {
|
|
||||||
return "morning"
|
|
||||||
} else if (hours >= 12 && hours < 18) {
|
|
||||||
return "afternoon"
|
|
||||||
} else if (hours >= 18 && hours < 22) {
|
|
||||||
return "evening"
|
|
||||||
}
|
|
||||||
return "night"
|
|
||||||
}
|
|
||||||
|
|
||||||
export const INITIAL_MESSAGES: Message[] = [
|
|
||||||
{
|
|
||||||
role: "user",
|
|
||||||
message: "Who are you?",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
role: "system",
|
|
||||||
message: `Hey! I'm **Aelis** — your personal assistant that brings you the right thing, at the right time, in the right place.
|
|
||||||
|
|
||||||
- Jubilee line down? I've already found you an alternative route.
|
|
||||||
- Dinner reservation at 8? I'll have the restaurant, directions, and the menu ready before you head out.
|
|
||||||
|
|
||||||
I learn your routines, anticipate what's next, and surface what matters before you even think to look for it.
|
|
||||||
|
|
||||||
I'm not ready yet — [@kennethnym](https://x.com/kennethnym) is still building me. **Drop your email below** and I'll let you know when I'm available.`,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
export function waitListJoinedMessage(email: string): SystemMessage {
|
|
||||||
return {
|
|
||||||
role: "system",
|
|
||||||
message: `Thanks for joining the waitlist! I've sent you a confirmation email.
|
|
||||||
I'll send an email to **${email}** when I'm ready.
|
|
||||||
|
|
||||||
Have a good ${timeOfDay()}!`,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function duplicateEmailMessage(): SystemMessage {
|
|
||||||
return {
|
|
||||||
role: "system",
|
|
||||||
message: `I appreciate your excitement! You are already on the waitlist. When I am ready, I will reach out again. Have a good ${timeOfDay()} :)`,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function troubleMessage(): SystemMessage {
|
|
||||||
return {
|
|
||||||
role: "system",
|
|
||||||
message: `I apologize, but I am having trouble adding you to the waitlist. Could you refresh the page and try again please in a moment?`,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
import { useEffect, useMemo, useState } from "react"
|
|
||||||
|
|
||||||
export function useFakeStreaming(fullContent: string) {
|
|
||||||
const [currentContent, setCurrentContent] = useState("")
|
|
||||||
const [isStreaming, setIsStreaming] = useState(true)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const words = fullContent.split(" ")
|
|
||||||
|
|
||||||
let i = 0
|
|
||||||
const id = setInterval(() => {
|
|
||||||
if (i > words.length) {
|
|
||||||
setIsStreaming(false)
|
|
||||||
clearInterval(id)
|
|
||||||
} else {
|
|
||||||
setCurrentContent(words.slice(0, i).join(" ") + " ")
|
|
||||||
i++
|
|
||||||
}
|
|
||||||
}, 20)
|
|
||||||
}, [fullContent])
|
|
||||||
|
|
||||||
return useMemo(() => ({ currentContent, isStreaming }), [currentContent, isStreaming])
|
|
||||||
}
|
|
||||||
@@ -1,168 +0,0 @@
|
|||||||
import Lottie, { type LottieRef } from "lottie-react"
|
|
||||||
import { useEffect, useRef, useState } from "react"
|
|
||||||
|
|
||||||
import { useColorScheme } from "~/hooks/use-color-scheme"
|
|
||||||
import clickedAnimationDark from "~/lottie/clicked-dark.json"
|
|
||||||
import clickedAnimationLight from "~/lottie/clicked-light.json"
|
|
||||||
import loadingAnimationDark from "~/lottie/loading-dark.json"
|
|
||||||
import loadingAnimationLight from "~/lottie/loading-light.json"
|
|
||||||
import startLoadingAnimationDark from "~/lottie/start-loading-dark.json"
|
|
||||||
import startLoadingAnimationLight from "~/lottie/start-loading-light.json"
|
|
||||||
|
|
||||||
export const AnimatedLogoState = {
|
|
||||||
Idle: "idle",
|
|
||||||
Loading: "loading",
|
|
||||||
} as const
|
|
||||||
export type AnimatedLogoState = (typeof AnimatedLogoState)[keyof typeof AnimatedLogoState]
|
|
||||||
|
|
||||||
interface AnimatedLogoProps {
|
|
||||||
state: AnimatedLogoState
|
|
||||||
className?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Animation {
|
|
||||||
loop: boolean
|
|
||||||
reverse: boolean
|
|
||||||
sticky: boolean
|
|
||||||
data: unknown
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AnimatedLogo({ state, className }: AnimatedLogoProps) {
|
|
||||||
const colorScheme = useColorScheme()
|
|
||||||
const [animationQueue, setAnimationQueue] = useState<Animation[]>([])
|
|
||||||
const lottieRef: LottieRef = useRef(null)
|
|
||||||
|
|
||||||
let currentAnimation: Animation
|
|
||||||
let isIdle = false
|
|
||||||
if (animationQueue.length === 0) {
|
|
||||||
isIdle = true
|
|
||||||
currentAnimation = {
|
|
||||||
loop: false,
|
|
||||||
reverse: false,
|
|
||||||
sticky: false,
|
|
||||||
data: colorScheme === "dark" ? startLoadingAnimationDark : startLoadingAnimationLight,
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
isIdle = false
|
|
||||||
currentAnimation = animationQueue[0]
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (state === AnimatedLogoState.Loading) {
|
|
||||||
setAnimationQueue((queue) => [
|
|
||||||
...queue,
|
|
||||||
{
|
|
||||||
loop: false,
|
|
||||||
reverse: false,
|
|
||||||
sticky: false,
|
|
||||||
data: colorScheme === "dark" ? startLoadingAnimationDark : startLoadingAnimationLight,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
loop: true,
|
|
||||||
reverse: false,
|
|
||||||
sticky: false,
|
|
||||||
data: colorScheme === "dark" ? loadingAnimationDark : loadingAnimationLight,
|
|
||||||
},
|
|
||||||
])
|
|
||||||
} else if (state === AnimatedLogoState.Idle) {
|
|
||||||
setAnimationQueue((queue) => {
|
|
||||||
const last = queue.at(-1)
|
|
||||||
if (!last) {
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
last.loop &&
|
|
||||||
(last.data === loadingAnimationDark || last.data === loadingAnimationLight)
|
|
||||||
) {
|
|
||||||
return [
|
|
||||||
...queue,
|
|
||||||
{
|
|
||||||
loop: false,
|
|
||||||
sticky: false,
|
|
||||||
reverse: false,
|
|
||||||
data: colorScheme === "dark" ? loadingAnimationDark : loadingAnimationLight,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
loop: false,
|
|
||||||
sticky: false,
|
|
||||||
reverse: true,
|
|
||||||
data: colorScheme === "dark" ? startLoadingAnimationDark : startLoadingAnimationLight,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
}
|
|
||||||
return []
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}, [state])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!lottieRef.current) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (currentAnimation.reverse) {
|
|
||||||
const frames = lottieRef.current.getDuration(true)
|
|
||||||
if (frames) {
|
|
||||||
lottieRef.current.setDirection(-1)
|
|
||||||
lottieRef.current.goToAndPlay(frames - 1, true)
|
|
||||||
}
|
|
||||||
} else if (!isIdle) {
|
|
||||||
lottieRef.current.setDirection(1)
|
|
||||||
lottieRef.current.play()
|
|
||||||
}
|
|
||||||
}, [currentAnimation])
|
|
||||||
|
|
||||||
function onComplete() {
|
|
||||||
if (animationQueue.length > 0 && !animationQueue[0].sticky) {
|
|
||||||
setAnimationQueue((queue) => queue.slice(1))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function onLoopComplete() {
|
|
||||||
const current = animationQueue[0]
|
|
||||||
const next = animationQueue[1]
|
|
||||||
if (current && next && current.data === next.data && current.loop && !next.loop) {
|
|
||||||
setAnimationQueue((queue) => queue.slice(2))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function onMouseDown() {
|
|
||||||
if (state === AnimatedLogoState.Idle) {
|
|
||||||
setAnimationQueue([
|
|
||||||
{
|
|
||||||
loop: false,
|
|
||||||
sticky: true,
|
|
||||||
reverse: false,
|
|
||||||
data: colorScheme === "dark" ? clickedAnimationDark : clickedAnimationLight,
|
|
||||||
},
|
|
||||||
])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function onMouseUp() {
|
|
||||||
if (state === AnimatedLogoState.Idle) {
|
|
||||||
setAnimationQueue((queue) => [
|
|
||||||
{
|
|
||||||
loop: false,
|
|
||||||
sticky: false,
|
|
||||||
reverse: true,
|
|
||||||
data: colorScheme === "dark" ? clickedAnimationDark : clickedAnimationLight,
|
|
||||||
},
|
|
||||||
])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Lottie
|
|
||||||
lottieRef={lottieRef}
|
|
||||||
autoplay={false}
|
|
||||||
loop={currentAnimation.loop}
|
|
||||||
className={className}
|
|
||||||
animationData={currentAnimation.data}
|
|
||||||
onComplete={onComplete}
|
|
||||||
onLoopComplete={onLoopComplete}
|
|
||||||
onMouseDown={onMouseDown}
|
|
||||||
onMouseUp={onMouseUp}
|
|
||||||
onMouseLeave={onMouseUp}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
export function ProgressiveBlur({
|
|
||||||
className,
|
|
||||||
direction = "down",
|
|
||||||
}: {
|
|
||||||
className?: string
|
|
||||||
direction?: "down" | "up"
|
|
||||||
}) {
|
|
||||||
if (direction === "up") {
|
|
||||||
return (
|
|
||||||
<div className={`pointer-events-none ${className ?? ""}`}>
|
|
||||||
<div className="absolute inset-0 backdrop-blur-[1px] [mask:linear-gradient(rgba(0,0,0,0)_0%,rgba(0,0,0,1)_10%,rgba(0,0,0,1)_20%,rgba(0,0,0,0)_30%)]" />
|
|
||||||
<div className="absolute inset-0 backdrop-blur-[2px] [mask:linear-gradient(rgba(0,0,0,0)_10%,rgba(0,0,0,1)_20%,rgba(0,0,0,1)_40%,rgba(0,0,0,0)_50%)]" />
|
|
||||||
<div className="absolute inset-0 backdrop-blur-[4px] [mask:linear-gradient(rgba(0,0,0,0)_20%,rgba(0,0,0,1)_30%,rgba(0,0,0,1)_50%,rgba(0,0,0,0)_60%)]" />
|
|
||||||
<div className="absolute inset-0 backdrop-blur-[8px] [mask:linear-gradient(rgba(0,0,0,0)_30%,rgba(0,0,0,1)_40%,rgba(0,0,0,1)_60%,rgba(0,0,0,0)_70%)]" />
|
|
||||||
<div className="absolute inset-0 backdrop-blur-[16px] [mask:linear-gradient(rgba(0,0,0,0)_40%,rgba(0,0,0,1)_50%,rgba(0,0,0,1)_70%,rgba(0,0,0,0)_80%)]" />
|
|
||||||
<div className="absolute inset-0 backdrop-blur-[32px] [mask:linear-gradient(rgba(0,0,0,0)_50%,rgba(0,0,0,1)_60%,rgba(0,0,0,1)_80%,rgba(0,0,0,0)_90%)]" />
|
|
||||||
<div className="absolute inset-0 backdrop-blur-[64px] [mask:linear-gradient(rgba(0,0,0,0)_70%,rgba(0,0,0,1)_80%,rgba(0,0,0,1)_100%)]" />
|
|
||||||
<div className="absolute inset-0 bg-linear-to-t from-stone-50 dark:from-stone-900 to-transparent" />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={`pointer-events-none ${className ?? ""}`}>
|
|
||||||
<div className="absolute inset-0 backdrop-blur-[64px] [mask:linear-gradient(rgba(0,0,0,1)_0%,rgba(0,0,0,1)_20%,rgba(0,0,0,0)_30%)]" />
|
|
||||||
<div className="absolute inset-0 backdrop-blur-[32px] [mask:linear-gradient(rgba(0,0,0,0)_10%,rgba(0,0,0,1)_20%,rgba(0,0,0,1)_40%,rgba(0,0,0,0)_50%)]" />
|
|
||||||
<div className="absolute inset-0 backdrop-blur-[16px] [mask:linear-gradient(rgba(0,0,0,0)_20%,rgba(0,0,0,1)_30%,rgba(0,0,0,1)_50%,rgba(0,0,0,0)_60%)]" />
|
|
||||||
<div className="absolute inset-0 backdrop-blur-[8px] [mask:linear-gradient(rgba(0,0,0,0)_30%,rgba(0,0,0,1)_40%,rgba(0,0,0,1)_60%,rgba(0,0,0,0)_70%)]" />
|
|
||||||
<div className="absolute inset-0 backdrop-blur-[4px] [mask:linear-gradient(rgba(0,0,0,0)_40%,rgba(0,0,0,1)_50%,rgba(0,0,0,1)_70%,rgba(0,0,0,0)_80%)]" />
|
|
||||||
<div className="absolute inset-0 backdrop-blur-[2px] [mask:linear-gradient(rgba(0,0,0,0)_50%,rgba(0,0,0,1)_60%,rgba(0,0,0,1)_80%,rgba(0,0,0,0)_90%)]" />
|
|
||||||
<div className="absolute inset-0 backdrop-blur-[1px] [mask:linear-gradient(rgba(0,0,0,0)_70%,rgba(0,0,0,1)_80%,rgba(0,0,0,1)_90%,rgba(0,0,0,0)_100%)]" />
|
|
||||||
<div className="absolute inset-0 bg-linear-to-b from-stone-50 dark:from-stone-900 to-transparent" />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
import { useEffect, useState } from "react"
|
|
||||||
|
|
||||||
export const ColorScheme = {
|
|
||||||
Light: "light",
|
|
||||||
Dark: "dark",
|
|
||||||
} as const
|
|
||||||
export type ColorScheme = (typeof ColorScheme)[keyof typeof ColorScheme]
|
|
||||||
|
|
||||||
export function useColorScheme(): ColorScheme {
|
|
||||||
const [scheme, setScheme] = useState<ColorScheme>(() => {
|
|
||||||
if (typeof window === "undefined") return ColorScheme.Light
|
|
||||||
return window.matchMedia("(prefers-color-scheme: dark)").matches
|
|
||||||
? ColorScheme.Dark
|
|
||||||
: ColorScheme.Light
|
|
||||||
})
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const mql = window.matchMedia("(prefers-color-scheme: dark)")
|
|
||||||
const handler = (e: MediaQueryListEvent) => {
|
|
||||||
setScheme(e.matches ? ColorScheme.Dark : ColorScheme.Light)
|
|
||||||
}
|
|
||||||
mql.addEventListener("change", handler)
|
|
||||||
return () => mql.removeEventListener("change", handler)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
return scheme
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
{"fr":60,"h":400,"ip":0,"layers":[{"ind":3,"ty":4,"parent":2,"ks":{},"ip":0,"op":7,"st":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","p":{"a":0,"k":[160,53]},"r":{"a":0,"k":0},"s":{"a":0,"k":[336,122]}},{"ty":"fl","c":{"a":0,"k":[0,0,0,0]},"o":{"a":0,"k":0}},{"ty":"tr","o":{"a":0,"k":100}}]},{"ty":"gr","it":[{"ty":"el","p":{"a":0,"k":[160,53]},"s":{"a":0,"k":[320,106]}},{"ty":"st","c":{"a":0,"k":[0.906,0.898,0.894]},"lc":1,"lj":1,"ml":10,"o":{"a":0,"k":100},"w":{"a":0,"k":16}},{"ty":"tr","o":{"a":0,"k":100}}]}]},{"ind":2,"ty":3,"parent":1,"ks":{"a":{"a":0,"k":[160,53]},"p":{"a":0,"k":[200.5,200]},"r":{"a":1,"k":[{"t":0,"s":[-30],"i":{"x":0,"y":1},"o":{"x":0.5,"y":0}},{"t":6,"s":[-10],"h":1}]}},"ip":0,"op":7,"st":0},{"ind":5,"ty":4,"parent":4,"ks":{},"ip":0,"op":7,"st":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","p":{"a":0,"k":[160,53]},"r":{"a":0,"k":0},"s":{"a":0,"k":[336,122]}},{"ty":"fl","c":{"a":0,"k":[0,0,0,0]},"o":{"a":0,"k":0}},{"ty":"tr","o":{"a":0,"k":100}}]},{"ty":"gr","it":[{"ty":"el","p":{"a":0,"k":[160,53]},"s":{"a":0,"k":[320,106]}},{"ty":"st","c":{"a":0,"k":[0.906,0.898,0.894]},"lc":1,"lj":1,"ml":10,"o":{"a":0,"k":100},"w":{"a":0,"k":16}},{"ty":"tr","o":{"a":0,"k":100}}]}]},{"ind":4,"ty":3,"parent":1,"ks":{"a":{"a":0,"k":[160,53]},"p":{"a":0,"k":[200.594,200.176]},"r":{"a":1,"k":[{"t":0,"s":[30],"i":{"x":0,"y":1},"o":{"x":0.5,"y":0}},{"t":6,"s":[10],"h":1}]}},"ip":0,"op":7,"st":0},{"ind":1,"ty":3,"parent":0,"ks":{},"ip":0,"op":7,"st":0},{"ind":0,"ty":3,"ks":{},"ip":0,"op":7,"st":0}],"meta":{"g":"https://jitter.video"},"op":6,"v":"5.7.4","w":400}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
{"fr":60,"h":400,"ip":0,"layers":[{"ind":3,"ty":4,"parent":2,"ks":{},"ip":0,"op":7,"st":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","p":{"a":0,"k":[160,53]},"r":{"a":0,"k":0},"s":{"a":0,"k":[336,122]}},{"ty":"fl","c":{"a":0,"k":[0,0,0,0]},"o":{"a":0,"k":0}},{"ty":"tr","o":{"a":0,"k":100}}]},{"ty":"gr","it":[{"ty":"el","p":{"a":0,"k":[160,53]},"s":{"a":0,"k":[320,106]}},{"ty":"st","c":{"a":0,"k":[0.11,0.098,0.09]},"lc":1,"lj":1,"ml":10,"o":{"a":0,"k":100},"w":{"a":0,"k":16}},{"ty":"tr","o":{"a":0,"k":100}}]}]},{"ind":2,"ty":3,"parent":1,"ks":{"a":{"a":0,"k":[160,53]},"p":{"a":0,"k":[200.5,200]},"r":{"a":1,"k":[{"t":0,"s":[-30],"i":{"x":0,"y":1},"o":{"x":0.5,"y":0}},{"t":6,"s":[-10],"h":1}]}},"ip":0,"op":7,"st":0},{"ind":5,"ty":4,"parent":4,"ks":{},"ip":0,"op":7,"st":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","p":{"a":0,"k":[160,53]},"r":{"a":0,"k":0},"s":{"a":0,"k":[336,122]}},{"ty":"fl","c":{"a":0,"k":[0,0,0,0]},"o":{"a":0,"k":0}},{"ty":"tr","o":{"a":0,"k":100}}]},{"ty":"gr","it":[{"ty":"el","p":{"a":0,"k":[160,53]},"s":{"a":0,"k":[320,106]}},{"ty":"st","c":{"a":0,"k":[0.11,0.098,0.09]},"lc":1,"lj":1,"ml":10,"o":{"a":0,"k":100},"w":{"a":0,"k":16}},{"ty":"tr","o":{"a":0,"k":100}}]}]},{"ind":4,"ty":3,"parent":1,"ks":{"a":{"a":0,"k":[160,53]},"p":{"a":0,"k":[200.594,200.176]},"r":{"a":1,"k":[{"t":0,"s":[30],"i":{"x":0,"y":1},"o":{"x":0.5,"y":0}},{"t":6,"s":[10],"h":1}]}},"ip":0,"op":7,"st":0},{"ind":1,"ty":3,"parent":0,"ks":{},"ip":0,"op":7,"st":0},{"ind":0,"ty":3,"ks":{},"ip":0,"op":7,"st":0}],"meta":{"g":"https://jitter.video"},"op":6,"v":"5.7.4","w":400}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
{"fr":60,"h":400,"ip":0,"layers":[{"ind":3,"ty":4,"parent":2,"ks":{},"ip":0,"op":61,"st":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","p":{"a":0,"k":[160,26.75]},"r":{"a":0,"k":0},"s":{"a":0,"k":[336,174.5]}},{"ty":"fl","c":{"a":0,"k":[0,0,0,0]},"o":{"a":0,"k":0}},{"ty":"tr","o":{"a":0,"k":100}}]},{"ty":"gr","it":[{"ty":"el","p":{"a":1,"k":[{"t":0,"s":[160,0.5],"i":{"x":[1,1],"y":[1,1]},"o":{"x":[0,0],"y":[0,0]}},{"t":8.4,"s":[160,0.5],"i":{"x":[1,0],"y":[1,1]},"o":{"x":[0,0.5],"y":[0,0]}},{"t":30,"s":[160,53],"i":{"x":[1,1],"y":[1,1]},"o":{"x":[0,0],"y":[0,0]}},{"t":37.8,"s":[160,53],"i":{"x":[1,0],"y":[1,1]},"o":{"x":[0,0.5],"y":[0,0]}},{"t":60,"s":[160,0.5],"h":1}]},"s":{"a":1,"k":[{"t":0,"s":[320,1],"i":{"x":[1,1],"y":[1,1]},"o":{"x":[0,0],"y":[0,0]}},{"t":8.4,"s":[320,1],"i":{"x":[1,0],"y":[1,1]},"o":{"x":[0,0.5],"y":[0,0]}},{"t":30,"s":[320,106],"i":{"x":[1,1],"y":[1,1]},"o":{"x":[0,0],"y":[0,0]}},{"t":37.8,"s":[320,106],"i":{"x":[1,0],"y":[1,1]},"o":{"x":[0,0.5],"y":[0,0]}},{"t":60,"s":[320,1],"h":1}]}},{"ty":"st","c":{"a":0,"k":[0.906,0.898,0.894]},"lc":1,"lj":1,"ml":10,"o":{"a":0,"k":100},"w":{"a":0,"k":16}},{"ty":"tr","o":{"a":0,"k":100}}]}]},{"ind":2,"ty":3,"parent":1,"ks":{"a":{"a":1,"k":[{"t":0,"s":[160,0.5],"i":{"x":[1,1],"y":[1,1]},"o":{"x":[0,0],"y":[0,0]}},{"t":8.4,"s":[160,0.5],"i":{"x":[1,0],"y":[1,1]},"o":{"x":[0,0.5],"y":[0,0]}},{"t":30,"s":[160,53],"i":{"x":[1,1],"y":[1,1]},"o":{"x":[0,0],"y":[0,0]}},{"t":37.8,"s":[160,53],"i":{"x":[1,0],"y":[1,1]},"o":{"x":[0,0.5],"y":[0,0]}},{"t":60,"s":[160,0.5],"h":1}]},"p":{"a":0,"k":[200,200.014]},"r":{"a":1,"k":[{"t":0,"s":[-90],"h":1},{"t":8.4,"s":[-90],"i":{"x":0,"y":1},"o":{"x":0.5,"y":0}},{"t":30,"s":[0],"h":1},{"t":37.8,"s":[0],"i":{"x":0,"y":1},"o":{"x":0.5,"y":0}},{"t":60,"s":[90],"h":1}]}},"ip":0,"op":61,"st":0},{"ind":5,"ty":4,"parent":4,"ks":{},"ip":0,"op":61,"st":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","p":{"a":0,"k":[160,26.75]},"r":{"a":0,"k":0},"s":{"a":0,"k":[336,174.5]}},{"ty":"fl","c":{"a":0,"k":[0,0,0,0]},"o":{"a":0,"k":0}},{"ty":"tr","o":{"a":0,"k":100}}]},{"ty":"gr","it":[{"ty":"el","p":{"a":1,"k":[{"t":0,"s":[160,0.5],"i":{"x":[1,0],"y":[1,1]},"o":{"x":[0,0.5],"y":[0,0]}},{"t":30,"s":[160,53],"i":{"x":[1,0],"y":[1,1]},"o":{"x":[0,0.5],"y":[0,0]}},{"t":60,"s":[160,0.5],"h":1}]},"s":{"a":1,"k":[{"t":0,"s":[320,1],"i":{"x":[1,0],"y":[1,1]},"o":{"x":[0,0.5],"y":[0,0]}},{"t":30,"s":[320,106],"i":{"x":[1,0],"y":[1,1]},"o":{"x":[0,0.5],"y":[0,0]}},{"t":60,"s":[320,1],"h":1}]}},{"ty":"st","c":{"a":0,"k":[0.906,0.898,0.894]},"lc":1,"lj":1,"ml":10,"o":{"a":0,"k":100},"w":{"a":0,"k":16}},{"ty":"tr","o":{"a":0,"k":100}}]}]},{"ind":4,"ty":3,"parent":1,"ks":{"a":{"a":1,"k":[{"t":0,"s":[160,0.5],"i":{"x":[1,0],"y":[1,1]},"o":{"x":[0,0.5],"y":[0,0]}},{"t":30,"s":[160,53],"i":{"x":[1,0],"y":[1,1]},"o":{"x":[0,0.5],"y":[0,0]}},{"t":60,"s":[160,0.5],"h":1}]},"p":{"a":0,"k":[200.094,200.19]},"r":{"a":1,"k":[{"t":0,"s":[-90],"i":{"x":0,"y":1},"o":{"x":0.5,"y":0}},{"t":30,"s":[0],"i":{"x":0,"y":1},"o":{"x":0.5,"y":0}},{"t":60,"s":[90],"h":1}]}},"ip":0,"op":61,"st":0},{"ind":1,"ty":3,"parent":0,"ks":{},"ip":0,"op":61,"st":0},{"ind":0,"ty":3,"ks":{},"ip":0,"op":61,"st":0}],"meta":{"g":"https://jitter.video"},"op":60,"v":"5.7.4","w":400}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
{"fr":60,"h":400,"ip":0,"layers":[{"ind":3,"ty":4,"parent":2,"ks":{},"ip":0,"op":61,"st":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","p":{"a":0,"k":[160,26.75]},"r":{"a":0,"k":0},"s":{"a":0,"k":[336,174.5]}},{"ty":"fl","c":{"a":0,"k":[0,0,0,0]},"o":{"a":0,"k":0}},{"ty":"tr","o":{"a":0,"k":100}}]},{"ty":"gr","it":[{"ty":"el","p":{"a":1,"k":[{"t":0,"s":[160,0.5],"i":{"x":[1,1],"y":[1,1]},"o":{"x":[0,0],"y":[0,0]}},{"t":8.4,"s":[160,0.5],"i":{"x":[1,0],"y":[1,1]},"o":{"x":[0,0.5],"y":[0,0]}},{"t":30,"s":[160,53],"i":{"x":[1,1],"y":[1,1]},"o":{"x":[0,0],"y":[0,0]}},{"t":37.8,"s":[160,53],"i":{"x":[1,0],"y":[1,1]},"o":{"x":[0,0.5],"y":[0,0]}},{"t":60,"s":[160,0.5],"h":1}]},"s":{"a":1,"k":[{"t":0,"s":[320,1],"i":{"x":[1,1],"y":[1,1]},"o":{"x":[0,0],"y":[0,0]}},{"t":8.4,"s":[320,1],"i":{"x":[1,0],"y":[1,1]},"o":{"x":[0,0.5],"y":[0,0]}},{"t":30,"s":[320,106],"i":{"x":[1,1],"y":[1,1]},"o":{"x":[0,0],"y":[0,0]}},{"t":37.8,"s":[320,106],"i":{"x":[1,0],"y":[1,1]},"o":{"x":[0,0.5],"y":[0,0]}},{"t":60,"s":[320,1],"h":1}]}},{"ty":"st","c":{"a":0,"k":[0.11,0.098,0.09]},"lc":1,"lj":1,"ml":10,"o":{"a":0,"k":100},"w":{"a":0,"k":16}},{"ty":"tr","o":{"a":0,"k":100}}]}]},{"ind":2,"ty":3,"parent":1,"ks":{"a":{"a":1,"k":[{"t":0,"s":[160,0.5],"i":{"x":[1,1],"y":[1,1]},"o":{"x":[0,0],"y":[0,0]}},{"t":8.4,"s":[160,0.5],"i":{"x":[1,0],"y":[1,1]},"o":{"x":[0,0.5],"y":[0,0]}},{"t":30,"s":[160,53],"i":{"x":[1,1],"y":[1,1]},"o":{"x":[0,0],"y":[0,0]}},{"t":37.8,"s":[160,53],"i":{"x":[1,0],"y":[1,1]},"o":{"x":[0,0.5],"y":[0,0]}},{"t":60,"s":[160,0.5],"h":1}]},"p":{"a":0,"k":[200,200.014]},"r":{"a":1,"k":[{"t":0,"s":[-90],"h":1},{"t":8.4,"s":[-90],"i":{"x":0,"y":1},"o":{"x":0.5,"y":0}},{"t":30,"s":[0],"h":1},{"t":37.8,"s":[0],"i":{"x":0,"y":1},"o":{"x":0.5,"y":0}},{"t":60,"s":[90],"h":1}]}},"ip":0,"op":61,"st":0},{"ind":5,"ty":4,"parent":4,"ks":{},"ip":0,"op":61,"st":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","p":{"a":0,"k":[160,26.75]},"r":{"a":0,"k":0},"s":{"a":0,"k":[336,174.5]}},{"ty":"fl","c":{"a":0,"k":[0,0,0,0]},"o":{"a":0,"k":0}},{"ty":"tr","o":{"a":0,"k":100}}]},{"ty":"gr","it":[{"ty":"el","p":{"a":1,"k":[{"t":0,"s":[160,0.5],"i":{"x":[1,0],"y":[1,1]},"o":{"x":[0,0.5],"y":[0,0]}},{"t":30,"s":[160,53],"i":{"x":[1,0],"y":[1,1]},"o":{"x":[0,0.5],"y":[0,0]}},{"t":60,"s":[160,0.5],"h":1}]},"s":{"a":1,"k":[{"t":0,"s":[320,1],"i":{"x":[1,0],"y":[1,1]},"o":{"x":[0,0.5],"y":[0,0]}},{"t":30,"s":[320,106],"i":{"x":[1,0],"y":[1,1]},"o":{"x":[0,0.5],"y":[0,0]}},{"t":60,"s":[320,1],"h":1}]}},{"ty":"st","c":{"a":0,"k":[0.11,0.098,0.09]},"lc":1,"lj":1,"ml":10,"o":{"a":0,"k":100},"w":{"a":0,"k":16}},{"ty":"tr","o":{"a":0,"k":100}}]}]},{"ind":4,"ty":3,"parent":1,"ks":{"a":{"a":1,"k":[{"t":0,"s":[160,0.5],"i":{"x":[1,0],"y":[1,1]},"o":{"x":[0,0.5],"y":[0,0]}},{"t":30,"s":[160,53],"i":{"x":[1,0],"y":[1,1]},"o":{"x":[0,0.5],"y":[0,0]}},{"t":60,"s":[160,0.5],"h":1}]},"p":{"a":0,"k":[200.094,200.19]},"r":{"a":1,"k":[{"t":0,"s":[-90],"i":{"x":0,"y":1},"o":{"x":0.5,"y":0}},{"t":30,"s":[0],"i":{"x":0,"y":1},"o":{"x":0.5,"y":0}},{"t":60,"s":[90],"h":1}]}},"ip":0,"op":61,"st":0},{"ind":1,"ty":3,"parent":0,"ks":{},"ip":0,"op":61,"st":0},{"ind":0,"ty":3,"ks":{},"ip":0,"op":61,"st":0}],"meta":{"g":"https://jitter.video"},"op":60,"v":"5.7.4","w":400}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
{"fr":60,"h":400,"ip":0,"layers":[{"ind":3,"ty":4,"parent":2,"ks":{},"ip":0,"op":31,"st":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","p":{"a":0,"k":[160,26.75]},"r":{"a":0,"k":0},"s":{"a":0,"k":[336,174.5]}},{"ty":"fl","c":{"a":0,"k":[0,0,0,0]},"o":{"a":0,"k":0}},{"ty":"tr","o":{"a":0,"k":100}}]},{"ty":"gr","it":[{"ty":"el","p":{"a":1,"k":[{"t":0,"s":[160,53],"i":{"x":[1,1],"y":[1,1]},"o":{"x":[0,0],"y":[0,0]}},{"t":5.28,"s":[160,53],"i":{"x":[1,0.001],"y":[1,0.998]},"o":{"x":[0,0.349],"y":[0,0]}},{"t":30,"s":[160,0.5],"h":1}]},"s":{"a":1,"k":[{"t":0,"s":[320,106],"i":{"x":[1,1],"y":[1,1]},"o":{"x":[0,0],"y":[0,0]}},{"t":5.28,"s":[320,106],"i":{"x":[1,0.001],"y":[1,0.998]},"o":{"x":[0,0.349],"y":[0,0]}},{"t":30,"s":[320,1],"h":1}]}},{"ty":"st","c":{"a":0,"k":[0.906,0.898,0.894]},"lc":1,"lj":1,"ml":10,"o":{"a":0,"k":100},"w":{"a":0,"k":16}},{"ty":"tr","o":{"a":0,"k":100}}]}]},{"ind":2,"ty":3,"parent":1,"ks":{"a":{"a":1,"k":[{"t":0,"s":[160,53],"i":{"x":[1,1],"y":[1,1]},"o":{"x":[0,0],"y":[0,0]}},{"t":5.28,"s":[160,53],"i":{"x":[1,0.001],"y":[1,0.998]},"o":{"x":[0,0.349],"y":[0,0]}},{"t":30,"s":[160,0.5],"h":1}]},"p":{"a":0,"k":[200.5,200]},"r":{"a":1,"k":[{"t":0,"s":[-30],"h":1},{"t":5.28,"s":[-30],"i":{"x":0.001,"y":0.998},"o":{"x":0.349,"y":0}},{"t":30,"s":[-90],"h":1}]}},"ip":0,"op":31,"st":0},{"ind":5,"ty":4,"parent":4,"ks":{},"ip":0,"op":31,"st":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","p":{"a":0,"k":[160,26.75]},"r":{"a":0,"k":0},"s":{"a":0,"k":[336,174.5]}},{"ty":"fl","c":{"a":0,"k":[0,0,0,0]},"o":{"a":0,"k":0}},{"ty":"tr","o":{"a":0,"k":100}}]},{"ty":"gr","it":[{"ty":"el","p":{"a":1,"k":[{"t":0,"s":[160,53],"i":{"x":[1,0],"y":[1,0.999]},"o":{"x":[0,0.348],"y":[0,0]}},{"t":30,"s":[160,0.5],"h":1}]},"s":{"a":1,"k":[{"t":0,"s":[320,106],"i":{"x":[1,0],"y":[1,0.999]},"o":{"x":[0,0.348],"y":[0,0]}},{"t":30,"s":[320,1],"h":1}]}},{"ty":"st","c":{"a":0,"k":[0.906,0.898,0.894]},"lc":1,"lj":1,"ml":10,"o":{"a":0,"k":100},"w":{"a":0,"k":16}},{"ty":"tr","o":{"a":0,"k":100}}]}]},{"ind":4,"ty":3,"parent":1,"ks":{"a":{"a":1,"k":[{"t":0,"s":[160,53],"i":{"x":[1,0],"y":[1,0.999]},"o":{"x":[0,0.348],"y":[0,0]}},{"t":30,"s":[160,0.5],"h":1}]},"p":{"a":0,"k":[200.594,200.176]},"r":{"a":1,"k":[{"t":0,"s":[30],"i":{"x":0,"y":0.999},"o":{"x":0.348,"y":0}},{"t":30,"s":[90],"h":1}]}},"ip":0,"op":31,"st":0},{"ind":1,"ty":3,"parent":0,"ks":{},"ip":0,"op":31,"st":0},{"ind":0,"ty":3,"ks":{},"ip":0,"op":31,"st":0}],"meta":{"g":"https://jitter.video"},"op":30,"v":"5.7.4","w":400}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
{"fr":60,"h":400,"ip":0,"layers":[{"ind":3,"ty":4,"parent":2,"ks":{},"ip":0,"op":31,"st":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","p":{"a":0,"k":[160,26.75]},"r":{"a":0,"k":0},"s":{"a":0,"k":[336,174.5]}},{"ty":"fl","c":{"a":0,"k":[0,0,0,0]},"o":{"a":0,"k":0}},{"ty":"tr","o":{"a":0,"k":100}}]},{"ty":"gr","it":[{"ty":"el","p":{"a":1,"k":[{"t":0,"s":[160,53],"i":{"x":[1,1],"y":[1,1]},"o":{"x":[0,0],"y":[0,0]}},{"t":5.28,"s":[160,53],"i":{"x":[1,0.001],"y":[1,0.998]},"o":{"x":[0,0.349],"y":[0,0]}},{"t":30,"s":[160,0.5],"h":1}]},"s":{"a":1,"k":[{"t":0,"s":[320,106],"i":{"x":[1,1],"y":[1,1]},"o":{"x":[0,0],"y":[0,0]}},{"t":5.28,"s":[320,106],"i":{"x":[1,0.001],"y":[1,0.998]},"o":{"x":[0,0.349],"y":[0,0]}},{"t":30,"s":[320,1],"h":1}]}},{"ty":"st","c":{"a":0,"k":[0.11,0.098,0.09]},"lc":1,"lj":1,"ml":10,"o":{"a":0,"k":100},"w":{"a":0,"k":16}},{"ty":"tr","o":{"a":0,"k":100}}]}]},{"ind":2,"ty":3,"parent":1,"ks":{"a":{"a":1,"k":[{"t":0,"s":[160,53],"i":{"x":[1,1],"y":[1,1]},"o":{"x":[0,0],"y":[0,0]}},{"t":5.28,"s":[160,53],"i":{"x":[1,0.001],"y":[1,0.998]},"o":{"x":[0,0.349],"y":[0,0]}},{"t":30,"s":[160,0.5],"h":1}]},"p":{"a":0,"k":[200.5,200]},"r":{"a":1,"k":[{"t":0,"s":[-30],"h":1},{"t":5.28,"s":[-30],"i":{"x":0.001,"y":0.998},"o":{"x":0.349,"y":0}},{"t":30,"s":[-90],"h":1}]}},"ip":0,"op":31,"st":0},{"ind":5,"ty":4,"parent":4,"ks":{},"ip":0,"op":31,"st":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","p":{"a":0,"k":[160,26.75]},"r":{"a":0,"k":0},"s":{"a":0,"k":[336,174.5]}},{"ty":"fl","c":{"a":0,"k":[0,0,0,0]},"o":{"a":0,"k":0}},{"ty":"tr","o":{"a":0,"k":100}}]},{"ty":"gr","it":[{"ty":"el","p":{"a":1,"k":[{"t":0,"s":[160,53],"i":{"x":[1,0],"y":[1,0.999]},"o":{"x":[0,0.348],"y":[0,0]}},{"t":30,"s":[160,0.5],"h":1}]},"s":{"a":1,"k":[{"t":0,"s":[320,106],"i":{"x":[1,0],"y":[1,0.999]},"o":{"x":[0,0.348],"y":[0,0]}},{"t":30,"s":[320,1],"h":1}]}},{"ty":"st","c":{"a":0,"k":[0.11,0.098,0.09]},"lc":1,"lj":1,"ml":10,"o":{"a":0,"k":100},"w":{"a":0,"k":16}},{"ty":"tr","o":{"a":0,"k":100}}]}]},{"ind":4,"ty":3,"parent":1,"ks":{"a":{"a":1,"k":[{"t":0,"s":[160,53],"i":{"x":[1,0],"y":[1,0.999]},"o":{"x":[0,0.348],"y":[0,0]}},{"t":30,"s":[160,0.5],"h":1}]},"p":{"a":0,"k":[200.594,200.176]},"r":{"a":1,"k":[{"t":0,"s":[30],"i":{"x":0,"y":0.999},"o":{"x":0.348,"y":0}},{"t":30,"s":[90],"h":1}]}},"ip":0,"op":31,"st":0},{"ind":1,"ty":3,"parent":0,"ks":{},"ip":0,"op":31,"st":0},{"ind":0,"ty":3,"ks":{},"ip":0,"op":31,"st":0}],"meta":{"g":"https://jitter.video"},"op":30,"v":"5.7.4","w":400}
|
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
import { isRouteErrorResponse, Links, Meta, Outlet, Scripts, ScrollRestoration } from "react-router"
|
|
||||||
|
|
||||||
import type { Route } from "./+types/root"
|
|
||||||
|
|
||||||
import "./app.css"
|
|
||||||
import "streamdown/styles.css"
|
|
||||||
|
|
||||||
export const links: Route.LinksFunction = () => [
|
|
||||||
{ rel: "preconnect", href: "https://fonts.googleapis.com" },
|
|
||||||
{
|
|
||||||
rel: "preconnect",
|
|
||||||
href: "https://fonts.gstatic.com",
|
|
||||||
crossOrigin: "anonymous",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
rel: "stylesheet",
|
|
||||||
href: "https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
rel: "icon",
|
|
||||||
href: "/favicon-light.svg",
|
|
||||||
type: "image/svg+xml",
|
|
||||||
media: "(prefers-color-scheme: light)",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
rel: "icon",
|
|
||||||
href: "/favicon-dark.svg",
|
|
||||||
type: "image/svg+xml",
|
|
||||||
media: "(prefers-color-scheme: dark)",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
rel: "icon",
|
|
||||||
href: "/favicon.ico",
|
|
||||||
sizes: "any",
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
export function Layout({ children }: { children: React.ReactNode }) {
|
|
||||||
return (
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charSet="utf-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
||||||
<Meta />
|
|
||||||
<Links />
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
{children}
|
|
||||||
<ScrollRestoration />
|
|
||||||
<Scripts />
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function App() {
|
|
||||||
return <Outlet />
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
|
|
||||||
let message = "Oops!"
|
|
||||||
let details = "An unexpected error occurred."
|
|
||||||
let stack: string | undefined
|
|
||||||
|
|
||||||
if (isRouteErrorResponse(error)) {
|
|
||||||
message = error.status === 404 ? "404" : "Error"
|
|
||||||
details =
|
|
||||||
error.status === 404 ? "The requested page could not be found." : error.statusText || details
|
|
||||||
} else if (import.meta.env.DEV && error && error instanceof Error) {
|
|
||||||
details = error.message
|
|
||||||
stack = error.stack
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<main className="flex flex-col items-center justify-center w-full h-full gap-4">
|
|
||||||
<h1 className="text-6xl font-semibold">{message}</h1>
|
|
||||||
<p className="text-stone-600 dark:text-stone-400">{details}</p>
|
|
||||||
<a href="/" className="mt-4 text-sm underline opacity-50 hover:opacity-75">
|
|
||||||
Back to home
|
|
||||||
</a>
|
|
||||||
{stack && (
|
|
||||||
<pre className="mt-8 w-full max-w-2xl p-4 overflow-x-auto text-xs bg-stone-100 dark:bg-stone-800 rounded-lg">
|
|
||||||
<code>{stack}</code>
|
|
||||||
</pre>
|
|
||||||
)}
|
|
||||||
</main>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
import { type RouteConfig, index, route } from "@react-router/dev/routes"
|
|
||||||
|
|
||||||
export default [
|
|
||||||
index("routes/home.tsx"),
|
|
||||||
route("privacy", "routes/privacy-policy.tsx"),
|
|
||||||
] satisfies RouteConfig
|
|
||||||
@@ -1,395 +0,0 @@
|
|||||||
import { AnimatePresence, motion } from "motion/react"
|
|
||||||
import React, { useEffect, useLayoutEffect, useRef, useState } from "react"
|
|
||||||
import { Link, useFetcher } from "react-router"
|
|
||||||
import { Resend } from "resend"
|
|
||||||
import { Streamdown } from "streamdown"
|
|
||||||
|
|
||||||
import { ChatBox } from "~/chat/chat-box"
|
|
||||||
import {
|
|
||||||
duplicateEmailMessage,
|
|
||||||
INITIAL_MESSAGES,
|
|
||||||
troubleMessage,
|
|
||||||
waitListJoinedMessage,
|
|
||||||
type Message,
|
|
||||||
type SystemMessage,
|
|
||||||
type UserMessage,
|
|
||||||
} from "~/chat/message"
|
|
||||||
import { useFakeStreaming } from "~/chat/use-fake-streaming"
|
|
||||||
import {
|
|
||||||
AnimatedLogo,
|
|
||||||
AnimatedLogoState,
|
|
||||||
AnimatedLogoState as TAnimatedLogoState,
|
|
||||||
} from "~/components/animated-logo"
|
|
||||||
import { ProgressiveBlur } from "~/components/progressive-blur"
|
|
||||||
|
|
||||||
import type { Route } from "./+types/home"
|
|
||||||
|
|
||||||
const PAGE_TITLE = "Aelis - Next Generation AI Assistant"
|
|
||||||
const PAGE_DESCRIPTION =
|
|
||||||
"Meet Aelis, a personal assistant that stays one step ahead of your day. Join the waitlist now."
|
|
||||||
|
|
||||||
export function meta({}: Route.MetaArgs) {
|
|
||||||
return [
|
|
||||||
{ title: PAGE_TITLE },
|
|
||||||
{
|
|
||||||
name: "description",
|
|
||||||
content: PAGE_DESCRIPTION,
|
|
||||||
},
|
|
||||||
{ property: "og:title", content: PAGE_TITLE },
|
|
||||||
{ property: "og:description", content: PAGE_DESCRIPTION },
|
|
||||||
{ property: "og:image", content: "https://ael.is/social-media-preview.png" },
|
|
||||||
{ property: "og:url", content: "https://ael.is" },
|
|
||||||
{ property: "og:type", content: "website" },
|
|
||||||
{ name: "twitter:card", content: "summary_large_image" },
|
|
||||||
{ name: "twitter:title", content: PAGE_TITLE },
|
|
||||||
{ name: "twitter:description", content: PAGE_DESCRIPTION },
|
|
||||||
{ name: "twitter:image", content: "https://ael.is/social-media-preview.png" },
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
const FormError = {
|
|
||||||
Duplicate: "duplicate",
|
|
||||||
Resend: "resend",
|
|
||||||
} as const
|
|
||||||
|
|
||||||
export async function action({ request }: Route.ActionArgs) {
|
|
||||||
const formData = await request.formData()
|
|
||||||
const email = formData.get("email")
|
|
||||||
|
|
||||||
if (typeof email !== "string" || !isValidEmail(email)) {
|
|
||||||
return { error: "Invalid email" }
|
|
||||||
}
|
|
||||||
|
|
||||||
const resend = new Resend(process.env.RESEND_API_KEY)
|
|
||||||
|
|
||||||
const segmentId = "b80fb036-74a1-4f7d-bca5-2c035b696071"
|
|
||||||
|
|
||||||
const dup = await resend.contacts.get({
|
|
||||||
email,
|
|
||||||
})
|
|
||||||
if (dup.data) {
|
|
||||||
return { error: FormError.Duplicate }
|
|
||||||
}
|
|
||||||
|
|
||||||
const res = await resend.contacts.create({
|
|
||||||
email,
|
|
||||||
segments: [{ id: segmentId }],
|
|
||||||
})
|
|
||||||
|
|
||||||
if (res.error) {
|
|
||||||
console.log("Error adding contact to Resend:", res.error)
|
|
||||||
return { error: FormError.Resend, message: res.error.message }
|
|
||||||
}
|
|
||||||
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 1000))
|
|
||||||
|
|
||||||
const emailRes = await resend.emails.send({
|
|
||||||
from: "Aelis <no-reply@ael.is>",
|
|
||||||
to: email,
|
|
||||||
template: {
|
|
||||||
id: "waitlist-confirmation",
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
if (emailRes.error) {
|
|
||||||
// swallow the error since the user is already added to the waitlist, but log it for debugging
|
|
||||||
console.log("Error sending confirmation email:", emailRes.error)
|
|
||||||
}
|
|
||||||
|
|
||||||
return { email }
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Home() {
|
|
||||||
const [messages, setMessages] = useState<Message[]>(INITIAL_MESSAGES)
|
|
||||||
const [emailSent, setEmailSent] = useState("")
|
|
||||||
const [isAnimatingSend, setIsAnimatingSend] = useState(false)
|
|
||||||
const [logoState, setLogoState] = useState<TAnimatedLogoState>(AnimatedLogoState.Idle)
|
|
||||||
const chatBoxRef = useRef<HTMLDivElement>(null)
|
|
||||||
const fetcher = useFetcher()
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (fetcher.data?.email && !isAnimatingSend) {
|
|
||||||
setMessages((messages) => [...messages, waitListJoinedMessage(fetcher.data.email)])
|
|
||||||
} else if (fetcher.data?.error) {
|
|
||||||
if (!isAnimatingSend) {
|
|
||||||
let errorMessage: SystemMessage
|
|
||||||
switch (fetcher.data.error) {
|
|
||||||
case FormError.Duplicate:
|
|
||||||
errorMessage = duplicateEmailMessage()
|
|
||||||
break
|
|
||||||
default: {
|
|
||||||
console.error(fetcher.data.error)
|
|
||||||
errorMessage = troubleMessage()
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setMessages((messages) => [...messages, errorMessage])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [fetcher.data?.email, fetcher.data?.error, isAnimatingSend])
|
|
||||||
|
|
||||||
const insertEmailMessage = (email: string) => {
|
|
||||||
setEmailSent(email)
|
|
||||||
setIsAnimatingSend(true)
|
|
||||||
setLogoState(AnimatedLogoState.Loading)
|
|
||||||
setMessages((messages) => [
|
|
||||||
...messages,
|
|
||||||
{
|
|
||||||
role: "user",
|
|
||||||
message: email,
|
|
||||||
bubbleLayoutId: "test",
|
|
||||||
},
|
|
||||||
])
|
|
||||||
|
|
||||||
fetcher.submit({ email }, { method: "post" })
|
|
||||||
}
|
|
||||||
|
|
||||||
let chatBox: React.ReactNode
|
|
||||||
if (emailSent && isAnimatingSend) {
|
|
||||||
const chatBoxRect = chatBoxRef.current?.getBoundingClientRect()
|
|
||||||
const mainRect = chatBoxRef.current?.offsetParent?.getBoundingClientRect()
|
|
||||||
chatBox = (
|
|
||||||
<MorphingChatBox
|
|
||||||
chatBoxWidth={chatBoxRef.current?.offsetWidth ?? 0}
|
|
||||||
chatBoxHeight={chatBoxRef.current?.offsetHeight ?? 0}
|
|
||||||
chatBoxLeft={(chatBoxRect?.left ?? 0) - (mainRect?.left ?? 0)}
|
|
||||||
chatBoxTop={(chatBoxRect?.top ?? 0) - (mainRect?.top ?? 0)}
|
|
||||||
onAnimationEnd={() => {
|
|
||||||
setIsAnimatingSend(false)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{emailSent}
|
|
||||||
</MorphingChatBox>
|
|
||||||
)
|
|
||||||
} else if (!emailSent) {
|
|
||||||
chatBox = (
|
|
||||||
<AnimatePresence>
|
|
||||||
{logoState === AnimatedLogoState.Idle && !emailSent && (
|
|
||||||
<motion.div
|
|
||||||
ref={chatBoxRef}
|
|
||||||
key="test"
|
|
||||||
className="w-full max-w-2xl absolute bottom-12 px-6 md:px-0 flex justify-center z-20"
|
|
||||||
initial={{ y: 100, opacity: 0 }}
|
|
||||||
animate={{ y: 0, opacity: 1 }}
|
|
||||||
transition={{ type: "spring", stiffness: 300, damping: 30, mass: 1.5 }}
|
|
||||||
>
|
|
||||||
<ChatBox
|
|
||||||
className="w-full max-w-2xl"
|
|
||||||
validate={isValidEmail}
|
|
||||||
disabled={fetcher.state === "submitting" || fetcher.state === "loading"}
|
|
||||||
onSubmit={insertEmailMessage}
|
|
||||||
/>
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
chatBox = null
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<main className="relative w-full h-full flex flex-col items-center justify-start gap-4 overflow-hidden">
|
|
||||||
<ProgressiveBlur className="absolute top-0 left-0 right-0 h-24 z-10" />
|
|
||||||
<AnimatedLogo
|
|
||||||
className="absolute top-4 md:top-8 size-10 z-20 cursor-pointer"
|
|
||||||
state={logoState}
|
|
||||||
/>
|
|
||||||
<MessageList
|
|
||||||
messages={messages}
|
|
||||||
showLastMessage={!isAnimatingSend}
|
|
||||||
onMessageStreamStart={() => {
|
|
||||||
setLogoState(AnimatedLogoState.Loading)
|
|
||||||
}}
|
|
||||||
onMessageStreamEnd={() => {
|
|
||||||
setLogoState(AnimatedLogoState.Idle)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{chatBox}
|
|
||||||
<ProgressiveBlur
|
|
||||||
direction="up"
|
|
||||||
className="absolute bottom-0 left-0 right-0 h-24 z-10 pointer-events-none"
|
|
||||||
/>
|
|
||||||
<footer className="absolute bottom-4 z-20">
|
|
||||||
<Link to="/privacy" className="text-xs opacity-50 underline">
|
|
||||||
Privacy policy
|
|
||||||
</Link>
|
|
||||||
</footer>
|
|
||||||
</main>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function MorphingChatBox({
|
|
||||||
chatBoxWidth,
|
|
||||||
chatBoxHeight,
|
|
||||||
chatBoxLeft,
|
|
||||||
chatBoxTop,
|
|
||||||
onAnimationEnd,
|
|
||||||
children,
|
|
||||||
}: React.PropsWithChildren<{
|
|
||||||
chatBoxWidth: number
|
|
||||||
chatBoxHeight: number
|
|
||||||
chatBoxLeft: number
|
|
||||||
chatBoxTop: number
|
|
||||||
onAnimationEnd: () => void
|
|
||||||
}>) {
|
|
||||||
const [targetWidth, setTargetWidth] = useState(-1)
|
|
||||||
const [targetHeight, setTargetHeight] = useState(-1)
|
|
||||||
const [targetCoords, setTargetCoords] = useState([0, 0])
|
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
|
||||||
const bubble = document.getElementById("test")
|
|
||||||
if (bubble) {
|
|
||||||
const mainRect = bubble.closest("main")?.getBoundingClientRect()
|
|
||||||
const rect = bubble.getBoundingClientRect()
|
|
||||||
setTargetWidth(bubble.offsetWidth)
|
|
||||||
setTargetHeight(bubble.offsetHeight)
|
|
||||||
setTargetCoords([rect.left - (mainRect?.left ?? 0), rect.top - (mainRect?.top ?? 0)])
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
if (targetWidth < 0 || targetHeight < 0) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<motion.div
|
|
||||||
className="absolute rounded-lg bg-stone-100 dark:bg-stone-800 px-4 py-2 border border-stone-200 dark:border-stone-700"
|
|
||||||
initial={{
|
|
||||||
width: chatBoxWidth,
|
|
||||||
height: chatBoxHeight,
|
|
||||||
borderRadius: 8,
|
|
||||||
left: chatBoxLeft,
|
|
||||||
top: chatBoxTop,
|
|
||||||
}}
|
|
||||||
animate={{
|
|
||||||
width: targetWidth,
|
|
||||||
height: targetHeight,
|
|
||||||
borderTopLeftRadius: 100,
|
|
||||||
borderTopRightRadius: 100,
|
|
||||||
borderBottomRightRadius: 24,
|
|
||||||
borderBottomLeftRadius: 100,
|
|
||||||
left: targetCoords[0],
|
|
||||||
top: targetCoords[1],
|
|
||||||
}}
|
|
||||||
transition={{
|
|
||||||
left: { duration: 0.45, ease: [0.05, 0.8, 0.3, 1] },
|
|
||||||
top: { duration: 0.45, ease: [0.3, 0, 0.2, 1] },
|
|
||||||
width: { duration: 0.45, ease: [0.05, 0.8, 0.3, 1] },
|
|
||||||
height: { duration: 0.45, ease: [0.05, 0.8, 0.3, 1] },
|
|
||||||
}}
|
|
||||||
onAnimationComplete={onAnimationEnd}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</motion.div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function MessageList({
|
|
||||||
messages,
|
|
||||||
showLastMessage,
|
|
||||||
onMessageStreamStart,
|
|
||||||
onMessageStreamEnd,
|
|
||||||
}: {
|
|
||||||
messages: Message[]
|
|
||||||
showLastMessage: boolean
|
|
||||||
onMessageStreamStart: () => void
|
|
||||||
onMessageStreamEnd: () => void
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<ul className="w-full flex flex-col gap-8 overflow-auto px-6 pt-20 md:px-0 md:pt-24 pb-34">
|
|
||||||
{messages.map((message, index) => (
|
|
||||||
<li
|
|
||||||
key={index}
|
|
||||||
className={`flex justify-center ${index === messages.length - 1 && !showLastMessage ? "invisible" : ""}`}
|
|
||||||
>
|
|
||||||
<MessageContent
|
|
||||||
message={message}
|
|
||||||
onMessageStreamStart={onMessageStreamStart}
|
|
||||||
onMessageStreamEnd={onMessageStreamEnd}
|
|
||||||
/>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function MessageContent({
|
|
||||||
message,
|
|
||||||
onMessageStreamStart,
|
|
||||||
onMessageStreamEnd,
|
|
||||||
}: {
|
|
||||||
message: Message
|
|
||||||
onMessageStreamStart: () => void
|
|
||||||
onMessageStreamEnd: () => void
|
|
||||||
}) {
|
|
||||||
switch (message.role) {
|
|
||||||
case "user":
|
|
||||||
return <UserMessageBubble message={message} />
|
|
||||||
case "system":
|
|
||||||
return (
|
|
||||||
<SystemMessageBubble
|
|
||||||
message={message}
|
|
||||||
onStreamStart={onMessageStreamStart}
|
|
||||||
onStreamEnd={onMessageStreamEnd}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function UserMessageBubble({ message }: { message: UserMessage }) {
|
|
||||||
return (
|
|
||||||
<div className="w-full max-w-2xl flex justify-end">
|
|
||||||
<div
|
|
||||||
id={message.bubbleLayoutId}
|
|
||||||
className="rounded-[100px_100px_24px_100px] bg-stone-100 dark:bg-stone-800 border border-stone-200 dark:border-stone-700 px-4 py-2"
|
|
||||||
>
|
|
||||||
{message.message}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function SystemMessageBubble({
|
|
||||||
message,
|
|
||||||
onStreamStart,
|
|
||||||
onStreamEnd,
|
|
||||||
}: {
|
|
||||||
message: SystemMessage
|
|
||||||
onStreamStart: () => void
|
|
||||||
onStreamEnd: () => void
|
|
||||||
}) {
|
|
||||||
const { currentContent, isStreaming } = useFakeStreaming(message.message)
|
|
||||||
const ref = useRef<HTMLDivElement>(null)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
ref.current?.scrollIntoView({ behavior: "smooth", block: "end" })
|
|
||||||
}, [currentContent])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (isStreaming) {
|
|
||||||
onStreamStart()
|
|
||||||
} else {
|
|
||||||
onStreamEnd()
|
|
||||||
}
|
|
||||||
}, [isStreaming])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div ref={ref} className="w-full max-w-2xl flex justify-start font-serif text-lg scroll-mb-34">
|
|
||||||
<Streamdown
|
|
||||||
animated={{ animation: "slideUp" }}
|
|
||||||
isAnimating={isStreaming}
|
|
||||||
linkSafety={{ enabled: false }}
|
|
||||||
components={{
|
|
||||||
// @ts-expect-error
|
|
||||||
a: ({ className, ...props }) => <a className={`underline ${className}`} {...props} />,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{currentContent}
|
|
||||||
</Streamdown>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function isValidEmail(value: string): boolean {
|
|
||||||
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)
|
|
||||||
}
|
|
||||||
@@ -1,246 +0,0 @@
|
|||||||
import { Link } from "react-router"
|
|
||||||
import { Streamdown } from "streamdown"
|
|
||||||
|
|
||||||
import { AnimatedLogo, AnimatedLogoState } from "~/components/animated-logo"
|
|
||||||
|
|
||||||
import type { Route } from "./+types/privacy-policy"
|
|
||||||
|
|
||||||
export function meta({}: Route.MetaArgs) {
|
|
||||||
return [
|
|
||||||
{ title: "Privacy Policy — Aelis" },
|
|
||||||
{ name: "description", content: "Aelis privacy policy" },
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function PrivacyPolicy() {
|
|
||||||
return (
|
|
||||||
<main className="relative max-w-2xl mx-auto px-6 py-16">
|
|
||||||
<Link to="/" className="block w-fit mb-8">
|
|
||||||
<AnimatedLogo className="size-10 pointer-events-none" state={AnimatedLogoState.Idle} />
|
|
||||||
</Link>
|
|
||||||
<Streamdown
|
|
||||||
isAnimating={false}
|
|
||||||
linkSafety={{ enabled: false }}
|
|
||||||
components={{
|
|
||||||
a: ({ className, ...props }) => <a className={`underline ${className}`} {...props} />,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{POLICY}
|
|
||||||
</Streamdown>
|
|
||||||
<footer className="mt-16 pt-8 border-t border-stone-200 dark:border-stone-700">
|
|
||||||
<Link to="/" className="text-sm opacity-50 hover:opacity-75 underline">
|
|
||||||
Back to home
|
|
||||||
</Link>
|
|
||||||
</footer>
|
|
||||||
</main>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const POLICY = `# Privacy Policy
|
|
||||||
|
|
||||||
**Last updated:** March 5, 2026
|
|
||||||
|
|
||||||
This Privacy Policy describes how **Aelis** ("we", "us", or "our") collects, uses, and protects your personal information when you visit **https://ael.is** or interact with our services.
|
|
||||||
|
|
||||||
If you do not agree with this Privacy Policy, please do not use the website.
|
|
||||||
|
|
||||||
For any questions, contact: **[kenneth@nym.sh](mailto:kenneth@nym.sh)**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Information We Collect
|
|
||||||
|
|
||||||
### Personal Information You Provide
|
|
||||||
|
|
||||||
**In Short:** We collect personal information that you provide to us.
|
|
||||||
|
|
||||||
We collect personal information that you voluntarily provide when you express interest in our services, contact us, or sign up for the waitlist.
|
|
||||||
|
|
||||||
We collect your email address when you sign up for the waitlist so we can notify you when the product launches or provide related updates.
|
|
||||||
|
|
||||||
### Personal Information Provided by You
|
|
||||||
|
|
||||||
The personal information we collect may include:
|
|
||||||
|
|
||||||
* email addresses
|
|
||||||
|
|
||||||
You are responsible for ensuring the personal information you provide is accurate and up to date.
|
|
||||||
|
|
||||||
### Sensitive Information
|
|
||||||
|
|
||||||
We **do not collect or process sensitive personal information**.
|
|
||||||
|
|
||||||
### Information From Third Parties
|
|
||||||
|
|
||||||
We **do not collect personal information from third parties**.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. How We Use Your Information
|
|
||||||
|
|
||||||
We process your information for the following purposes:
|
|
||||||
|
|
||||||
* To operate and maintain our services
|
|
||||||
* To communicate with you about product updates and launch announcements
|
|
||||||
* To send administrative information such as policy updates
|
|
||||||
* To prevent fraud or abuse
|
|
||||||
* To comply with legal obligations
|
|
||||||
* To protect someone’s safety when necessary
|
|
||||||
|
|
||||||
We only process personal information when we have a valid legal reason to do so.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Legal Bases for Processing (EU / UK)
|
|
||||||
|
|
||||||
If you are located in the European Economic Area (EEA) or the United Kingdom, we rely on the following legal bases to process personal information:
|
|
||||||
|
|
||||||
### Consent
|
|
||||||
|
|
||||||
You have given permission for us to process your personal information for a specific purpose.
|
|
||||||
|
|
||||||
### Contract
|
|
||||||
|
|
||||||
Processing is necessary to provide services you requested.
|
|
||||||
|
|
||||||
### Legal Obligations
|
|
||||||
|
|
||||||
Processing is required to comply with applicable laws.
|
|
||||||
|
|
||||||
### Vital Interests
|
|
||||||
|
|
||||||
Processing is necessary to protect someone's safety.
|
|
||||||
|
|
||||||
You may withdraw consent at any time by contacting us.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. When and With Whom We Share Personal Information
|
|
||||||
|
|
||||||
We may share your personal information in limited situations.
|
|
||||||
|
|
||||||
### Service Providers
|
|
||||||
|
|
||||||
We may share information with trusted service providers that help us operate our website or manage communications.
|
|
||||||
|
|
||||||
### Business Transfers
|
|
||||||
|
|
||||||
We may transfer information during negotiations of a merger, sale of assets, financing, or acquisition of our business.
|
|
||||||
|
|
||||||
We **do not sell your personal information**.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. How Long We Keep Your Information
|
|
||||||
|
|
||||||
We retain personal information only as long as necessary to:
|
|
||||||
|
|
||||||
* provide services
|
|
||||||
* comply with legal obligations
|
|
||||||
* resolve disputes
|
|
||||||
* enforce agreements
|
|
||||||
|
|
||||||
When we no longer need personal information, we delete or anonymize it where possible.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. How We Keep Your Information Safe
|
|
||||||
|
|
||||||
We implement reasonable technical and organizational safeguards designed to protect personal information.
|
|
||||||
|
|
||||||
However, no electronic transmission or storage system is completely secure. We cannot guarantee absolute security.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. Information From Minors
|
|
||||||
|
|
||||||
Our services are **not intended for individuals under 18 years old**.
|
|
||||||
|
|
||||||
We do not knowingly collect personal information from children. If we discover that we have collected such information, we will delete it.
|
|
||||||
|
|
||||||
If you believe a child has provided personal information, contact **[kenneth@nym.sh](mailto:kenneth@nym.sh)**.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. Your Privacy Rights
|
|
||||||
|
|
||||||
Depending on your location, you may have rights regarding your personal information, including:
|
|
||||||
|
|
||||||
* the right to access your data
|
|
||||||
* the right to correct inaccurate data
|
|
||||||
* the right to delete your data
|
|
||||||
* the right to restrict processing
|
|
||||||
* the right to data portability
|
|
||||||
* the right to object to processing
|
|
||||||
* the right to withdraw consent
|
|
||||||
|
|
||||||
To exercise these rights, submit a request:
|
|
||||||
|
|
||||||
https://app.termly.io/dsar/b8633d03-406f-4133-b16e-ded63e893997
|
|
||||||
|
|
||||||
Or contact us at **[kenneth@nym.sh](mailto:kenneth@nym.sh)**.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. Do Not Track (DNT)
|
|
||||||
|
|
||||||
Many browsers include a **Do Not Track (DNT)** feature.
|
|
||||||
|
|
||||||
Because there is currently no consistent standard for responding to DNT signals, we do not respond to them.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 10. Global Privacy Control
|
|
||||||
|
|
||||||
We recognize **Global Privacy Control (GPC)** signals.
|
|
||||||
|
|
||||||
If your browser sends a GPC signal, we treat it as a request to opt out of the sale or sharing of personal information where applicable.
|
|
||||||
|
|
||||||
More information: https://globalprivacycontrol.org
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 11. Privacy Rights in Other Regions
|
|
||||||
|
|
||||||
Additional privacy rights may apply depending on your location, including:
|
|
||||||
|
|
||||||
* European Economic Area (EEA)
|
|
||||||
* United Kingdom
|
|
||||||
* Switzerland
|
|
||||||
* Canada
|
|
||||||
* United States
|
|
||||||
* Australia
|
|
||||||
* New Zealand
|
|
||||||
|
|
||||||
If you believe we are processing your personal information unlawfully, you may contact your local data protection authority.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 12. Updates to This Privacy Policy
|
|
||||||
|
|
||||||
We may update this Privacy Policy from time to time.
|
|
||||||
|
|
||||||
When we do, we will update the **Last updated** date at the top of this document.
|
|
||||||
|
|
||||||
We encourage users to review this Privacy Policy regularly.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 13. Contact Information
|
|
||||||
|
|
||||||
If you have questions or comments about this Privacy Policy, you may contact us:
|
|
||||||
|
|
||||||
**Aelis**
|
|
||||||
|
|
||||||
Email: **[kenneth@nym.sh](mailto:kenneth@nym.sh)**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 14. Request Access, Update, or Deletion
|
|
||||||
|
|
||||||
Depending on applicable law, you may request access to, correction of, or deletion of your personal information.
|
|
||||||
|
|
||||||
Email:
|
|
||||||
|
|
||||||
**[kenneth@nym.sh](mailto:kenneth@nym.sh)**
|
|
||||||
`
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
# fly.toml app configuration file generated for aelis-waitlist-website on 2026-03-08T01:11:12Z
|
|
||||||
#
|
|
||||||
# See https://fly.io/docs/reference/configuration/ for information about how to use this file.
|
|
||||||
#
|
|
||||||
|
|
||||||
app = 'aelis-waitlist-website'
|
|
||||||
primary_region = 'lhr'
|
|
||||||
|
|
||||||
[build]
|
|
||||||
|
|
||||||
[http_service]
|
|
||||||
internal_port = 3000
|
|
||||||
force_https = true
|
|
||||||
auto_stop_machines = 'stop'
|
|
||||||
auto_start_machines = true
|
|
||||||
min_machines_running = 0
|
|
||||||
processes = ['app']
|
|
||||||
|
|
||||||
[[vm]]
|
|
||||||
memory = '1gb'
|
|
||||||
cpus = 1
|
|
||||||
memory_mb = 1024
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "waitlist-website",
|
|
||||||
"private": true,
|
|
||||||
"type": "module",
|
|
||||||
"scripts": {
|
|
||||||
"build": "react-router build",
|
|
||||||
"dev": "react-router dev",
|
|
||||||
"start": "react-router-serve ./build/server/index.js",
|
|
||||||
"typecheck": "react-router typegen && tsc"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@react-router/node": "7.12.0",
|
|
||||||
"@react-router/serve": "7.12.0",
|
|
||||||
"clsx": "^2.1.1",
|
|
||||||
"isbot": "^5.1.31",
|
|
||||||
"lottie-react": "^2.4.1",
|
|
||||||
"lucide-react": "^0.577.0",
|
|
||||||
"motion": "^12.35.0",
|
|
||||||
"react": "^19.2.4",
|
|
||||||
"react-aria-components": "^1.16.0",
|
|
||||||
"react-dom": "^19.2.4",
|
|
||||||
"react-router": "7.12.0",
|
|
||||||
"resend": "^6.9.3",
|
|
||||||
"streamdown": "^2.4.0"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@react-router/dev": "7.12.0",
|
|
||||||
"@tailwindcss/vite": "^4.1.13",
|
|
||||||
"@types/node": "^22",
|
|
||||||
"@types/react": "^19.2.7",
|
|
||||||
"@types/react-dom": "^19.2.3",
|
|
||||||
"tailwindcss": "^4.1.13",
|
|
||||||
"typescript": "^5.9.2",
|
|
||||||
"vite": "^7.1.7",
|
|
||||||
"vite-tsconfig-paths": "^5.1.4"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg width="100%" height="100%" viewBox="0 0 1667 1667" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:1.5;"><rect id="dark" x="0" y="0" width="1666.67" height="1666.67" style="fill:none;"/><path d="M943.75,642.086c318.648,183.972 527.874,419.028 466.934,524.581c-60.941,105.552 -369.119,41.885 -687.767,-142.086c-318.649,-183.972 -527.875,-419.029 -466.934,-524.581c60.941,-105.552 369.119,-41.886 687.767,142.086Z" style="fill:none;stroke:#e7e5e4;stroke-width:62.5px;"/><path d="M722.917,642.086c318.648,-183.972 626.826,-247.638 687.767,-142.086c60.94,105.552 -148.286,340.609 -466.934,524.581c-318.648,183.971 -626.826,247.638 -687.767,142.086c-60.941,-105.553 148.285,-340.609 466.934,-524.581Z" style="fill:none;stroke:#e7e5e4;stroke-width:62.5px;"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.1 KiB |
@@ -1 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg width="100%" height="100%" viewBox="0 0 1667 1667" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:1.5;"><rect id="light" x="0" y="0" width="1666.67" height="1666.67" style="fill:none;"/><g id="light1" serif:id="light"><path d="M943.75,642.086c318.648,183.972 527.874,419.028 466.934,524.581c-60.941,105.552 -369.119,41.885 -687.767,-142.086c-318.649,-183.972 -527.875,-419.029 -466.934,-524.581c60.941,-105.552 369.119,-41.886 687.767,142.086Z" style="fill:none;stroke:#1c1917;stroke-width:62.5px;"/><path d="M722.917,642.086c318.648,-183.972 626.826,-247.638 687.767,-142.086c60.94,105.552 -148.286,340.609 -466.934,524.581c-318.648,183.971 -626.826,247.638 -687.767,142.086c-60.941,-105.553 148.285,-340.609 466.934,-524.581Z" style="fill:none;stroke:#1c1917;stroke-width:62.5px;"/></g></svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.1 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 15 KiB |
@@ -1,4 +0,0 @@
|
|||||||
User-agent: *
|
|
||||||
Allow: /
|
|
||||||
|
|
||||||
Sitemap: https://ael.is/sitemap.xml
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
|
||||||
<url>
|
|
||||||
<loc>https://ael.is/</loc>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ael.is/privacy</loc>
|
|
||||||
</url>
|
|
||||||
</urlset>
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 68 KiB |
@@ -1,7 +0,0 @@
|
|||||||
import type { Config } from "@react-router/dev/config"
|
|
||||||
|
|
||||||
export default {
|
|
||||||
// Config options...
|
|
||||||
// Server-side render by default, to enable SPA mode set this to `false`
|
|
||||||
ssr: true,
|
|
||||||
} satisfies Config
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
{
|
|
||||||
"include": ["**/*", "**/.server/**/*", "**/.client/**/*", ".react-router/types/**/*"],
|
|
||||||
"compilerOptions": {
|
|
||||||
"lib": ["DOM", "DOM.Iterable", "ES2022"],
|
|
||||||
"types": ["node", "vite/client"],
|
|
||||||
"target": "ES2022",
|
|
||||||
"module": "ES2022",
|
|
||||||
"moduleResolution": "bundler",
|
|
||||||
"jsx": "react-jsx",
|
|
||||||
"rootDirs": [".", "./.react-router/types"],
|
|
||||||
"baseUrl": ".",
|
|
||||||
"paths": {
|
|
||||||
"~/*": ["./app/*"]
|
|
||||||
},
|
|
||||||
"esModuleInterop": true,
|
|
||||||
"verbatimModuleSyntax": true,
|
|
||||||
"noEmit": true,
|
|
||||||
"resolveJsonModule": true,
|
|
||||||
"skipLibCheck": true,
|
|
||||||
"strict": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
import { reactRouter } from "@react-router/dev/vite"
|
|
||||||
import tailwindcss from "@tailwindcss/vite"
|
|
||||||
import { defineConfig } from "vite"
|
|
||||||
import tsconfigPaths from "vite-tsconfig-paths"
|
|
||||||
|
|
||||||
export default defineConfig({
|
|
||||||
plugins: [tailwindcss(), reactRouter(), tsconfigPaths()],
|
|
||||||
ssr: {
|
|
||||||
noExternal: ["lottie-react"],
|
|
||||||
},
|
|
||||||
})
|
|
||||||
@@ -14,8 +14,6 @@
|
|||||||
"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"
|
||||||
|
|||||||
@@ -7,10 +7,6 @@
|
|||||||
"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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,7 +61,10 @@ function partsEqual(a: unknown, b: unknown): boolean {
|
|||||||
const bKeys = Object.keys(b)
|
const bKeys = Object.keys(b)
|
||||||
if (aKeys.length !== bKeys.length) return false
|
if (aKeys.length !== bKeys.length) return false
|
||||||
return aKeys.every((key) =>
|
return aKeys.every((key) =>
|
||||||
partsEqual((a as Record<string, unknown>)[key], (b as Record<string, unknown>)[key]),
|
partsEqual(
|
||||||
|
(a as Record<string, unknown>)[key],
|
||||||
|
(b as Record<string, unknown>)[key],
|
||||||
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
import type { JrxNode } from "@nym.sh/jrx"
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Source-provided hints for post-processors.
|
* Source-provided hints for post-processors.
|
||||||
*
|
*
|
||||||
@@ -78,12 +76,3 @@ 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>
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 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, RenderedFeedItem, Slot } from "./feed"
|
export type { FeedItem, FeedItemSignals, Slot } from "./feed"
|
||||||
export { TimeRelevance } from "./feed"
|
export { TimeRelevance } from "./feed"
|
||||||
|
|
||||||
// Feed Source
|
// Feed Source
|
||||||
|
|||||||
@@ -534,68 +534,4 @@ 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, Slot } from "@aelis/core"
|
import type { ActionDefinition, ContextEntry, FeedItemSignals, FeedSource } 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,9 +7,6 @@ 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 --
|
||||||
@@ -343,14 +340,6 @@ 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}` : ""}`,
|
||||||
@@ -358,6 +347,5 @@ 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(),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
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
4
packages/aelis-source-caldav/src/text.d.ts
vendored
@@ -1,4 +0,0 @@
|
|||||||
declare module "*.txt" {
|
|
||||||
const content: string
|
|
||||||
export default content
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user