feat(server): with api server, service-lize (#807)

Co-authored-by: Neko Ayaka <neko@ayaka.moe>
Co-authored-by: Lovehsigure_520 <1260907335@qq.com>
This commit is contained in:
RainbowBird
2026-01-05 16:24:03 +08:00
committed by RainbowBird
co-authored by Neko Ayaka Lovehsigure_520
parent dd46b8deff
commit d064a959cf
30 changed files with 2163 additions and 184 deletions
+7
View File
@@ -0,0 +1,7 @@
DATABASE_URL=""
AUTH_GOOGLE_CLIENT_ID=""
AUTH_GOOGLE_CLIENT_SECRET=""
AUTH_GITHUB_CLIENT_ID=""
AUTH_GITHUB_CLIENT_SECRET=""
+14
View File
@@ -0,0 +1,14 @@
FROM node:24-alpine
WORKDIR /app
RUN corepack enable
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
COPY apps/server apps/server
RUN pnpm install --frozen-lockfile --ignore-scripts
EXPOSE 3000
CMD ["pnpm", "-F", "@proj-airi/api-server", "start"]
+33
View File
@@ -0,0 +1,33 @@
version: '3.9'
services:
db:
image: postgres:16-alpine
container_name: airi-postgres
environment:
- POSTGRES_DB=airi
- POSTGRES_USER=airi
- POSTGRES_PASSWORD=airi
ports:
- '5432:5432'
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB']
interval: 5s
timeout: 5s
retries: 10
volumes:
- ${serviceName}_data:/var/lib/postgresql/data
server:
build:
context: ../..
dockerfile: apps/server/Dockerfile
depends_on:
db:
condition: service_healthy
ports:
- '3000:3000'
volumes:
db_data:
driver: local
+10
View File
@@ -0,0 +1,10 @@
import process from 'node:process'
export default {
schema: './src/schemas/**/*.ts',
out: './drizzle',
dialect: 'postgresql',
dbCredentials: {
url: process.env.DATABASE_URL,
},
}
+53
View File
@@ -0,0 +1,53 @@
CREATE TABLE "account" (
"id" text PRIMARY KEY NOT NULL,
"account_id" text NOT NULL,
"provider_id" text NOT NULL,
"user_id" text NOT NULL,
"access_token" text,
"refresh_token" text,
"id_token" text,
"access_token_expires_at" timestamp,
"refresh_token_expires_at" timestamp,
"scope" text,
"password" text,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp NOT NULL
);
--> statement-breakpoint
CREATE TABLE "session" (
"id" text PRIMARY KEY NOT NULL,
"expires_at" timestamp NOT NULL,
"token" text NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp NOT NULL,
"ip_address" text,
"user_agent" text,
"user_id" text NOT NULL,
CONSTRAINT "session_token_unique" UNIQUE("token")
);
--> statement-breakpoint
CREATE TABLE "user" (
"id" text PRIMARY KEY NOT NULL,
"name" text NOT NULL,
"email" text NOT NULL,
"email_verified" boolean DEFAULT false NOT NULL,
"image" text,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL,
CONSTRAINT "user_email_unique" UNIQUE("email")
);
--> statement-breakpoint
CREATE TABLE "verification" (
"id" text PRIMARY KEY NOT NULL,
"identifier" text NOT NULL,
"value" text NOT NULL,
"expires_at" timestamp NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "account" ADD CONSTRAINT "account_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "session" ADD CONSTRAINT "session_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "account_userId_idx" ON "account" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "session_userId_idx" ON "session" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "verification_identifier_idx" ON "verification" USING btree ("identifier");
+374
View File
@@ -0,0 +1,374 @@
{
"id": "b19a2fb9-f374-4190-808b-0988677f5823",
"prevId": "00000000-0000-0000-0000-000000000000",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.account": {
"name": "account",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"account_id": {
"name": "account_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"provider_id": {
"name": "provider_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"access_token": {
"name": "access_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"refresh_token": {
"name": "refresh_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"id_token": {
"name": "id_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"access_token_expires_at": {
"name": "access_token_expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"refresh_token_expires_at": {
"name": "refresh_token_expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"scope": {
"name": "scope",
"type": "text",
"primaryKey": false,
"notNull": false
},
"password": {
"name": "password",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
}
},
"indexes": {
"account_userId_idx": {
"name": "account_userId_idx",
"columns": [
{
"expression": "user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"account_user_id_user_id_fk": {
"name": "account_user_id_user_id_fk",
"tableFrom": "account",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.session": {
"name": "session",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"token": {
"name": "token",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"ip_address": {
"name": "ip_address",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_agent": {
"name": "user_agent",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
}
},
"indexes": {
"session_userId_idx": {
"name": "session_userId_idx",
"columns": [
{
"expression": "user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"session_user_id_user_id_fk": {
"name": "session_user_id_user_id_fk",
"tableFrom": "session",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"session_token_unique": {
"name": "session_token_unique",
"nullsNotDistinct": false,
"columns": [
"token"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.user": {
"name": "user",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email_verified": {
"name": "email_verified",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"image": {
"name": "image",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"user_email_unique": {
"name": "user_email_unique",
"nullsNotDistinct": false,
"columns": [
"email"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.verification": {
"name": "verification",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"identifier": {
"name": "identifier",
"type": "text",
"primaryKey": false,
"notNull": true
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"verification_identifier_idx": {
"name": "verification_identifier_idx",
"columns": [
{
"expression": "identifier",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}
+13
View File
@@ -0,0 +1,13 @@
{
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1766665325052,
"tag": "0000_steep_gamora",
"breakpoints": true
}
]
}
+30
View File
@@ -0,0 +1,30 @@
{
"name": "@proj-airi/api-server",
"type": "module",
"version": "0.0.1",
"private": true,
"scripts": {
"apply:env": "dotenvx run -f .env -f .env.local --overload --ignore=MISSING_ENV_FILE",
"auth:generate": "pnpm run apply:env -- better-auth generate --config src/scripts/auth.ts --output src/schemas/auth.ts -y",
"dev": "pnpm run apply:env -- tsx --watch src/app.ts",
"start": "tsx src/app.ts",
"db:generate": "drizzle-kit generate",
"db:push": "pnpm run apply:env -- drizzle-kit push"
},
"dependencies": {
"@dotenvx/dotenvx": "^1.51.1",
"@guiiai/logg": "catalog:",
"@hono/node-server": "^1.19.6",
"better-auth": "^1.4.5",
"drizzle-orm": "^0.44.7",
"hono": "^4.10.7",
"injeca": "catalog:",
"postgres": "^3.4.7",
"tsx": "^4.21.0",
"valibot": "catalog:"
},
"devDependencies": {
"@better-auth/cli": "^1.4.5",
"drizzle-kit": "^0.31.7"
}
}
+11
View File
@@ -0,0 +1,11 @@
[build]
builder = "DOCKERFILE"
dockerfilePath = "apps/server/Dockerfile"
watchPatterns = [
"apps/server/**",
"packages/**",
"pnpm-lock.yaml"
]
[deploy]
startCommand = "pnpm -F @proj-airi/api-server start"
+99
View File
@@ -0,0 +1,99 @@
import process, { exit } from 'node:process'
import { initLogger, LoggerFormat, LoggerLevel, useLogger } from '@guiiai/logg'
import { serve } from '@hono/node-server'
import { Hono } from 'hono'
import { cors } from 'hono/cors'
import { logger as honoLogger } from 'hono/logger'
import { injeca } from 'injeca'
import { createAuth } from './services/auth'
import { createDrizzle } from './services/db'
import { parsedEnv } from './services/env'
import { getTrustedOrigin } from './utils/origin'
async function createApp() {
initLogger(LoggerLevel.Debug, LoggerFormat.Pretty)
const resolved = await injeca.resolve({ parsedEnv })
const logger = useLogger('app').useGlobalConfig()
const db = createDrizzle(resolved.parsedEnv.DATABASE_URL)
const auth = createAuth(db, resolved.parsedEnv)
db.execute('SELECT 1')
.then(() => {
logger.log('Connected to database')
})
.catch((err) => {
logger.withError(err).error('Failed to connect to database')
exit(1)
})
const app = new Hono<{
Variables: {
user: typeof auth.$Infer.Session.user | null
session: typeof auth.$Infer.Session.session | null
}
}>()
app.use(
'/api/auth/*', // or replace with "*" to enable cors for all routes
cors({
origin(origin: string) {
return getTrustedOrigin(origin)
},
credentials: true,
}),
)
app.use(honoLogger())
app.use('*', async (c, next) => {
const session = await auth.api.getSession({ headers: c.req.raw.headers })
if (!session) {
c.set('user', null)
c.set('session', null)
await next()
return
}
c.set('user', session.user)
c.set('session', session.session)
await next()
})
app.get('/session', (c) => {
const session = c.get('session')
const user = c.get('user')
if (!user)
return c.body(null, 401)
return c.json({
session,
user,
})
})
// NOTICE: required by better-auth
app.on(['POST', 'GET'], '/api/auth/*', (c) => {
return auth.handler(c.req.raw)
})
logger.withFields({ port: 3000 }).log('Server started')
return app
}
// eslint-disable-next-line antfu/no-top-level-await
serve(await createApp())
function handleError(error: unknown, type: string) {
useLogger().withError(error).error(type)
}
process.on('uncaughtException', error => handleError(error, 'Uncaught exception'))
process.on('unhandledRejection', error => handleError(error, 'Unhandled rejection'))
+93
View File
@@ -0,0 +1,93 @@
import { relations } from 'drizzle-orm'
import { boolean, index, pgTable, text, timestamp } from 'drizzle-orm/pg-core'
export const user = pgTable('user', {
id: text('id').primaryKey(),
name: text('name').notNull(),
email: text('email').notNull().unique(),
emailVerified: boolean('email_verified').default(false).notNull(),
image: text('image'),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at')
.defaultNow()
.$onUpdate(() => /* @__PURE__ */ new Date())
.notNull(),
})
export const session = pgTable(
'session',
{
id: text('id').primaryKey(),
expiresAt: timestamp('expires_at').notNull(),
token: text('token').notNull().unique(),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at')
.$onUpdate(() => /* @__PURE__ */ new Date())
.notNull(),
ipAddress: text('ip_address'),
userAgent: text('user_agent'),
userId: text('user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
},
table => [index('session_userId_idx').on(table.userId)],
)
export const account = pgTable(
'account',
{
id: text('id').primaryKey(),
accountId: text('account_id').notNull(),
providerId: text('provider_id').notNull(),
userId: text('user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
accessToken: text('access_token'),
refreshToken: text('refresh_token'),
idToken: text('id_token'),
accessTokenExpiresAt: timestamp('access_token_expires_at'),
refreshTokenExpiresAt: timestamp('refresh_token_expires_at'),
scope: text('scope'),
password: text('password'),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at')
.$onUpdate(() => /* @__PURE__ */ new Date())
.notNull(),
},
table => [index('account_userId_idx').on(table.userId)],
)
export const verification = pgTable(
'verification',
{
id: text('id').primaryKey(),
identifier: text('identifier').notNull(),
value: text('value').notNull(),
expiresAt: timestamp('expires_at').notNull(),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at')
.defaultNow()
.$onUpdate(() => /* @__PURE__ */ new Date())
.notNull(),
},
table => [index('verification_identifier_idx').on(table.identifier)],
)
export const userRelations = relations(user, ({ many }) => ({
sessions: many(session),
accounts: many(account),
}))
export const sessionRelations = relations(session, ({ one }) => ({
user: one(user, {
fields: [session.userId],
references: [user.id],
}),
}))
export const accountRelations = relations(account, ({ one }) => ({
user: one(user, {
fields: [account.userId],
references: [user.id],
}),
}))
+8
View File
@@ -0,0 +1,8 @@
import process from 'node:process'
import { createAuth } from '../services/auth'
import { createDrizzle } from '../services/db'
import { parseEnv } from '../services/env'
const env = parseEnv(process.env)
export default createAuth(createDrizzle(env.DATABASE_URL), env)
+52
View File
@@ -0,0 +1,52 @@
import type { Database } from './db'
import type { Env } from './env'
import process from 'node:process'
import { betterAuth } from 'better-auth'
import { drizzleAdapter } from 'better-auth/adapters/drizzle'
import { bearer } from 'better-auth/plugins'
import * as authSchema from '../schemas/auth'
export function createAuth(db: Database, env: Env) {
return betterAuth({
database: drizzleAdapter(db, {
provider: 'pg',
schema: {
...authSchema,
},
}),
plugins: [
bearer(),
],
emailAndPassword: {
enabled: true,
},
baseURL: process.env.API_SERVER_URL || 'http://localhost:3000',
trustedOrigins: ['*'],
// To skip state-mismatch errors
// https://github.com/better-auth/better-auth/issues/4969#issuecomment-3397804378
advanced: {
defaultCookieAttributes: {
sameSite: 'None', // this enables cross-site cookies
secure: true, // required for SameSite=None
},
},
socialProviders: {
google: {
clientId: env.AUTH_GOOGLE_CLIENT_ID,
clientSecret: env.AUTH_GOOGLE_CLIENT_SECRET,
},
github: {
clientId: env.AUTH_GITHUB_CLIENT_ID,
clientSecret: env.AUTH_GITHUB_CLIENT_SECRET,
},
},
})
}
+9
View File
@@ -0,0 +1,9 @@
import postgres from 'postgres'
import { drizzle } from 'drizzle-orm/postgres-js'
export type Database = ReturnType<typeof createDrizzle>
export function createDrizzle(dsn: string) {
return drizzle(postgres(dsn))
}
+29
View File
@@ -0,0 +1,29 @@
import type { InferOutput } from 'valibot'
import { env, exit } from 'node:process'
import { useLogger } from '@guiiai/logg'
import { injeca } from 'injeca'
import { nonEmpty, object, parse, pipe, string } from 'valibot'
const EnvSchema = object({
DATABASE_URL: pipe(string(), nonEmpty('DATABASE_URL is required')),
AUTH_GOOGLE_CLIENT_ID: pipe(string(), nonEmpty('AUTH_GOOGLE_CLIENT_ID is required')),
AUTH_GOOGLE_CLIENT_SECRET: pipe(string(), nonEmpty('AUTH_GOOGLE_CLIENT_SECRET is required')),
AUTH_GITHUB_CLIENT_ID: pipe(string(), nonEmpty('AUTH_GITHUB_CLIENT_ID is required')),
AUTH_GITHUB_CLIENT_SECRET: pipe(string(), nonEmpty('AUTH_GITHUB_CLIENT_SECRET is required')),
})
export type Env = InferOutput<typeof EnvSchema>
export function parseEnv(inputEnv: Record<string, string> | typeof env): Env {
try {
return parse(EnvSchema, inputEnv)
}
catch (err) {
useLogger().withError(err).error('Invalid environment variables')
exit(1)
}
}
export const parsedEnv = injeca.provide('env', () => parseEnv(env))
+20
View File
@@ -0,0 +1,20 @@
export function getTrustedOrigin(origin: string): string {
// 1. Allow Dev (Localhost with any port)
if (!origin || origin.startsWith('http://localhost:')) {
return origin
}
// 2. Allow Production (Exact Match)
if (origin === 'https://airi.moeru.ai') {
return origin
}
// 3. Allow Dynamic Subdomains (Strict Regex)
// Matches: https://foo.kwaa.workers.dev
if (/^https:\/\/.*\.kwaa\.workers\.dev$/.test(origin)) {
return origin
}
// Default: Block
return ''
}
+26
View File
@@ -0,0 +1,26 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"composite": true,
"target": "ESNext",
"jsx": "preserve",
"lib": [
"ESNext"
],
"useDefineForClassFields": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"types": [
"vitest",
"node"
],
"allowJs": true,
"strict": true,
"skipLibCheck": true
},
"include": [
"src/**/*.ts",
"src/**/*.d.ts"
]
}
+2 -1
View File
@@ -54,7 +54,8 @@
"@xsai/stream-transcription": "0.4.0-beta.8",
"@xsai/utils-chat": "catalog:",
"animejs": "^4.2.2",
"colorjs.io": "^0.6.0",
"better-auth": "^1.4.5",
"colorjs.io": "^0.5.2",
"culori": "^4.0.2",
"date-fns": "^4.1.0",
"dompurify": "^3.3.1",
+16
View File
@@ -0,0 +1,16 @@
import { ofetch } from 'ofetch'
import { useAuthStore } from '../stores/auth'
import { API_SERVER_URL } from './auth'
export function doRequest(url: string, options: RequestInit = {}) {
const authStore = useAuthStore()
return ofetch(url, {
baseURL: API_SERVER_URL,
...options,
headers: {
...options.headers,
Authorization: `Bearer ${authStore.authToken}`,
},
})
}
+48
View File
@@ -0,0 +1,48 @@
import { createAuthClient } from 'better-auth/vue'
import { useAuthStore } from '../stores/auth'
export const API_SERVER_URL = import.meta.env.VITE_SERVER_URL || 'https://airi-api.moeru.ai'
const authStore = useAuthStore()
export const authClient = createAuthClient({
baseURL: API_SERVER_URL,
credentials: 'include',
fetchOptions: {
auth: {
type: 'Bearer',
token: () => authStore.authToken,
},
onSuccess: (ctx) => {
const newToken = ctx.response.headers.get('set-auth-token')
if (newToken) {
authStore.authToken = newToken
}
},
},
})
export async function fetchSession() {
const { data } = await authClient.getSession()
if (data) {
authStore.user = data.user
authStore.session = data.session
return true
}
return false
}
export async function listSessions() {
return await authClient.listSessions()
}
export async function signOut() {
await authClient.signOut()
authStore.user = undefined
authStore.session = undefined
authStore.authToken = ''
}
+12
View File
@@ -0,0 +1,12 @@
<script setup lang="ts">
import { RouterView } from 'vue-router'
</script>
<template>
<RouterView />
</template>
<route lang="yaml">
meta:
layout: plain
</route>
+130
View File
@@ -0,0 +1,130 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { toast } from 'vue-sonner'
import { authClient, fetchSession } from '../../composables/auth'
import { useAuthStore } from '../../stores/auth'
const router = useRouter()
const isLoading = ref(false)
function signIn(provider: 'google' | 'github') {
isLoading.value = true
authClient.signIn.social({
provider,
callbackURL: window.location.origin,
}, {
onSuccess: (ctx: any) => {
const authToken = ctx.response.headers.get('set-auth-token') // get the token from the response headers
if (authToken) {
useAuthStore().authToken = authToken
}
},
onError: (ctx: any) => {
isLoading.value = false
toast.error(ctx.error.message || 'Failed to sign in')
},
}).catch((error: any) => {
isLoading.value = false
toast.error(error instanceof Error ? error.message : 'An unknown error occurred')
})
}
onMounted(() => {
fetchSession()
.then((authenticated) => {
if (authenticated) {
router.replace('/')
}
})
.catch(() => {})
})
</script>
<template>
<div class="min-h-screen w-full flex items-center justify-center overflow-hidden bg-[#050505] text-white font-sans selection:bg-purple-500/30">
<!-- Animated Background Elements -->
<div class="pointer-events-none absolute inset-0 z-0 overflow-hidden">
<div class="animate-pulse-slow absolute left-[-10%] top-[-20%] h-[600px] w-[600px] rounded-full bg-purple-600/15 blur-[120px]" />
<div class="animate-pulse-slow absolute bottom-[-20%] right-[-10%] h-[600px] w-[600px] rounded-full bg-blue-600/15 blur-[120px] delay-1000" />
<div class="absolute left-[40%] top-[40%] h-[300px] w-[300px] rounded-full bg-pink-500/10 blur-[100px]" />
</div>
<!-- Grid Pattern Overlay -->
<div class="[mask-image:radial-gradient(ellipse_60%_50%_at_50%_50%,#000_70%,transparent_100%)] absolute inset-0 z-0 bg-[linear-gradient(to_right,#ffffff05_1px,transparent_1px),linear-gradient(to_bottom,#ffffff05_1px,transparent_1px)] bg-[size:32px_32px]" />
<!-- Main Card -->
<div class="relative z-10 max-w-md w-full p-6 sm:p-8">
<div class="relative overflow-hidden border border-white/10 rounded-3xl bg-black/60 shadow-2xl backdrop-blur-2xl transition-all duration-500 hover:border-white/20 hover:shadow-2xl hover:shadow-purple-500/10">
<!-- Shine effect -->
<div class="pointer-events-none absolute inset-0 from-white/5 to-transparent bg-gradient-to-br" />
<div class="relative flex flex-col items-center p-8">
<!-- Logo / Icon -->
<div class="group mb-8 h-20 w-20 flex items-center justify-center rounded-2xl bg-gradient-to-tr shadow-lg shadow-violet-500/25">
<div class="i-solar-gamepad-bold text-4xl text-white transition-transform duration-300 group-hover:rotate-[-10deg] group-hover:scale-110" />
</div>
<h1 class="mb-3 text-3xl text-white font-bold tracking-tight sm:text-4xl">
A I R I
</h1>
<p class="mb-10 text-center text-sm text-gray-400 leading-relaxed">
Dive into the virtual dimension. <br>
Connect with your favorite characters today.
</p>
<div class="w-full flex flex-col gap-4">
<button
class="group relative w-full flex items-center justify-center gap-3 rounded-xl bg-white px-4 py-3.5 text-black font-bold transition-all active:scale-[0.98] hover:scale-[1.02] disabled:cursor-not-allowed hover:bg-gray-100 disabled:opacity-70"
:disabled="isLoading"
@click="signIn('google')"
>
<div class="i-simple-icons-google text-xl transition-transform group-hover:scale-110" />
<span>Sign in with Google</span>
</button>
<button
class="group relative w-full flex items-center justify-center gap-3 border border-white/10 rounded-xl bg-white/5 px-4 py-3.5 text-white font-bold transition-all active:scale-[0.98] hover:scale-[1.02] disabled:cursor-not-allowed hover:bg-white/10 disabled:opacity-70"
:disabled="isLoading"
@click="signIn('github')"
>
<div class="i-simple-icons-github text-xl transition-transform group-hover:scale-110" />
<span>Sign in with GitHub</span>
</button>
</div>
<div class="mt-10 flex items-center justify-center gap-6 text-xs text-gray-500 font-medium">
<a href="#" class="transition-colors hover:text-white">Terms</a>
<span class="h-1 w-1 rounded-full bg-gray-700" />
<a href="#" class="transition-colors hover:text-white">Privacy</a>
<span class="h-1 w-1 rounded-full bg-gray-700" />
<a href="#" class="transition-colors hover:text-white">Help</a>
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.animate-pulse-slow {
animation: pulse 8s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
@keyframes pulse {
0%, 100% {
opacity: 0.5;
transform: scale(1);
}
50% {
opacity: 0.3;
transform: scale(1.1);
}
}
</style>
<route lang="yaml">
meta:
layout: false
</route>
+8
View File
@@ -23,6 +23,8 @@ import { breakpointsTailwind, useBreakpoints, useMouse } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import { computed, onMounted, onUnmounted, ref, useTemplateRef, watch } from 'vue'
import { fetchSession } from '../composables/auth'
const paused = ref(false)
function handleSettingsOpen(open: boolean) {
@@ -167,6 +169,12 @@ watch([stream, () => vadLoaded.value], async ([s, loaded]) => {
}
}
})
onMounted(() => {
// TODO: move this to pinia store with `initialize(...)` action.
// Fetch session, ignore errors
fetchSession().catch(() => {})
})
</script>
<template>
+21
View File
@@ -0,0 +1,21 @@
import type { Session, User } from 'better-auth'
import { useLocalStorage } from '@vueuse/core'
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
export const useAuthStore = defineStore('auth', () => {
const authToken = useLocalStorage('auth/token', '')
const user = ref<User>()
const session = ref<Session>()
const isAuthenticated = computed(() => !!user.value && !!session.value)
// TODO: include fetchSession here for pulling and updating better-auth session with initialize(...) action
return {
authToken,
user,
session,
isAuthenticated,
}
})
@@ -1,6 +1,5 @@
<script setup lang="ts">
import { RouterLink } from 'vue-router'
import HeaderAvatar from './HeaderAvatar.vue'
import HeaderLink from './HeaderLink.vue'
import ActionAbout from './InteractiveArea/Actions/About.vue'
</script>
@@ -10,15 +9,7 @@ import ActionAbout from './InteractiveArea/Actions/About.vue'
<HeaderLink />
<div flex items-center gap-2>
<ActionAbout />
<RouterLink
border="2 solid neutral-100/60 dark:neutral-800/30"
bg="neutral-50/70 dark:neutral-800/70"
w-fit flex items-center justify-center rounded-xl p-2 backdrop-blur-md
title="Settings"
to="/settings"
>
<div i-solar:settings-minimalistic-bold-duotone size-5 text="neutral-500 dark:neutral-400" />
</RouterLink>
<HeaderAvatar />
</div>
</header>
</template>
@@ -0,0 +1,150 @@
<script setup lang="ts">
import { onClickOutside } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import { computed, ref } from 'vue'
import { RouterLink } from 'vue-router'
import { toast } from 'vue-sonner'
import { listSessions, signOut } from '../../composables/auth'
import { useAuthStore } from '../../stores/auth'
const authStore = useAuthStore()
const { isAuthenticated, user } = storeToRefs(authStore)
const userName = computed(() => user.value?.name)
const userAvatar = computed(() => user.value?.image)
const showDropdown = ref(false)
const dropdownRef = ref(null)
onClickOutside(dropdownRef, () => {
showDropdown.value = false
})
function handleLogout() {
signOut()
}
async function handleListSessions() {
try {
const { data: sessions } = await listSessions()
if (sessions) {
toast.success(`You have ${sessions.length} active sessions.`)
}
}
catch (error) {
toast.error(error instanceof Error ? error.message : 'An unknown error occurred')
}
}
</script>
<template>
<div flex items-center gap-2>
<!-- Non-authenticated: Settings & Login -->
<!-- NOTICE: The avatar is stored in the localstorage, it will be shown at the first time of the page load, so we do not need the skeleton loading here -->
<template v-if="!isAuthenticated">
<RouterLink
border="2 solid neutral-100/60 dark:neutral-800/30"
bg="neutral-50/70 dark:neutral-800/70"
w-fit flex items-center justify-center rounded-xl p-2 backdrop-blur-md
title="Settings"
to="/settings"
>
<div i-solar:settings-minimalistic-bold-duotone size-5 text="neutral-500 dark:neutral-400" />
</RouterLink>
<RouterLink
border="2 solid neutral-100/60 dark:neutral-800/30"
bg="neutral-50/70 dark:neutral-800/70"
w-fit flex items-center justify-center rounded-xl p-2 backdrop-blur-md
:title="isAuthenticated ? `Logged in as ${userName}` : 'Login'"
to="/auth/login"
>
<div i-solar:user-bold-duotone />
</RouterLink>
</template>
<!-- Authenticated: Avatar Dropdown -->
<div v-else ref="dropdownRef" class="relative">
<button
type="button"
class="flex items-center gap-2 border-2 border-neutral-100/60 rounded-full bg-neutral-50/70 p-1 pl-1 pr-3 backdrop-blur-md transition dark:border-neutral-800/30 dark:bg-neutral-800/70 hover:bg-neutral-100 dark:hover:bg-neutral-800"
:class="{ 'ring-2 ring-primary-500/20': showDropdown }"
aria-haspopup="true"
:aria-expanded="showDropdown ? 'true' : 'false'"
@click="showDropdown = !showDropdown"
>
<img
v-if="userAvatar"
:src="userAvatar"
class="h-8 w-8 rounded-full object-cover ring-2 ring-white dark:ring-neutral-900"
>
<div
v-else
class="h-8 w-8 flex items-center justify-center rounded-full bg-neutral-200 text-neutral-500 ring-2 ring-white dark:bg-neutral-700 dark:text-neutral-400 dark:ring-neutral-900"
>
<div class="i-solar:user-bold-duotone text-lg" />
</div>
<span v-if="userName" class="max-w-[100px] truncate text-sm text-neutral-700 font-medium hidden sm:block dark:text-neutral-200">
{{ userName }}
</span>
<div
class="i-solar:alt-arrow-down-linear text-neutral-400 transition-transform duration-200"
:class="{ 'rotate-180': showDropdown }"
/>
</button>
<transition
enter-active-class="transition duration-200 ease-out"
enter-from-class="translate-y-1 opacity-0"
enter-to-class="translate-y-0 opacity-100"
leave-active-class="transition duration-150 ease-in"
leave-from-class="translate-y-0 opacity-100"
leave-to-class="translate-y-1 opacity-0"
>
<div
v-if="showDropdown"
class="absolute right-0 top-full z-50 mt-2 w-60 origin-top-right border border-neutral-200/60 rounded-xl bg-white/90 p-1 shadow-xl backdrop-blur-xl divide-y divide-neutral-100 dark:border-neutral-800/60 dark:bg-neutral-900/90 dark:divide-neutral-800"
>
<div class="px-3 py-2">
<p class="text-xs text-neutral-500 dark:text-neutral-400">
Signed in as
</p>
<p class="truncate text-sm text-neutral-900 font-medium dark:text-white">
{{ userName }}
</p>
</div>
<div class="py-1">
<button
class="group w-full flex items-center gap-3 rounded-lg px-3 py-2 text-sm text-neutral-700 transition hover:bg-neutral-100 dark:text-neutral-200 dark:hover:bg-neutral-800"
@click="handleListSessions"
>
<div class="i-solar:devices-bold-duotone text-lg text-neutral-400 transition group-hover:text-primary-500" />
Active Sessions
</button>
<RouterLink
to="/settings"
class="group w-full flex items-center gap-3 rounded-lg px-3 py-2 text-sm text-neutral-700 transition hover:bg-neutral-100 dark:text-neutral-200 dark:hover:bg-neutral-800"
@click="showDropdown = false"
>
<div class="i-solar:settings-minimalistic-bold-duotone text-lg text-neutral-400 transition group-hover:text-primary-500" />
Settings
</RouterLink>
</div>
<div class="py-1">
<button
class="group w-full flex items-center gap-3 rounded-lg px-3 py-2 text-sm text-red-600 transition hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-900/20"
@click="handleLogout"
>
<div class="i-solar:logout-3-bold-duotone text-lg transition group-hover:text-red-600 dark:group-hover:text-red-400" />
Logout
</button>
</div>
</div>
</transition>
</div>
</div>
</template>
@@ -1,11 +1,11 @@
<script setup lang="ts">
import HeaderAvatar from './HeaderAvatar.vue'
import MobileHeaderLink from './MobileHeaderLink.vue'
</script>
<template>
<header mb-1 w-full gap-2>
<div w-full flex justify-center>
<MobileHeaderLink />
</div>
<header mb-1 w-full flex items-center justify-between gap-2 px-2>
<MobileHeaderLink />
<HeaderAvatar />
</header>
</template>
+1 -1
View File
@@ -3,7 +3,7 @@ import { RouterView } from 'vue-router'
</script>
<template>
<main text="gray-700 dark:gray-200" h-full font-cute>
<main h-full font-cute>
<RouterView />
</main>
</template>
+887 -167
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -47,6 +47,7 @@ catalog:
uncrypto: ^0.1.3
unplugin-info: 1.2.4
vite-plugin-mkcert: ^1.17.9
valibot: ^1.2.0
xsschema: ^0.4.0-beta.12
zod: ^4.2.1