1
0

ref: format with biome

This commit is contained in:
alikia2x (寒寒) 2025-12-31 21:03:27 +08:00
parent 43b3d82b3f
commit c55cfb36fc
WARNING! Although there is a key with this ID in the database it does not verify this commit! This commit is SUSPICIOUS.
GPG Key ID: 56209E0CCD8420C6
84 changed files with 721 additions and 720 deletions

View File

@ -2,52 +2,52 @@ import "dotenv/config";
export const apps = [
{
cwd: "./packages/crawler",
interpreter: "bun",
name: "crawler-jobadder",
script: "src/jobAdder.wrapper.ts",
cwd: "./packages/crawler",
interpreter: "bun",
},
{
cwd: "./packages/crawler",
env: {
LOG_ERR: "logs/error.log",
LOG_VERBOSE: "logs/verbose.log",
LOG_WARN: "logs/warn.log",
},
interpreter: "bun",
name: "crawler-worker",
script: "src/worker.ts",
cwd: "./packages/crawler",
interpreter: "bun",
env: {
LOG_VERBOSE: "logs/verbose.log",
LOG_WARN: "logs/warn.log",
LOG_ERR: "logs/error.log",
},
},
{
cwd: "./packages/crawler",
env: {
LOG_ERR: "logs/error.log",
LOG_VERBOSE: "logs/verbose.log",
LOG_WARN: "logs/warn.log",
},
interpreter: "bun",
name: "crawler-filter",
script: "src/filterWorker.wrapper.ts",
cwd: "./packages/crawler",
interpreter: "bun",
env: {
LOG_VERBOSE: "logs/verbose.log",
LOG_WARN: "logs/warn.log",
LOG_ERR: "logs/error.log",
},
},
{
cwd: "./ml/api",
env: {
LOG_ERR: "logs/error.log",
LOG_VERBOSE: "logs/verbose.log",
LOG_WARN: "logs/warn.log",
PYTHONPATH: "./ml/api:./ml/filter",
},
interpreter: process.env.PYTHON_INTERPRETER || "python3",
name: "ml-api",
script: "start.py",
cwd: "./ml/api",
interpreter: process.env.PYTHON_INTERPRETER || "python3",
env: {
PYTHONPATH: "./ml/api:./ml/filter",
LOG_VERBOSE: "logs/verbose.log",
LOG_WARN: "logs/warn.log",
LOG_ERR: "logs/error.log",
},
},
{
name: "cvsa-be",
script: "src/index.ts",
cwd: "./packages/backend",
interpreter: "bun",
env: {
NODE_ENV: "production",
},
interpreter: "bun",
name: "cvsa-be",
script: "src/index.ts",
},
];

View File

@ -31,12 +31,12 @@ export async function verifyUser(
}
return {
createdAt: foundUser.createdAt,
id: foundUser.id,
username: foundUser.username,
nickname: foundUser.nickname,
role: foundUser.role,
unqId: foundUser.unqId,
createdAt: foundUser.createdAt,
username: foundUser.username,
};
}
@ -52,12 +52,12 @@ export async function createSession(
try {
await db.insert(loginSessionsInCredentials).values({
id: sessionId,
uid: userId,
ipAddress,
userAgent,
lastUsedAt: new Date().toISOString(),
expireAt: expireAt.toISOString(),
id: sessionId,
ipAddress,
lastUsedAt: new Date().toISOString(),
uid: userId,
userAgent,
});
} catch (error) {
logger.error(error as Error);
@ -107,8 +107,8 @@ export async function validateSession(
.where(eq(loginSessionsInCredentials.id, sessionId));
return {
user: users[0],
session: session,
user: users[0],
};
}

View File

@ -38,13 +38,13 @@ const avSchema = z.string().regex(AV_REGEX);
export function detectBiliID(id: string) {
if (bvSchema.safeParse(id).success) {
return {
type: "bv" as const,
id: id as `BV1${string}`,
type: "bv" as const,
};
} else if (avSchema.safeParse(id).success) {
return {
type: "av" as const,
id: id as `av${string}`,
type: "av" as const,
};
}
return null;

View File

@ -2,69 +2,69 @@ import { z } from "zod";
const videoStatsSchema = z.object({
aid: z.number(),
view: z.number(),
danmaku: z.number(),
reply: z.number(),
favorite: z.number(),
coin: z.number(),
share: z.number(),
now_rank: z.number(),
danmaku: z.number(),
favorite: z.number(),
his_rank: z.number(),
like: z.number(),
now_rank: z.number(),
reply: z.number(),
share: z.number(),
view: z.number(),
});
export const BiliAPIVideoMetadataSchema = z.object({
bvid: z.string(),
aid: z.number(),
bvid: z.string(),
copyright: z.number(),
pic: z.string(),
title: z.string(),
pubdate: z.number(),
ctime: z.number(),
desc: z.string(),
desc_v2: z.string(),
tname: z.string(),
tid: z.number(),
tid_v2: z.number(),
tname_v2: z.string(),
state: z.number(),
duration: z.number(),
owner: z.object({
face: z.string(),
mid: z.number(),
name: z.string(),
face: z.string(),
}),
pic: z.string(),
pubdate: z.number(),
stat: videoStatsSchema,
state: z.number(),
tid: z.number(),
tid_v2: z.number(),
title: z.string(),
tname: z.string(),
tname_v2: z.string(),
});
export const BiliVideoSchema = z.object({
duration: z.number().nullable(),
id: z.number(),
aid: z.number(),
publishedAt: z.string().nullable(),
bvid: z.string().nullable(),
coverUrl: z.string().nullable(),
createdAt: z.string().nullable(),
description: z.string().nullable(),
bvid: z.string().nullable(),
uid: z.number().nullable(),
duration: z.number().nullable(),
id: z.number(),
publishedAt: z.string().nullable(),
status: z.number(),
tags: z.string().nullable(),
title: z.string().nullable(),
status: z.number(),
coverUrl: z.string().nullable(),
uid: z.number().nullable(),
});
export type BiliVideoType = z.infer<typeof BiliVideoSchema>;
export const SongSchema = z.object({
duration: z.number().nullable(),
name: z.string().nullable(),
id: z.number(),
aid: z.number().nullable(),
createdAt: z.string(),
deleted: z.boolean(),
duration: z.number().nullable(),
id: z.number(),
image: z.string().nullable(),
name: z.string().nullable(),
neteaseId: z.number().nullable(),
producer: z.string().nullable(),
publishedAt: z.string().nullable(),
type: z.number().nullable(),
neteaseId: z.number().nullable(),
createdAt: z.string(),
updatedAt: z.string(),
deleted: z.boolean(),
image: z.string().nullable(),
producer: z.string().nullable(),
});

View File

@ -7,100 +7,100 @@ export interface Singer {
export const singers: Singer[] = [
{
name: "洛天依",
color: "#66CCFF",
birthday: "0712",
color: "#66CCFF",
name: "洛天依",
},
{
name: "言和",
color: "#00FFCC",
birthday: "0711",
color: "#00FFCC",
name: "言和",
},
{
name: "乐正绫",
color: "#EE0000",
birthday: "0412",
color: "#EE0000",
name: "乐正绫",
},
{
name: "乐正龙牙",
color: "#006666",
birthday: "1002",
color: "#006666",
name: "乐正龙牙",
},
{
name: "徵羽摩柯",
color: "#0080FF",
birthday: "1210",
color: "#0080FF",
name: "徵羽摩柯",
},
{
name: "墨清弦",
birthday: "0520",
color: "#FFFF00",
birthday: "0520",
name: "墨清弦",
},
{
name: "星尘",
color: "#9999FF",
birthday: "0812",
color: "#9999FF",
name: "星尘",
},
{
name: "永夜Minus",
color: "#613c8a",
birthday: "1208",
color: "#613c8a",
name: "永夜Minus",
},
{
name: "心华",
color: "#EE82EE",
birthday: "0210",
color: "#EE82EE",
name: "心华",
},
{
name: "海伊",
color: "#3399FF",
birthday: "0722",
color: "#3399FF",
name: "海伊",
},
{
name: "苍穹",
color: "#8BC0B5",
birthday: "0520",
color: "#8BC0B5",
name: "苍穹",
},
{
name: "赤羽",
color: "#FF4004",
birthday: "1126",
color: "#FF4004",
name: "赤羽",
},
{
name: "诗岸",
color: "#F6BE72",
birthday: "0119",
color: "#F6BE72",
name: "诗岸",
},
{
name: "牧心",
color: "#2A2859",
birthday: "0807",
color: "#2A2859",
name: "牧心",
},
{
name: "起礼",
birthday: "0713",
color: "#FF0099",
birthday: "0713",
name: "起礼",
},
{
name: "起复",
birthday: "0713",
color: "#99FF00",
birthday: "0713",
name: "起复",
},
{
name: "夏语遥",
color: "#34CCCC",
birthday: "1110",
color: "#34CCCC",
name: "夏语遥",
},
];
export const specialSingers = [
{
name: "雅音宫羽",
message: "你是我最真模样,从来不曾遗忘。",
name: "雅音宫羽",
},
{
name: "初音未来",
message: "初始之音,响彻未来!",
name: "初音未来",
},
];

View File

@ -40,9 +40,9 @@ export const requireAuth = new Elysia({ name: "require-auth" })
if (!sessionId) {
set.status = 401;
return {
user: null,
session: null,
isAuthenticated: false,
session: null,
user: null,
};
}
@ -52,17 +52,17 @@ export const requireAuth = new Elysia({ name: "require-auth" })
if (!validationResult) {
set.status = 401;
return {
user: null,
session: null,
isAuthenticated: false,
session: null,
user: null,
};
}
// Session is valid, return user and session context
return {
user: validationResult.user,
session: validationResult.session,
isAuthenticated: true,
session: validationResult.session,
user: validationResult.user,
};
})
.onBeforeHandle({ as: "scoped" }, ({ user, status }) => {

View File

@ -14,8 +14,8 @@ export const openAPIMiddleware = openapi({
},
references: fromTypes(),
scalar: {
theme: "kepler",
hideClientButton: true,
hideDarkModeToggle: true,
theme: "kepler",
},
});

View File

@ -13,8 +13,8 @@ class TimeLogger {
getCompletedDurations() {
return Array.from(this.durations.entries()).map(([name, duration]) => ({
name,
duration,
name,
}));
}

View File

@ -25,41 +25,41 @@ export const loginHandler = new Elysia({ prefix: "/auth" }).use(ip()).post(
return {
message: "You are logged in.",
sessionID: sessionId,
user: {
id: user.id,
username: user.username,
nickname: user.nickname,
role: user.role,
username: user.username,
},
sessionID: sessionId,
};
},
{
body: t.Object({
password: t.String(),
username: t.String(),
}),
detail: {
description:
"This endpoint authenticates users by verifying their credentials and creates a new session. \
Upon successful authentication, it returns user information and sets a secure HTTP-only cookie \
for session management. The session includes IP address and user agent tracking for security purposes.",
summary: "User login",
},
response: {
200: t.Object({
message: t.String(),
sessionID: t.String(),
user: t.Object({
id: t.Integer(),
username: t.String(),
nickname: t.Optional(t.String()),
role: t.String(),
username: t.String(),
}),
sessionID: t.String(),
}),
401: t.Object({
message: t.String(),
}),
},
body: t.Object({
username: t.String(),
password: t.String(),
}),
detail: {
summary: "User login",
description:
"This endpoint authenticates users by verifying their credentials and creates a new session. \
Upon successful authentication, it returns user information and sets a secure HTTP-only cookie \
for session management. The session includes IP address and user agent tracking for security purposes.",
},
}
);

View File

@ -18,6 +18,13 @@ export const logoutHandler = new Elysia({ prefix: "/auth" }).use(requireAuth).de
return { message: "Successfully logged out." };
},
{
detail: {
description:
"This endpoint logs out the current user by deactivating their session and removing the session cookie. \
It requires an active session cookie to be present in the request. After successful logout, the session \
is invalidated and cannot be used again.",
summary: "Logout current session",
},
response: {
200: t.Object({
message: t.String(),
@ -26,12 +33,5 @@ export const logoutHandler = new Elysia({ prefix: "/auth" }).use(requireAuth).de
message: t.String(),
}),
},
detail: {
summary: "Logout current session",
description:
"This endpoint logs out the current user by deactivating their session and removing the session cookie. \
It requires an active session cookie to be present in the request. After successful logout, the session \
is invalidated and cannot be used again.",
},
}
);

View File

@ -9,18 +9,18 @@ export const getCurrentUserHandler = new Elysia().use(requireAuth).get(
}
return {
id: user.id,
username: user.username,
nickname: user.nickname,
role: user.role,
username: user.username,
};
},
{
response: {
200: t.Object({
id: t.Integer(),
username: t.String(),
nickname: t.Union([t.String(), t.Null()]),
role: t.String(),
username: t.String(),
}),
401: t.Object({
message: t.String(),

View File

@ -8,42 +8,42 @@ export const pingHandler = new Elysia({ prefix: "/ping" }).use(ip()).get(
return {
message: "pong",
request: {
body: body,
headers: headers,
ip: ip,
method: request.method,
body: body,
url: request.url,
},
response: {
time: Date.now(),
status: 200,
time: Date.now(),
version: VERSION,
},
};
},
{
body: t.Optional(t.String()),
detail: {
description:
"This endpoint returns a 'pong' message along with comprehensive information about the incoming request and the server's current status, including request headers, IP address, and server version. It's useful for monitoring API availability and debugging.",
summary: "Send a ping",
},
response: {
200: t.Object({
message: t.String(),
request: t.Object({
body: t.Optional(t.Union([t.String(), t.Null()])),
headers: t.Any(),
ip: t.Optional(t.String()),
method: t.String(),
body: t.Optional(t.Union([t.String(), t.Null()])),
url: t.String(),
}),
response: t.Object({
time: t.Number(),
status: t.Number(),
time: t.Number(),
version: t.String(),
}),
}),
},
body: t.Optional(t.String()),
detail: {
summary: "Send a ping",
description:
"This endpoint returns a 'pong' message along with comprehensive information about the incoming request and the server's current status, including request headers, IP address, and server version. It's useful for monitoring API availability and debugging.",
},
}
);

View File

@ -8,10 +8,10 @@ import { VERSION } from "@backend/src";
import { Elysia, t } from "elysia";
const SingerObj = t.Object({
name: t.String(),
color: t.Optional(t.String()),
birthday: t.Optional(t.String()),
color: t.Optional(t.String()),
message: t.Optional(t.String()),
name: t.String(),
});
export const rootHandler = new Elysia().get(
@ -32,35 +32,35 @@ export const rootHandler = new Elysia().get(
}
return {
project: {
name: "中V档案馆",
mascot: "知夏",
name: "中V档案馆",
quote: "星河知海夏生光",
},
status: 200,
version: VERSION,
time: Date.now(),
singer: singer,
status: 200,
time: Date.now(),
version: VERSION,
};
},
{
response: {
200: t.Object({
project: t.Object({
name: t.String(),
mascot: t.String(),
quote: t.String(),
}),
status: t.Number(),
version: t.String(),
time: t.Number(),
singer: t.Union([SingerObj, t.Array(SingerObj)]),
}),
},
detail: {
summary: "Root route",
description:
"The root path. It returns a JSON object containing a random virtual singer, \
backend version, current server time and other miscellaneous information.",
summary: "Root route",
},
response: {
200: t.Object({
project: t.Object({
mascot: t.String(),
name: t.String(),
quote: t.String(),
}),
singer: t.Union([SingerObj, t.Array(SingerObj)]),
status: t.Number(),
time: t.Number(),
version: t.String(),
}),
},
}
);

View File

@ -23,11 +23,11 @@ const getSongSearchResult = async (searchQuery: string) => {
const lengthRatio = searchQuery.length / song.songs.name.length;
const viewsLog = Math.log10(song.latest_video_snapshot.views + 1);
return {
type: "song" as "song",
data: song,
occurrences,
viewsLog,
lengthRatio,
occurrences,
type: "song" as "song",
viewsLog,
};
})
.filter((d) => d !== null);
@ -52,9 +52,9 @@ const getSongSearchResult = async (searchQuery: string) => {
normalizedOccurrences * 0.3 + result.lengthRatio * 0.5 + normalizedViewsLog * 0.2;
return {
type: result.type,
data: result.data.songs,
rank: Math.min(Math.max(rank, 0), 1), // Ensure rank is between 0 and 1
type: result.type,
};
});
@ -70,9 +70,9 @@ const getDBVideoSearchResult = async (searchQuery: string) => {
.innerJoin(latestVideoSnapshot, eq(bilibiliMetadata.aid, latestVideoSnapshot.aid))
.where(eq(bilibiliMetadata.aid, aid));
return results.map((video) => ({
type: "bili-video-db" as "bili-video-db",
data: { views: video.latest_video_snapshot.views, ...video.bilibili_metadata },
rank: 1, // Exact match
type: "bili-video-db" as "bili-video-db",
}));
};
@ -92,9 +92,9 @@ const getVideoSearchResult = async (searchQuery: string) => {
}
return [
{
type: "bili-video" as "bili-video",
data: data,
rank: 0.99, // Exact match
type: "bili-video" as "bili-video",
},
];
};
@ -123,43 +123,43 @@ export const searchHandler = new Elysia({ prefix: "/search" }).get(
};
},
{
response: {
200: z.object({
elapsedMs: z.number(),
data: z.array(
z.union([
z.object({
type: z.literal("song"),
data: SongSchema,
rank: z.number(),
}),
z.object({
type: z.literal("bili-video-db"),
data: BiliVideoDataSchema,
rank: z.number(),
}),
z.object({
type: z.literal("bili-video"),
data: BiliAPIVideoMetadataSchema,
rank: z.number(),
}),
])
),
}),
404: z.object({
message: z.string(),
}),
},
query: z.object({
query: z.string(),
}),
detail: {
summary: "Search songs and videos",
description:
"This endpoint performs a comprehensive search across songs and videos in the database. \
It searches for songs by name and videos by bilibili ID (av/BV format). The results are ranked \
by relevance using a weighted algorithm that considers search term frequency, title length, \
and view count. Returns search results with performance timing information.",
summary: "Search songs and videos",
},
query: z.object({
query: z.string(),
}),
response: {
200: z.object({
data: z.array(
z.union([
z.object({
data: SongSchema,
rank: z.number(),
type: z.literal("song"),
}),
z.object({
data: BiliVideoDataSchema,
rank: z.number(),
type: z.literal("bili-video-db"),
}),
z.object({
data: BiliAPIVideoMetadataSchema,
rank: z.number(),
type: z.literal("bili-video"),
}),
])
),
elapsedMs: z.number(),
}),
404: z.object({
message: z.string(),
}),
},
}
);

View File

@ -40,15 +40,26 @@ export const addSongHandler = new Elysia()
});
}
return status(201, {
message: "Successfully created import session.",
jobID: job.id,
message: "Successfully created import session.",
});
},
{
body: t.Object({
id: t.String(),
}),
detail: {
description:
"This endpoint allows authenticated users to import a song from bilibili by providing a video ID. \
The video ID can be in av or BV format. The system validates the ID format, checks if the video already \
exists in the database, and if not, creates a background job to fetch video metadata and add it to the songs collection. \
Returns the job ID for tracking the import progress.",
summary: "Import song from bilibili",
},
response: {
201: t.Object({
message: t.String(),
jobID: t.String(),
message: t.String(),
}),
400: t.Object({
message: t.String(),
@ -60,17 +71,6 @@ export const addSongHandler = new Elysia()
message: t.String(),
}),
},
body: t.Object({
id: t.String(),
}),
detail: {
summary: "Import song from bilibili",
description:
"This endpoint allows authenticated users to import a song from bilibili by providing a video ID. \
The video ID can be in av or BV format. The system validates the ID format, checks if the video already \
exists in the database, and if not, creates a background job to fetch video metadata and add it to the songs collection. \
Returns the job ID for tracking the import progress.",
},
}
)
.get(
@ -80,10 +80,10 @@ export const addSongHandler = new Elysia()
if (parseInt(jobID) === -1) {
return {
id: jobID,
state: "completed",
result: {
message: "Video already exists in the songs table.",
},
state: "completed",
};
}
const job = await LatestVideosQueue.getJob(jobID);
@ -94,33 +94,33 @@ export const addSongHandler = new Elysia()
}
const state = await job.getState();
return {
id: job.id!,
state,
result: job.returnvalue,
failedReason: job.failedReason,
id: job.id!,
result: job.returnvalue,
state,
};
},
{
detail: {
description:
"This endpoint retrieves the current status of a song import job. It returns the job state \
(completed, failed, active, etc.), the result if completed, and any failure reason if the job failed. \
Use this endpoint to monitor the progress of song imports initiated through the import endpoint.",
summary: "Check import job status",
},
params: t.Object({
id: t.String(),
}),
response: {
200: t.Object({
id: t.String(),
state: t.String(),
result: t.Optional(t.Any()),
failedReason: t.Optional(t.String()),
id: t.String(),
result: t.Optional(t.Any()),
state: t.String(),
}),
404: t.Object({
message: t.String(),
}),
},
params: t.Object({
id: t.String(),
}),
detail: {
summary: "Check import job status",
description:
"This endpoint retrieves the current status of a song import job. It returns the job state \
(completed, failed, active, etc.), the result if completed, and any failure reason if the job failed. \
Use this endpoint to monitor the progress of song imports initiated through the import endpoint.",
},
}
);

View File

@ -9,16 +9,26 @@ export const deleteSongHandler = new Elysia({ prefix: "/song" }).use(requireAuth
const id = Number(params.id);
await db.update(songs).set({ deleted: true }).where(eq(songs.id, id));
await db.insert(history).values({
objectId: id,
changeType: "del-song",
changedBy: user!.unqId,
changeType: "del-song",
data: null,
objectId: id,
});
return {
message: `Successfully deleted song ${id}.`,
};
},
{
detail: {
description:
"This endpoint allows authenticated users to soft-delete a song from the database. \
The song is marked as deleted rather than being permanently removed, preserving data integrity. \
The deletion is logged in the history table for audit purposes. Requires authentication and appropriate permissions.",
summary: "Delete song",
},
params: t.Object({
id: t.String(),
}),
response: {
200: t.Object({
message: t.String(),
@ -30,15 +40,5 @@ export const deleteSongHandler = new Elysia({ prefix: "/song" }).use(requireAuth
message: t.String(),
}),
},
params: t.Object({
id: t.String(),
}),
detail: {
summary: "Delete song",
description:
"This endpoint allows authenticated users to soft-delete a song from the database. \
The song is marked as deleted rather than being permanently removed, preserving data integrity. \
The deletion is logged in the history table for audit purposes. Requires authentication and appropriate permissions.",
},
}
);

View File

@ -31,37 +31,37 @@ export const closeMileStoneHandler = new Elysia({ prefix: "/songs" }).use(server
return q.limit(limit || 20).offset(offset || 0);
},
{
detail: {
description:
"This endpoint retrieves songs that are approaching significant view count milestones. \
It supports three milestone types: 'dendou' (0-100k views), 'densetsu' (100k-1M views), and 'shinwa' (1M-10M views). \
For each type, it returns videos that are within the specified view range and have an estimated time to reach \
the next milestone below the threshold. Results are ordered by estimated time to milestone.",
summary: "Get songs close to milestones",
},
params: t.Object({
type: t.String({ enum: ["dendou", "densetsu", "shinwa"] }),
}),
query: t.Object({
limit: t.Optional(t.Number()),
offset: t.Optional(t.Number()),
}),
response: {
200: z.array(
z.object({
bilibili_metadata: BiliVideoSchema,
eta: z.object({
aid: z.number(),
currentViews: z.number(),
eta: z.number(),
speed: z.number(),
currentViews: z.number(),
updatedAt: z.string(),
}),
bilibili_metadata: BiliVideoSchema,
})
),
404: t.Object({
message: t.String(),
}),
},
params: t.Object({
type: t.String({ enum: ["dendou", "densetsu", "shinwa"] }),
}),
query: t.Object({
offset: t.Optional(t.Number()),
limit: t.Optional(t.Number()),
}),
detail: {
summary: "Get songs close to milestones",
description:
"This endpoint retrieves songs that are approaching significant view count milestones. \
It supports three milestone types: 'dendou' (0-100k views), 'densetsu' (100k-1M views), and 'shinwa' (1M-10M views). \
For each type, it returns videos that are within the specified view range and have an estimated time to reach \
the next milestone below the threshold. Results are ordered by estimated time to milestone.",
},
}
);

View File

@ -25,19 +25,30 @@ export const songEtaHandler = new Elysia({ prefix: "/video" }).get(
return {
aid: data[0].aid,
eta: data[0].eta,
views: data[0].currentViews,
speed: data[0].speed,
updatedAt: data[0].updatedAt,
views: data[0].currentViews,
};
},
{
detail: {
description:
"This endpoint retrieves the estimated time to reach the next milestone for a given video. \
It accepts video IDs in av or BV format and returns the current view count, estimated time to \
reach the next milestone (in hours), view growth speed, and last update timestamp. Useful for \
tracking video growth and milestone predictions.",
summary: "Get video milestone ETA",
},
headers: t.Object({
Authorization: t.Optional(t.String()),
}),
response: {
200: t.Object({
aid: t.Number(),
eta: t.Number(),
views: t.Number(),
speed: t.Number(),
updatedAt: t.String(),
views: t.Number(),
}),
400: t.Object({
code: t.String(),
@ -48,16 +59,5 @@ export const songEtaHandler = new Elysia({ prefix: "/video" }).get(
message: t.String(),
}),
},
headers: t.Object({
Authorization: t.Optional(t.String()),
}),
detail: {
summary: "Get video milestone ETA",
description:
"This endpoint retrieves the estimated time to reach the next milestone for a given video. \
It accepts video IDs in av or BV format and returns the current view count, estimated time to \
reach the next milestone (in hours), view growth speed, and last update timestamp. Useful for \
tracking video growth and milestone predictions.",
},
}
);

View File

@ -11,12 +11,12 @@ const videoSchema = BiliVideoSchema.omit({ publishedAt: true })
.omit({ createdAt: true })
.omit({ coverUrl: true })
.extend({
views: z.number(),
username: z.string(),
uid: z.number(),
published_at: z.string(),
createdAt: z.string(),
cover_url: z.string(),
createdAt: z.string(),
published_at: z.string(),
uid: z.number(),
username: z.string(),
views: z.number(),
});
export const getUnlabelledVideos = new Elysia({ prefix: "/videos" }).use(requireAuth).get(
@ -75,9 +75,9 @@ export const postVideoLabel = new Elysia({ prefix: "/video" }).use(requireAuth).
if (!aid) {
return status(400, {
code: "MALFORMED_SLOT",
errors: [],
message:
"We cannot parse the video ID, or we currently do not support this format.",
errors: [],
});
}
@ -90,8 +90,8 @@ export const postVideoLabel = new Elysia({ prefix: "/video" }).use(requireAuth).
if (video.length === 0) {
return status(400, {
code: "VIDEO_NOT_FOUND",
message: "Video not found",
errors: [],
message: "Video not found",
});
}

View File

@ -34,13 +34,13 @@ async function insertVideoSnapshot(data: VideoInfoData) {
await db.insert(videoSnapshot).values({
aid,
views,
danmakus,
replies,
likes,
coins,
shares,
danmakus,
favorites,
likes,
replies,
shares,
views,
});
snapshotCounter.add(1);
}
@ -54,9 +54,9 @@ export const getVideoMetadataHandler = new Elysia({ prefix: "/video" }).get(
if (!aid) {
return c.status(400, {
code: "MALFORMED_SLOT",
errors: [],
message:
"We cannot parse the video ID, or we currently do not support this format.",
errors: [],
});
}
@ -70,8 +70,8 @@ export const getVideoMetadataHandler = new Elysia({ prefix: "/video" }).get(
if (typeof r === "number") {
return c.status(500, {
code: "THIRD_PARTY_ERROR",
message: `Got status code ${r} from bilibili API.`,
errors: [],
message: `Got status code ${r} from bilibili API.`,
});
}
@ -83,18 +83,18 @@ export const getVideoMetadataHandler = new Elysia({ prefix: "/video" }).get(
return data;
},
{
response: {
200: BiliAPIVideoMetadataSchema,
400: ErrorResponseSchema,
500: ErrorResponseSchema,
},
detail: {
summary: "Get video metadata",
description:
"This endpoint retrieves comprehensive metadata for a bilibili video. It accepts video IDs in av or BV format \
and returns detailed information including title, description, uploader, statistics (views, likes, coins, etc.), \
and publication date. The data is cached for 60 seconds to reduce API calls. If the video is not in cache, \
it fetches fresh data from bilibili API and stores a snapshot in the database.",
summary: "Get video metadata",
},
response: {
200: BiliAPIVideoMetadataSchema,
400: ErrorResponseSchema,
500: ErrorResponseSchema,
},
}
);

View File

@ -15,9 +15,9 @@ export const getVideoSnapshotsHandler = new Elysia({ prefix: "/video" }).get(
if (!aid) {
return c.status(400, {
code: "MALFORMED_SLOT",
errors: [],
message:
"We cannot parse the video ID, or we currently do not support this format.",
errors: [],
});
}
@ -36,31 +36,31 @@ export const getVideoSnapshotsHandler = new Elysia({ prefix: "/video" }).get(
return data;
},
{
response: {
200: z.array(
z.object({
id: z.number(),
createdAt: z.string(),
views: z.number(),
coins: z.number().nullable(),
likes: z.number().nullable(),
favorites: z.number().nullable(),
shares: z.number().nullable(),
danmakus: z.number().nullable(),
aid: z.number(),
replies: z.number().nullable(),
})
),
400: ErrorResponseSchema,
500: ErrorResponseSchema,
},
detail: {
summary: "Get video snapshots",
description:
"This endpoint retrieves historical view count snapshots for a bilibili video. It accepts video IDs in av or BV format \
and returns a chronological list of snapshots showing how the video's statistics (views, likes, coins, favorites, etc.) \
have changed over time. If no snapshots exist for the video, it automatically queues a snapshot job to collect initial data. \
Results are ordered by creation date in descending order.",
summary: "Get video snapshots",
},
response: {
200: z.array(
z.object({
aid: z.number(),
coins: z.number().nullable(),
createdAt: z.string(),
danmakus: z.number().nullable(),
favorites: z.number().nullable(),
id: z.number(),
likes: z.number().nullable(),
replies: z.number().nullable(),
shares: z.number().nullable(),
views: z.number(),
})
),
400: ErrorResponseSchema,
500: ErrorResponseSchema,
},
}
);

View File

@ -12,10 +12,10 @@ queueEvents.on<CustomListener>(
"addSong",
async ({ uid, songID }: { uid: string; songID: number }) => {
await db.insert(history).values({
objectId: songID,
changeType: "add-song",
changedBy: uid,
changeType: "add-song",
data: null,
objectId: songID,
});
}
);

View File

@ -24,10 +24,10 @@ export const onAfterHandler = new Elysia().onAfterHandle(
? JSON.stringify(realResponse.response, null, 2)
: JSON.stringify(realResponse.response);
return new Response(encoder.encode(text), {
status: realResponse.code as any,
headers: {
"Content-Type": "application/json; charset=utf-8",
},
status: realResponse.code as any,
});
}
const text = isBrowser

View File

@ -28,7 +28,6 @@ function generateErrorCodeRegex(strings: string[]): string {
export const ErrorResponseSchema = t.Object({
code: t.String({ pattern: generateErrorCodeRegex(errorCodes) }),
message: t.String(),
errors: t.Array(t.String()),
i18n: t.Optional(
t.Object({
@ -36,4 +35,5 @@ export const ErrorResponseSchema = t.Object({
values: t.Optional(t.Record(t.String(), t.Union([t.String(), t.Number(), t.Date()]))),
})
),
message: t.String(),
});

View File

@ -166,15 +166,18 @@ async function handleFetch(
}
function createJsonResponse(data: ProxyResponseData, requestId: string): Response {
return new Response(JSON.stringify({
...data,
requestId,
}), {
headers: {
"Access-Control-Allow-Origin": "*",
"Content-Type": "application/json",
},
});
return new Response(
JSON.stringify({
...data,
requestId,
}),
{
headers: {
"Access-Control-Allow-Origin": "*",
"Content-Type": "application/json",
},
}
);
}
function createErrorResponse(message: string, status: number, requestId: string): Response {
@ -182,8 +185,8 @@ function createErrorResponse(message: string, status: number, requestId: string)
JSON.stringify({
data: "",
error: message,
time: Date.now(),
requestId,
time: Date.now(),
}),
{
headers: {

View File

@ -5,7 +5,7 @@ declare namespace Cloudflare {
interface GlobalProps {
mainModule: typeof import("./src/index");
}
interface Env {}
type Env = {};
}
interface Env extends Cloudflare.Env {}
@ -464,7 +464,7 @@ declare const performance: Performance;
declare const Cloudflare: Cloudflare;
declare const origin: string;
declare const navigator: Navigator;
interface TestController {}
type TestController = {};
interface ExecutionContext<Props = unknown> {
waitUntil(promise: Promise<any>): void;
passThroughOnException(): void;
@ -590,7 +590,7 @@ type DurableObjectLocationHint =
interface DurableObjectNamespaceGetDurableObjectOptions {
locationHint?: DurableObjectLocationHint;
}
interface DurableObjectClass<_T extends Rpc.DurableObjectBranded | undefined = undefined> {}
type DurableObjectClass<_T extends Rpc.DurableObjectBranded | undefined = undefined> = {};
interface DurableObjectState<Props = unknown> {
waitUntil(promise: Promise<any>): void;
readonly exports: Cloudflare.Exports;
@ -2933,7 +2933,7 @@ interface TraceItem {
interface TraceItemAlarmEventInfo {
readonly scheduledTime: Date;
}
interface TraceItemCustomEventInfo {}
type TraceItemCustomEventInfo = {};
interface TraceItemScheduledEventInfo {
readonly scheduledTime: number;
readonly cron: string;
@ -10460,7 +10460,7 @@ declare abstract class D1PreparedStatement {
// but this will ensure type checking on older versions still passes.
// TypeScript's interface merging will ensure our empty interface is effectively
// ignored when `Disposable` is included in the standard lib.
interface Disposable {}
type Disposable = {};
/**
* An email message that can be sent from a Worker.
*/
@ -11166,7 +11166,7 @@ declare namespace Cloudflare {
// will merge all declarations.
//
// You can use `wrangler types` to generate the `Env` type automatically.
interface Env {}
type Env = {};
// Project-specific parameters used to inform types.
//
// This interface is, again, intended to be declared in project-specific files, and then that
@ -11185,7 +11185,7 @@ declare namespace Cloudflare {
// }
//
// You can use `wrangler types` to generate `GlobalProps` automatically.
interface GlobalProps {}
type GlobalProps = {};
// Evaluates to the type of a property in GlobalProps, defaulting to `Default` if it is not
// present.
type GlobalProp<K extends string, Default> = K extends keyof GlobalProps

View File

@ -18,17 +18,17 @@ const databasePassword = getEnvVar("DB_PASSWORD")!;
const databasePort = getEnvVar("DB_PORT")!;
export const postgresConfig = {
host: databaseHost,
port: parseInt(databasePort),
database: databaseName,
username: databaseUser,
host: databaseHost,
password: databasePassword,
port: parseInt(databasePort),
username: databaseUser,
};
export const postgresConfigCred = {
hostname: databaseHost,
port: parseInt(databasePort),
database: databaseNameCred,
user: databaseUser,
hostname: databaseHost,
password: databasePassword,
port: parseInt(databasePort),
user: databaseUser,
};

View File

@ -4,7 +4,7 @@ const host = process.env.REDIS_HOST || "localhost";
const port = parseInt(process.env.REDIS_PORT) || 6379;
export const redis = new Redis({
port: port,
host: host,
maxRetriesPerRequest: null,
port: port,
});

View File

@ -2,10 +2,10 @@ import "dotenv/config";
import { defineConfig } from "drizzle-kit";
export default defineConfig({
out: "./drizzle/main",
dialect: "postgresql",
dbCredentials: {
url: process.env.DATABASE_URL_MAIN!,
},
dialect: "postgresql",
out: "./drizzle/main",
schemaFilter: ["public", "credentials", "internal"],
});

View File

@ -1,9 +1,9 @@
import { defineConfig } from "drizzle-kit";
export default defineConfig({
out: "./cred",
dialect: "postgresql",
dbCredentials: {
url: process.env.DATABASE_URL_CRED!,
},
dialect: "postgresql",
out: "./cred",
});

View File

@ -6,9 +6,9 @@ if (!process.env.DATABASE_URL_MAIN) {
}
export default defineConfig({
out: "./drizzle/main",
dialect: "postgresql",
dbCredentials: {
url: process.env.DATABASE_URL_MAIN,
},
dialect: "postgresql",
out: "./drizzle/main",
});

View File

@ -43,12 +43,12 @@ const createTransport = (level: string, filename: string) => {
return value;
}
return new transports.File({
level,
filename,
format: format.combine(timestampFormat, format.json({ replacer })),
level,
maxFiles,
maxsize,
tailable,
maxFiles,
format: format.combine(timestampFormat, format.json({ replacer })),
});
};
@ -60,13 +60,13 @@ const winstonLogger = winston.createLogger({
levels: winston.config.npm.levels,
transports: [
new transports.Console({
level: "debug",
format: format.combine(
format.timestamp({ format: "YYYY-MM-DD HH:mm:ss.SSSZZ" }),
format.colorize(),
format.errors({ stack: true }),
customFormat
),
level: "debug",
}),
createTransport("silly", sillyLogPath),
createTransport("warn", warnLogPath),
@ -75,28 +75,28 @@ const winstonLogger = winston.createLogger({
});
const logger = {
silly: (message: string, service?: string, codePath?: string) => {
winstonLogger.silly(message, { service, codePath });
},
verbose: (message: string, service?: string, codePath?: string) => {
winstonLogger.verbose(message, { service, codePath });
},
log: (message: string, service?: string, codePath?: string) => {
winstonLogger.info(message, { service, codePath });
},
debug: (message: string, service?: string, codePath?: string) => {
winstonLogger.debug(message, { service, codePath });
},
warn: (message: string, service?: string, codePath?: string) => {
winstonLogger.warn(message, { service, codePath });
winstonLogger.debug(message, { codePath, service });
},
error: (error: string | Error, service?: string, codePath?: string) => {
if (error instanceof Error) {
winstonLogger.error(error.message, { service, error: error, codePath });
winstonLogger.error(error.message, { codePath, error: error, service });
} else {
winstonLogger.error(error, { service, codePath });
winstonLogger.error(error, { codePath, service });
}
},
log: (message: string, service?: string, codePath?: string) => {
winstonLogger.info(message, { codePath, service });
},
silly: (message: string, service?: string, codePath?: string) => {
winstonLogger.silly(message, { codePath, service });
},
verbose: (message: string, service?: string, codePath?: string) => {
winstonLogger.verbose(message, { codePath, service });
},
warn: (message: string, service?: string, codePath?: string) => {
winstonLogger.warn(message, { codePath, service });
},
};
export default logger;

View File

@ -40,9 +40,9 @@ export class MultipleRateLimiter {
const { duration, max } = this.configs[i];
const { allowed } = await this.limiter.allow(`cvsa:${this.name}_${i}`, {
burst: max,
ratePerPeriod: max,
period: duration,
cost: 1,
period: duration,
ratePerPeriod: max,
});
if (!allowed && shouldThrow) {
throw new RateLimiterError("Rate limit exceeded");

View File

@ -17,14 +17,14 @@ export class BilibiliService {
const stats = metadata.data.data.stat;
return {
aid,
createdAt: new Date(metadata.time).toISOString(),
views: stats.view,
likes: stats.like,
coins: stats.coin,
createdAt: new Date(metadata.time).toISOString(),
danmakus: stats.danmaku,
favorites: stats.favorite,
likes: stats.like,
replies: stats.reply,
shares: stats.share,
danmakus: stats.danmaku,
views: stats.view,
};
}
}

View File

@ -73,9 +73,9 @@ export async function getVideoInfoFromAllData(aid: number) {
const row = rows[0];
return {
title: row.title,
description: row.description,
tags: row.tags,
title: row.title,
};
}

View File

@ -31,10 +31,10 @@ export class APIManager {
public async healthCheck(): Promise<boolean> {
try {
const response = await fetch(`${this.baseUrl}/health`, {
method: "GET",
headers: {
"Content-Type": "application/json",
},
method: "GET",
signal: AbortSignal.timeout(this.timeout),
});
@ -57,19 +57,19 @@ export class APIManager {
aid?: number
): Promise<number> {
const request: ClassificationRequest = {
title: title.trim() || "untitled",
aid: aid,
description: description.trim() || "N/A",
tags: tags.trim() || "empty",
aid: aid,
title: title.trim() || "untitled",
};
try {
const response = await fetch(`${this.baseUrl}/classify`, {
method: "POST",
body: JSON.stringify(request),
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(request),
method: "POST",
signal: AbortSignal.timeout(this.timeout),
});
@ -98,11 +98,11 @@ export class APIManager {
): Promise<Array<{ aid?: number; label: number; probabilities: number[] }>> {
try {
const response = await fetch(`${this.baseUrl}/classify_batch`, {
method: "POST",
body: JSON.stringify(requests),
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(requests),
method: "POST",
signal: AbortSignal.timeout(this.timeout * 2), // Longer timeout for batch
});

View File

@ -61,8 +61,8 @@ export const archiveSnapshotsWorker = async (_job: Job) => {
const now = Date.now();
const targetTime = getRandomTimeInNextWeek();
const interval = intervalToDuration({
start: new Date(),
end: new Date(targetTime),
start: new Date(),
});
const formatted = formatDuration(interval, { format: ["days", "hours"] });

View File

@ -1,7 +1,6 @@
import { sql } from "@core/db/dbNew";
import type { Job } from "bullmq";
import { queueLatestVideos } from "mq/task/queueLatestVideo";
export const getLatestVideosWorker = async (_job: Job): Promise<void> => {
await queueLatestVideos(sql);
await queueLatestVideos();
};

View File

@ -22,8 +22,8 @@ interface AddSongEventPayload {
const publishAddsongEvent = async (songID: number, uid: string) =>
latestVideosEventsProducer.publishEvent<AddSongEventPayload>({
eventName: "addSong",
uid: uid,
songID: songID,
uid: uid,
});
export const getVideoInfoWorker = async (job: Job<GetVideoInfoJobData>): Promise<void> => {
@ -56,34 +56,34 @@ export const getVideoInfoWorker = async (job: Job<GetVideoInfoJobData>): Promise
await insertIntoMetadata({
aid,
bvid: data.View.bvid,
coverUrl: data.View.pic,
description: data.View.desc,
uid: uid,
duration: data.View.duration,
publishedAt: new Date(data.View.pubdate * 1000).toISOString(),
tags: data.Tags.filter((tag) => !["old_channel", "topic"].indexOf(tag.tag_type))
.map((tag) => tag.tag_name)
.join(","),
title: data.View.title,
publishedAt: new Date(data.View.pubdate * 1000).toISOString(),
duration: data.View.duration,
coverUrl: data.View.pic,
uid: uid,
});
const userExists = await userExistsInBiliUsers(aid);
if (!userExists) {
await db.insert(bilibiliUser).values({
uid,
username: data.View.owner.name,
avatar: data.View.owner.face,
desc: data.Card.card.sign,
fans: data.Card.follower,
avatar: data.View.owner.face,
uid,
username: data.View.owner.name,
});
} else {
await db
.update(bilibiliUser)
.set({
username: data.View.owner.name,
avatar: data.View.owner.face,
desc: data.Card.card.sign,
fans: data.Card.follower,
avatar: data.View.owner.face,
username: data.View.owner.name,
})
.where(eq(bilibiliUser.uid, uid));
}
@ -92,13 +92,13 @@ export const getVideoInfoWorker = async (job: Job<GetVideoInfoJobData>): Promise
await db.insert(videoSnapshot).values({
aid,
views: stat.view,
danmakus: stat.danmaku,
replies: stat.reply,
likes: stat.like,
coins: stat.coin,
shares: stat.share,
danmakus: stat.danmaku,
favorites: stat.favorite,
likes: stat.like,
replies: stat.reply,
shares: stat.share,
views: stat.view,
});
snapshotCounter.add(1);

View File

@ -32,12 +32,12 @@ export const bulkSnapshotTickWorker = async (_job: Job) => {
const schedulesData = group.map((schedule) => {
return {
aid: Number(schedule.aid),
id: Number(schedule.id),
type: schedule.type,
created_at: schedule.created_at,
started_at: schedule.started_at,
finished_at: schedule.finished_at,
id: Number(schedule.id),
started_at: schedule.started_at,
status: schedule.status,
type: schedule.type,
};
});
await SnapshotQueue.add(

View File

@ -17,8 +17,8 @@ import { closetMilestone } from "./snapshotTick";
const snapshotTypeToTaskMap = {
milestone: "snapshotMilestoneVideo",
normal: "snapshotVideo",
new: "snapshotMilestoneVideo",
normal: "snapshotVideo",
} as const;
export const snapshotVideoWorker = async (job: Job): Promise<void> => {

View File

@ -44,28 +44,28 @@ export async function takeVideoSnapshot(
const favorites = data.stat.favorite;
await insertVideoSnapshot({
createdAt: new Date(time).toISOString(),
views,
coins,
likes,
favorites,
shares,
danmakus,
replies,
aid,
coins,
createdAt: new Date(time).toISOString(),
danmakus,
favorites,
likes,
replies,
shares,
views,
});
logger.log(`Taken snapshot for video ${aid}.`, "net", "fn:insertVideoSnapshot");
return {
aid,
views,
danmakus,
replies,
likes,
coins,
shares,
danmakus,
favorites,
likes,
replies,
shares,
time,
views,
};
}

View File

@ -1,4 +1,3 @@
import type { Psql } from "@core/db/psql.d";
import { SECOND } from "@core/lib";
import logger from "@core/log";
import { videoExistsInAllData } from "db/bilibili_metadata";
@ -6,14 +5,14 @@ import { LatestVideosQueue } from "mq/index";
import { getLatestVideoAids } from "net/getLatestVideoAids";
import { sleep } from "utils/sleep";
export async function queueLatestVideos(sql: Psql): Promise<number | null> {
export async function queueLatestVideos(): Promise<number | null> {
let page = 1;
let i = 0;
const videosFound = new Set();
while (true) {
const pageSize = page == 1 ? 10 : 30;
const pageSize = page === 1 ? 10 : 30;
const aids = await getLatestVideoAids(page, pageSize);
if (aids.length == 0) {
if (aids.length === 0) {
logger.verbose("No more videos found", "net", "fn:insertLatestVideos()");
break;
}
@ -28,12 +27,12 @@ export async function queueLatestVideos(sql: Psql): Promise<number | null> {
"getVideoInfo",
{ aid },
{
delay,
attempts: 100,
backoff: {
type: "fixed",
delay: SECOND * 5,
type: "fixed",
},
delay,
}
);
videosFound.add(aid);

View File

@ -26,7 +26,7 @@ const filterWorker = new Worker(
break;
}
},
{ connection: redis as ConnectionOptions, concurrency: 2, removeOnComplete: { count: 1000 } }
{ concurrency: 2, connection: redis as ConnectionOptions, removeOnComplete: { count: 1000 } }
);
process.on("SIGINT", () => shutdown("SIGINT", filterWorker));

View File

@ -58,8 +58,8 @@ const latestVideoWorker = new Worker(
}
},
{
connection: redis as ConnectionOptions,
concurrency: 6,
connection: redis as ConnectionOptions,
removeOnComplete: { count: 1440 },
removeOnFail: { count: 0 },
}
@ -100,7 +100,7 @@ const snapshotWorker = new Worker(
break;
}
},
{ connection: redis as ConnectionOptions, concurrency: 50, removeOnComplete: { count: 2000 } }
{ concurrency: 50, connection: redis as ConnectionOptions, removeOnComplete: { count: 2000 } }
);
snapshotWorker.on("error", (err) => {
@ -118,7 +118,7 @@ const miscWorker = new Worker(
break;
}
},
{ connection: redis as ConnectionOptions, concurrency: 5, removeOnComplete: { count: 1000 } }
{ concurrency: 5, connection: redis as ConnectionOptions, removeOnComplete: { count: 1000 } }
);
miscWorker.on("error", (err) => {

View File

@ -11,7 +11,7 @@ import { Switch } from "./Switch";
import { useTheme } from "./ThemeContext";
import { i18nProvider } from "./utils";
const defaultColor: Oklch = { mode: "oklch", h: 29.2339, c: 0.244572, l: 0.596005 };
const defaultColor: Oklch = { c: 0.244572, h: 29.2339, l: 0.596005, mode: "oklch" };
const colorAtom = atomWithStorage<Oklch>("selectedColor", defaultColor);
const p3Atom = atomWithStorage<boolean>("showP3", false);

View File

@ -5,19 +5,6 @@ export type ThemeMode = "light" | "dark";
export const buildColorTokens = (base: Oklch) => {
return {
light: {
background: getAdjustedColor(base, 0.98, 0.01),
"bg-elevated-1": getAdjustedColor(base, 1, 0.008),
"body-text": getAdjustedColor(base, 0.1, 0.01),
"border-var-1": getAdjustedColor(base, 0.845, 0.004),
"border-var-2": getAdjustedColor(base, 0.8, 0.007),
"border-var-3": getAdjustedColor(base, 0.755, 0.01),
primary: getAdjustedColor(base, 0.48, 0.08),
"on-primary": getAdjustedColor(base, 0.999, 0.001),
"on-bg-var-2": getAdjustedColor(base, 0.398, 0.0234),
error: { mode: "oklch", l: 0.506, c: 0.192, h: 27.7 } as Oklch,
"on-error": getAdjustedColor(base, 0.99, 0.01),
},
dark: {
background: getAdjustedColor(base, 0.15, 0.002),
"bg-elevated-1": getAdjustedColor(base, 0.2, 0.004),
@ -25,11 +12,24 @@ export const buildColorTokens = (base: Oklch) => {
"border-var-1": getAdjustedColor(base, 0.3, 0.004),
"border-var-2": getAdjustedColor(base, 0.4, 0.007),
"border-var-3": getAdjustedColor(base, 0.5, 0.01),
primary: getAdjustedColor(base, 0.84, 0.1),
"on-primary": getAdjustedColor(base, 0.3, 0.08),
error: { c: 0.223, h: 27.8, l: 0.65, mode: "oklch" } as Oklch,
"on-bg-var-2": getAdjustedColor(base, 0.83, 0.028),
error: { mode: "oklch", l: 0.65, c: 0.223, h: 27.8 } as Oklch,
"on-error": getAdjustedColor(base, 0.9, 0.01),
"on-primary": getAdjustedColor(base, 0.3, 0.08),
primary: getAdjustedColor(base, 0.84, 0.1),
},
light: {
background: getAdjustedColor(base, 0.98, 0.01),
"bg-elevated-1": getAdjustedColor(base, 1, 0.008),
"body-text": getAdjustedColor(base, 0.1, 0.01),
"border-var-1": getAdjustedColor(base, 0.845, 0.004),
"border-var-2": getAdjustedColor(base, 0.8, 0.007),
"border-var-3": getAdjustedColor(base, 0.755, 0.01),
error: { c: 0.192, h: 27.7, l: 0.506, mode: "oklch" } as Oklch,
"on-bg-var-2": getAdjustedColor(base, 0.398, 0.0234),
"on-error": getAdjustedColor(base, 0.99, 0.01),
"on-primary": getAdjustedColor(base, 0.999, 0.001),
primary: getAdjustedColor(base, 0.48, 0.08),
},
};
};

View File

@ -81,7 +81,7 @@ export const ColorBlock = ({ baseColor, text, l, c, h }: ColorBlockProps) => {
animate={{ opacity: 1, width: 22 }}
transition={{
opacity: { duration: 0.2, ease: "backOut" },
width: { type: "spring", bounce: 0.2, duration: 0.5 },
width: { bounce: 0.2, duration: 0.5, type: "spring" },
}}
>
<Icon />

View File

@ -84,8 +84,8 @@ export const Handle = ({
shadow-[0px_0px_7px_2px_rgba(0,0,0,0.35)] cursor-grab active:cursor-grabbing
touch-none select-none"
style={{
left: `${pos}%`,
backgroundColor: `oklch(${color.l} ${color.c} ${color.h})`,
left: `${pos}%`,
transform: "translateY(-50%) translateX(-50%) rotate(45deg)",
}}
onMouseDown={handleMouseDown}

View File

@ -24,10 +24,10 @@ export const Slider = ({ useP3, channel, color, onChange, i18nProvider }: Slider
const canvasRef = useRef<null | HTMLCanvasElement>(null);
useOklchCanvas({
channel: channel,
max: maxValue[channel],
canvasRef: canvasRef,
channel: channel,
color,
max: maxValue[channel],
useP3,
});

View File

@ -44,10 +44,10 @@ export function useOklchCanvas({ useP3, channel, max, canvasRef, color }: UseOkl
try {
const testColor = oklch({
mode: "oklch",
l: channel === "l" ? value : color.l,
c: channel === "c" ? value : color.c,
h: channel === "h" ? value : color.h,
l: channel === "l" ? value : color.l,
mode: "oklch",
});
if (testColor && inGamut(colorGamut)(testColor)) {

View File

@ -7,22 +7,22 @@ export const round = (value: number, precision: number) => {
export const roundOklch = (oklch: Oklch) => {
return {
...oklch,
l: round(oklch.l, precision.l),
c: round(oklch.c, precision.c),
h: round(oklch.h!, precision.h),
l: round(oklch.l, precision.l),
};
};
export const precision = {
l: 4,
c: 4,
h: 2,
l: 4,
};
export const maxValue = {
l: 1,
c: 0.37,
h: 360,
l: 1,
};
/**

View File

@ -7,27 +7,27 @@ import { cn } from "@/lib//utils";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
defaultVariants: {
size: "default",
variant: "default",
},
variants: {
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
icon: "size-9",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
},
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
ghost: "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
);
@ -47,7 +47,7 @@ function Button({
return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
className={cn(buttonVariants({ className, size, variant }))}
{...props}
/>
);

View File

@ -4,7 +4,7 @@ import * as RechartsPrimitive from "recharts";
import { cn } from "@/lib//utils";
// Format: { THEME_NAME: CSS_SELECTOR }
const THEMES = { light: "", dark: ".dark" } as const;
const THEMES = { dark: ".dark", light: "" } as const;
export type ChartConfig = {
[k in string]: {
@ -192,11 +192,11 @@ function ChartTooltipContent({
"shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)",
{
"h-2.5 w-2.5": indicator === "dot",
"w-1": indicator === "line",
"w-0 border-[1.5px] border-dashed bg-transparent":
indicator === "dashed",
"my-0.5":
nestLabel && indicator === "dashed",
"w-0 border-[1.5px] border-dashed bg-transparent":
indicator === "dashed",
"w-1": indicator === "line",
}
)}
style={

View File

@ -12,8 +12,8 @@ const Toaster = ({ ...props }: ToasterProps) => {
style={
{
"--normal-bg": "var(--popover)",
"--normal-text": "var(--popover-foreground)",
"--normal-border": "var(--border)",
"--normal-text": "var(--popover-foreground)",
} as React.CSSProperties
}
{...props}

View File

@ -54,8 +54,8 @@ export const MilestoneVideos: React.FC = () => {
try {
const { data, error } = await app.songs["close-milestone"]({ type }).get({
query: {
offset: currentOffset,
limit: 20,
offset: currentOffset,
},
});

View File

@ -12,8 +12,8 @@ const app = treaty<App>(import.meta.env.VITE_API_URL!);
export default function Login() {
const navigate = useNavigate();
const [formData, setFormData] = useState({
username: "",
password: "",
username: "",
});
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState("");

View File

@ -18,6 +18,10 @@ export type Snapshot = {
export const columns: ColumnDef<Snapshot>[] = [
{
accessorKey: "createdAt",
cell: ({ row }) => {
const createdAt = row.getValue("createdAt") as string;
return <div>{formatDateTime(new Date(createdAt))}</div>;
},
header: ({ column }) => {
return (
<Button
@ -29,57 +33,53 @@ export const columns: ColumnDef<Snapshot>[] = [
</Button>
);
},
cell: ({ row }) => {
const createdAt = row.getValue("createdAt") as string;
return <div>{formatDateTime(new Date(createdAt))}</div>;
},
},
{
accessorKey: "views",
header: "播放",
cell: ({ row }) => {
const views = row.getValue("views") as number;
return <div>{views.toLocaleString()}</div>;
},
header: "播放",
},
{
accessorKey: "likes",
header: "点赞",
cell: ({ row }) => {
const likes = row.getValue("likes") as number;
return <div>{likes.toLocaleString()}</div>;
},
header: "点赞",
},
{
accessorKey: "favorites",
header: "收藏",
cell: ({ row }) => {
const favorites = row.getValue("favorites") as number;
return <div>{favorites.toLocaleString()}</div>;
},
header: "收藏",
},
{
accessorKey: "coins",
header: "硬币",
cell: ({ row }) => {
const coins = row.getValue("coins") as number;
return <div>{coins.toLocaleString()}</div>;
},
header: "硬币",
},
{
accessorKey: "danmakus",
header: "弹幕",
cell: ({ row }) => {
const danmakus = row.getValue("danmakus") as number;
return <div>{danmakus.toLocaleString()}</div>;
},
header: "弹幕",
},
{
accessorKey: "shares",
header: "转发",
cell: ({ row }) => {
const shares = row.getValue("shares") as number;
return <div>{shares.toLocaleString()}</div>;
},
header: "转发",
},
];

View File

@ -30,12 +30,12 @@ export function DataTable<TData, TValue>({ columns, data }: DataTableProps<TData
const [sorting, setSorting] = React.useState<SortingState>([]);
const table = useReactTable({
data,
columns,
data,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
onSortingChange: setSorting,
getSortedRowModel: getSortedRowModel(),
onSortingChange: setSorting,
state: {
sorting,
},

View File

@ -55,7 +55,7 @@ export function formatHours(hours: number): string {
export function addHoursToNow(hours: number): string {
const d = new Date();
d.setSeconds(d.getSeconds() + hours * 3600);
return formatDateTime(d, true)
return formatDateTime(d, true);
}
export default function SongInfo({ loaderData }: Route.ComponentProps) {

View File

@ -140,25 +140,25 @@ export const processSnapshots = (
};
const createSnapshotData = (timestamp: number, snapshot: any) => ({
createdAt: new Date(timestamp).toISOString(),
views: snapshot.views,
likes: snapshot.likes || 0,
favorites: snapshot.favorites || 0,
coins: snapshot.coins || 0,
createdAt: new Date(timestamp).toISOString(),
danmakus: snapshot.danmakus || 0,
favorites: snapshot.favorites || 0,
likes: snapshot.likes || 0,
views: snapshot.views,
});
const createInterpolatedSnapshot = (timestamp: number, prev: any, next: any, ratio: number) => ({
createdAt: new Date(timestamp).toISOString(),
views: Math.round(prev.views + (next.views - prev.views) * ratio),
likes: Math.round((prev.likes || 0) + ((next.likes || 0) - (prev.likes || 0)) * ratio),
favorites: Math.round(
(prev.favorites || 0) + ((next.favorites || 0) - (prev.favorites || 0)) * ratio
),
coins: Math.round((prev.coins || 0) + ((next.coins || 0) - (prev.coins || 0)) * ratio),
createdAt: new Date(timestamp).toISOString(),
danmakus: Math.round(
(prev.danmakus || 0) + ((next.danmakus || 0) - (prev.danmakus || 0)) * ratio
),
favorites: Math.round(
(prev.favorites || 0) + ((next.favorites || 0) - (prev.favorites || 0)) * ratio
),
likes: Math.round((prev.likes || 0) + ((next.likes || 0) - (prev.likes || 0)) * ratio),
views: Math.round(prev.views + (next.views - prev.views) * ratio),
});
export const detectMilestoneAchievements = (
@ -198,9 +198,9 @@ export const detectMilestoneAchievements = (
const milestoneTime = new Date(prevTime + ratio * timeDiff);
const achievement: MilestoneAchievement = {
achievedAt: milestoneTime.toISOString(),
milestone,
milestoneName,
achievedAt: milestoneTime.toISOString(),
views: milestone,
};
@ -217,9 +217,9 @@ export const detectMilestoneAchievements = (
const exactSnapshot =
prevSnapshot.views === milestone ? prevSnapshot : currentSnapshot;
const achievement: MilestoneAchievement = {
achievedAt: exactSnapshot.createdAt,
milestone,
milestoneName,
achievedAt: exactSnapshot.createdAt,
views: milestone,
};

View File

@ -29,13 +29,13 @@ const StatsTable = ({ snapshots }: { snapshots: Snapshots | null }) => {
}
const tableData: Snapshot[] = snapshots.map((snapshot) => ({
createdAt: snapshot.createdAt,
views: snapshot.views,
likes: snapshot.likes || 0,
favorites: snapshot.favorites || 0,
coins: snapshot.coins || 0,
createdAt: snapshot.createdAt,
danmakus: snapshot.danmakus || 0,
favorites: snapshot.favorites || 0,
likes: snapshot.likes || 0,
shares: snapshot.shares || 0,
views: snapshot.views,
}));
return <DataTable columns={columns} data={tableData} />;
@ -85,16 +85,16 @@ export const SnapshotsView = ({
if (!snapshots) return null;
return [
{
id: 0,
createdAt: publishedAt,
views: 0,
coins: 0,
likes: 0,
favorites: 0,
shares: 0,
danmakus: 0,
aid: 0,
coins: 0,
createdAt: publishedAt,
danmakus: 0,
favorites: 0,
id: 0,
likes: 0,
replies: 0,
shares: 0,
views: 0,
},
...snapshots,
]

View File

@ -11,23 +11,23 @@ import {
} from "@/components/ui/chart";
const chartConfigLight = {
views: {
label: "播放",
color: "#111417",
},
likes: {
label: "点赞",
},
views: {
color: "#111417",
label: "播放",
},
} satisfies ChartConfig;
const chartConfigDark = {
views: {
label: "播放",
color: "#EEEEF0",
},
likes: {
label: "点赞",
},
views: {
color: "#EEEEF0",
label: "播放",
},
} satisfies ChartConfig;
interface ChartData {

View File

@ -35,7 +35,7 @@ import type { Route } from "./+types/users";
export function meta({}: Route.MetaArgs) {
return [
{ title: "User Management - Admin" },
{ name: "description", content: "Manage users and permissions" },
{ content: "Manage users and permissions", name: "description" },
];
}
@ -48,7 +48,7 @@ export async function loader({ request }: Route.LoaderArgs) {
// Fetch all users
const allUsers = await db.select().from(users).orderBy(users.createdAt);
return { users: allUsers, currentUser: user };
return { currentUser: user, users: allUsers };
}
export async function action({ request }: Route.ActionArgs) {
@ -119,12 +119,12 @@ export async function action({ request }: Route.ActionArgs) {
const hashedPassword = await hashPassword(password);
await db.insert(users).values({
id: await generateId(6),
username,
password: hashedPassword,
isAdmin,
createdAt: new Date(),
id: await generateId(6),
isAdmin,
password: hashedPassword,
updatedAt: new Date(),
username,
});
return { success: true };
@ -155,8 +155,8 @@ export async function action({ request }: Route.ActionArgs) {
}
const updateData: any = {
username,
isAdmin,
username,
};
// Only update password if provided

View File

@ -41,9 +41,9 @@ export function ProjectDialog({
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
onSubmit({
name,
description,
isPublic,
name,
});
onOpenChange(false);
// Reset form

View File

@ -40,8 +40,8 @@ export function UserSearchModal({
try {
const response = await fetch(`/project/${projectId}/settings`, {
method: "POST",
body: formData,
method: "POST",
});
if (response.ok) {

View File

@ -54,11 +54,11 @@ export function TaskForm({
const [isSubmitting, setIsSubmitting] = useState(false);
const currentColumn = columns.find((col) => col.id === columnId);
const priorityLabel = { low: "Low", medium: "Medium", high: "High" }[priority];
const priorityLabel = { high: "High", low: "Low", medium: "Medium" }[priority];
const priorityColor = {
high: "bg-red-100 text-red-800",
low: "bg-green-100 text-green-800",
medium: "bg-yellow-100 text-yellow-800",
high: "bg-red-100 text-red-800",
}[priority];
const handleSubmit = async (e: React.FormEvent) => {
@ -71,11 +71,11 @@ export function TaskForm({
setIsSubmitting(true);
try {
await onSubmit({
title: title.trim(),
description: description.trim(),
columnId,
priority,
description: description.trim(),
dueDate,
priority,
title: title.trim(),
});
} finally {
setIsSubmitting(false);

View File

@ -7,20 +7,20 @@ import { cn } from "@/lib/utils";
const badgeVariants = cva(
"inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
{
defaultVariants: {
variant: "default",
},
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary:
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline: "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
secondary:
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
},
},
defaultVariants: {
variant: "default",
},
}
);

View File

@ -7,29 +7,29 @@ import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
defaultVariants: {
size: "default",
variant: "default",
},
variants: {
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
icon: "size-9",
"icon-lg": "size-10",
"icon-sm": "size-8",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
},
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
ghost: "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
"icon-sm": "size-8",
"icon-lg": "size-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
);
@ -49,7 +49,7 @@ function Button({
return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
className={cn(buttonVariants({ className, size, variant }))}
{...props}
/>
);

View File

@ -33,36 +33,16 @@ function Calendar({
...formatters,
}}
classNames={{
root: cn("w-fit", defaultClassNames.root),
months: cn("flex gap-4 flex-col md:flex-row relative", defaultClassNames.months),
month: cn("flex flex-col w-full gap-4", defaultClassNames.month),
nav: cn(
"flex items-center gap-1 w-full absolute top-0 inset-x-0 justify-between",
defaultClassNames.nav
button_next: cn(
buttonVariants({ variant: buttonVariant }),
"size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
defaultClassNames.button_next
),
button_previous: cn(
buttonVariants({ variant: buttonVariant }),
"size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
defaultClassNames.button_previous
),
button_next: cn(
buttonVariants({ variant: buttonVariant }),
"size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
defaultClassNames.button_next
),
month_caption: cn(
"flex items-center justify-center h-(--cell-size) w-full px-(--cell-size)",
defaultClassNames.month_caption
),
dropdowns: cn(
"w-full flex items-center text-sm font-medium justify-center h-(--cell-size) gap-1.5",
defaultClassNames.dropdowns
),
dropdown_root: cn(
"relative has-focus:border-ring border border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] rounded-md",
defaultClassNames.dropdown_root
),
dropdown: cn("absolute bg-popover inset-0 opacity-0", defaultClassNames.dropdown),
caption_label: cn(
"select-none font-medium",
captionLayout === "label"
@ -70,51 +50,61 @@ function Calendar({
: "rounded-md pl-2 pr-1 flex items-center gap-1 text-sm h-8 [&>svg]:text-muted-foreground [&>svg]:size-3.5",
defaultClassNames.caption_label
),
table: "w-full border-collapse",
weekdays: cn("flex", defaultClassNames.weekdays),
weekday: cn(
"text-muted-foreground rounded-md flex-1 font-normal text-[0.8rem] select-none",
defaultClassNames.weekday
),
week: cn("flex w-full mt-2", defaultClassNames.week),
week_number_header: cn(
"select-none w-(--cell-size)",
defaultClassNames.week_number_header
),
week_number: cn(
"text-[0.8rem] select-none text-muted-foreground",
defaultClassNames.week_number
),
day: cn(
"relative w-full h-full p-0 text-center [&:first-child[data-selected=true]_button]:rounded-l-md [&:last-child[data-selected=true]_button]:rounded-r-md group/day aspect-square select-none",
defaultClassNames.day
),
range_start: cn("rounded-l-md bg-accent", defaultClassNames.range_start),
range_middle: cn("rounded-none", defaultClassNames.range_middle),
range_end: cn("rounded-r-md bg-accent", defaultClassNames.range_end),
today: cn(
"bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none",
defaultClassNames.today
disabled: cn("text-muted-foreground opacity-50", defaultClassNames.disabled),
dropdown: cn("absolute bg-popover inset-0 opacity-0", defaultClassNames.dropdown),
dropdown_root: cn(
"relative has-focus:border-ring border border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] rounded-md",
defaultClassNames.dropdown_root
),
dropdowns: cn(
"w-full flex items-center text-sm font-medium justify-center h-(--cell-size) gap-1.5",
defaultClassNames.dropdowns
),
hidden: cn("invisible", defaultClassNames.hidden),
month: cn("flex flex-col w-full gap-4", defaultClassNames.month),
month_caption: cn(
"flex items-center justify-center h-(--cell-size) w-full px-(--cell-size)",
defaultClassNames.month_caption
),
months: cn("flex gap-4 flex-col md:flex-row relative", defaultClassNames.months),
nav: cn(
"flex items-center gap-1 w-full absolute top-0 inset-x-0 justify-between",
defaultClassNames.nav
),
outside: cn(
"text-muted-foreground aria-selected:text-muted-foreground",
defaultClassNames.outside
),
disabled: cn("text-muted-foreground opacity-50", defaultClassNames.disabled),
hidden: cn("invisible", defaultClassNames.hidden),
range_end: cn("rounded-r-md bg-accent", defaultClassNames.range_end),
range_middle: cn("rounded-none", defaultClassNames.range_middle),
range_start: cn("rounded-l-md bg-accent", defaultClassNames.range_start),
root: cn("w-fit", defaultClassNames.root),
table: "w-full border-collapse",
today: cn(
"bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none",
defaultClassNames.today
),
week: cn("flex w-full mt-2", defaultClassNames.week),
week_number: cn(
"text-[0.8rem] select-none text-muted-foreground",
defaultClassNames.week_number
),
week_number_header: cn(
"select-none w-(--cell-size)",
defaultClassNames.week_number_header
),
weekday: cn(
"text-muted-foreground rounded-md flex-1 font-normal text-[0.8rem] select-none",
defaultClassNames.weekday
),
weekdays: cn("flex", defaultClassNames.weekdays),
...classNames,
}}
components={{
Root: ({ className, rootRef, ...props }) => {
return (
<div
data-slot="calendar"
ref={rootRef}
className={cn(className)}
{...props}
/>
);
},
Chevron: ({ className, orientation, ...props }) => {
if (orientation === "left") {
return <ChevronLeftIcon className={cn("size-4", className)} {...props} />;
@ -127,6 +117,16 @@ function Calendar({
return <ChevronDownIcon className={cn("size-4", className)} {...props} />;
},
DayButton: CalendarDayButton,
Root: ({ className, rootRef, ...props }) => {
return (
<div
data-slot="calendar"
ref={rootRef}
className={cn(className)}
{...props}
/>
);
},
WeekNumber: ({ children, ...props }) => {
return (
<td {...props}>

View File

@ -104,12 +104,12 @@ function SidebarProvider({
const contextValue = React.useMemo<SidebarContextProps>(
() => ({
state,
open,
setOpen,
isMobile,
open,
openMobile,
setOpen,
setOpenMobile,
state,
toggleSidebar,
}),
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]
@ -452,21 +452,21 @@ function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
const sidebarMenuButtonVariants = cva(
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
{
defaultVariants: {
size: "default",
variant: "default",
},
variants: {
size: {
default: "h-8 text-sm",
lg: "h-12 text-sm group-data-[collapsible=icon]:p-0!",
sm: "h-7 text-xs",
},
variant: {
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
outline:
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
},
size: {
default: "h-8 text-sm",
sm: "h-7 text-xs",
lg: "h-12 text-sm group-data-[collapsible=icon]:p-0!",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
);
@ -493,7 +493,7 @@ function SidebarMenuButton({
data-sidebar="menu-button"
data-size={size}
data-active={isActive}
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
className={cn(sidebarMenuButtonVariants({ size, variant }), className)}
{...props}
/>
);

View File

@ -18,18 +18,18 @@ const Toaster = ({ ...props }: ToasterProps) => {
theme={theme as ToasterProps["theme"]}
className="toaster group"
icons={{
success: <CircleCheckIcon className="size-4" />,
info: <InfoIcon className="size-4" />,
warning: <TriangleAlertIcon className="size-4" />,
error: <OctagonXIcon className="size-4" />,
info: <InfoIcon className="size-4" />,
loading: <Loader2Icon className="size-4 animate-spin" />,
success: <CircleCheckIcon className="size-4" />,
warning: <TriangleAlertIcon className="size-4" />,
}}
style={
{
"--normal-bg": "var(--popover)",
"--normal-text": "var(--popover-foreground)",
"--normal-border": "var(--border)",
"--border-radius": "var(--radius)",
"--normal-bg": "var(--popover)",
"--normal-border": "var(--border)",
"--normal-text": "var(--popover-foreground)",
} as React.CSSProperties
}
{...props}

View File

@ -14,7 +14,7 @@ import type { Route } from "./+types/newProject";
export function meta({}: Route.MetaArgs) {
return [
{ title: "Create New Project" },
{ name: "description", content: "Create a new project for task management" },
{ content: "Create a new project for task management", name: "description" },
];
}
@ -38,11 +38,11 @@ export async function action({ request }: Route.ActionArgs) {
// Create the project
await db.insert(projects).values({
id: projectId,
ownerId: user.id,
name,
description,
createdAt: now,
description,
id: projectId,
name,
ownerId: user.id,
updatedAt: now,
});
@ -55,11 +55,11 @@ export async function action({ request }: Route.ActionArgs) {
for (const column of defaultColumns) {
await db.insert(columns).values({
createdAt: now,
id: await generateId(6),
projectId,
name: column.name,
position: column.position,
createdAt: now,
projectId,
updatedAt: now,
});
}

View File

@ -17,7 +17,7 @@ import { projectPageAction } from "./projectPageAction";
export function meta({ loaderData }: Route.MetaArgs) {
return [
{ title: `${loaderData.project.name} - FramSpor` },
{ name: "description", content: `Manage tasks for ${loaderData.project.name}` },
{ content: `Manage tasks for ${loaderData.project.name}`, name: "description" },
];
}
@ -77,10 +77,10 @@ export async function loader({ params, request }: Route.LoaderArgs) {
);
return {
project,
columns: columnsWithTasks,
user,
canEdit,
columns: columnsWithTasks,
project,
user,
};
}
@ -144,8 +144,8 @@ export default function ProjectBoard({ loaderData }: Route.ComponentProps) {
formData.append("name", data.name);
const response = await fetch(`/project/${project.id}`, {
method: "POST",
body: formData,
method: "POST",
});
revalidator.revalidate();
@ -157,8 +157,8 @@ export default function ProjectBoard({ loaderData }: Route.ComponentProps) {
formData.append("columnId", columnId);
const response = await fetch(`/project/${project.id}`, {
method: "POST",
body: formData,
method: "POST",
});
if (response.ok) {
@ -179,8 +179,8 @@ export default function ProjectBoard({ loaderData }: Route.ComponentProps) {
formData.append("isPublic", data.isPublic ? "true" : "false");
const response = await fetch(`/project/${project.id}`, {
method: "POST",
body: formData,
method: "POST",
});
if (response.ok) {
@ -194,8 +194,8 @@ export default function ProjectBoard({ loaderData }: Route.ComponentProps) {
formData.append("intent", "deleteProject");
const response = await fetch(`/project/${project.id}`, {
method: "POST",
body: formData,
method: "POST",
});
if (response.ok) {
@ -229,8 +229,8 @@ export default function ProjectBoard({ loaderData }: Route.ComponentProps) {
}
const response = await fetch(`/project/${project.id}`, {
method: "POST",
body: formData,
method: "POST",
});
revalidator.revalidate();
@ -242,8 +242,8 @@ export default function ProjectBoard({ loaderData }: Route.ComponentProps) {
formData.append("taskId", editingTask.id);
const response = await fetch(`/project/${project.id}`, {
method: "POST",
body: formData,
method: "POST",
});
revalidator.revalidate();
@ -279,8 +279,8 @@ export default function ProjectBoard({ loaderData }: Route.ComponentProps) {
onSubmit={handleProjectSubmit}
onDelete={handleDeleteProject}
initialData={{
name: project.name,
description: project.description || "",
name: project.name,
}}
isEditing={true}
/>

View File

@ -67,14 +67,14 @@ export const projectPageAction = async ({ request, params }: Route.ActionArgs) =
const taskId = await generateId(7);
await db.insert(tasks).values({
id: taskId,
projectId: projectId,
columnId: columnId,
title: title,
description: description,
priority: priority,
dueDate: dueDate ? new Date(dueDate) : null,
createdAt: new Date(),
description: description,
dueDate: dueDate ? new Date(dueDate) : null,
id: taskId,
priority: priority,
projectId: projectId,
title: title,
updatedAt: new Date(),
});
@ -96,11 +96,11 @@ export const projectPageAction = async ({ request, params }: Route.ActionArgs) =
await db
.update(tasks)
.set({
title: title,
description: description,
columnId: columnId,
priority: priority,
description: description,
dueDate: dueDate ? new Date(dueDate) : null,
priority: priority,
title: title,
updatedAt: new Date(),
})
.where(eq(tasks.id, taskId));
@ -142,15 +142,15 @@ export const projectPageAction = async ({ request, params }: Route.ActionArgs) =
: 0;
await db.insert(columns).values({
createdAt: new Date(),
id: columnId,
projectId: projectId,
name: name,
position: newPosition,
createdAt: new Date(),
projectId: projectId,
updatedAt: new Date(),
});
return { success: true, columnId };
return { columnId, success: true };
}
if (intent === "updateColumn") {
@ -171,7 +171,7 @@ export const projectPageAction = async ({ request, params }: Route.ActionArgs) =
})
.where(eq(columns.id, columnId));
return { success: true, columnId };
return { columnId, success: true };
}
if (intent === "deleteColumn") {
@ -190,7 +190,7 @@ export const projectPageAction = async ({ request, params }: Route.ActionArgs) =
await db.delete(columns).where(eq(columns.id, columnId));
return { success: true, columnId };
return { columnId, success: true };
}
if (intent === "reorderColumns") {
@ -223,10 +223,10 @@ export const projectPageAction = async ({ request, params }: Route.ActionArgs) =
await db
.update(projects)
.set({
name: name,
description: description,
updatedAt: new Date(),
isPublic: isPublic,
name: name,
updatedAt: new Date(),
})
.where(eq(projects.id, projectId));
@ -248,7 +248,7 @@ export const projectPageAction = async ({ request, params }: Route.ActionArgs) =
await db.delete(projects).where(eq(projects.id, projectId));
return { success: true, redirect: "/" };
return { redirect: "/", success: true };
}
return { error: "Unknown action" };

View File

@ -43,7 +43,7 @@ import type { Route } from "./+types/settings";
export function meta({}: Route.MetaArgs) {
return [
{ title: "Project Settings" },
{ name: "description", content: "Manage project settings and permissions" },
{ content: "Manage project settings and permissions", name: "description" },
];
}
@ -85,7 +85,7 @@ export async function loader({ request, params }: Route.LoaderArgs) {
.where(and(eq(projects.id, projectId), eq(projects.ownerId, user.id)))
.get();
return { project, allUsers, currentPermissions, currentUser: user, isOwner };
return { allUsers, currentPermissions, currentUser: user, isOwner, project };
}
export async function action({ request, params }: Route.ActionArgs) {
@ -127,9 +127,9 @@ export async function action({ request, params }: Route.ActionArgs) {
await db
.update(projects)
.set({
name,
description,
isPublic,
name,
updatedAt: new Date(),
})
.where(eq(projects.id, projectId));
@ -172,11 +172,11 @@ export async function action({ request, params }: Route.ActionArgs) {
}
await db.insert(projectPermissions).values({
canEdit: canEditPermission,
createdAt: new Date(),
id: crypto.randomUUID(),
projectId,
userId,
canEdit: canEditPermission,
createdAt: new Date(),
});
return redirect(`/project/${projectId}`);
@ -333,8 +333,8 @@ export function UsersManagement({
fetch(
`/project/${project.id}/settings`,
{
method: "POST",
body: formData,
method: "POST",
}
);
}}

View File

@ -12,15 +12,15 @@ import "./app.css";
import { Toaster } from "@/components/ui/sonner";
export const links: Route.LinksFunction = () => [
{ rel: "preconnect", href: "https://fonts.googleapis.com" },
{ href: "https://fonts.googleapis.com", rel: "preconnect" },
{
rel: "preconnect",
href: "https://fonts.gstatic.com",
crossOrigin: "anonymous",
href: "https://fonts.gstatic.com",
rel: "preconnect",
},
{
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: "stylesheet",
},
];

View File

@ -12,7 +12,7 @@ import type { Route } from "./+types/setup";
export function meta({}: Route.MetaArgs) {
return [
{ title: "Initial Setup" },
{ name: "description", content: "Create initial admin user" },
{ content: "Create initial admin user", name: "description" },
];
}

View File

@ -16,7 +16,7 @@ import type { Route } from "./+types/profile";
export function meta({}: Route.MetaArgs) {
return [
{ title: "User Profile" },
{ name: "description", content: "Manage your account settings" },
{ content: "Manage your account settings", name: "description" },
];
}
@ -67,7 +67,7 @@ export async function action({ request }: Route.ActionArgs) {
.set({ password: hashedNewPassword, updatedAt: new Date() })
.where(eq(users.id, user.id));
return { success: true, message: "Password updated successfully" };
return { message: "Password updated successfully", success: true };
}
if (intent === "changeUsername") {
@ -88,14 +88,14 @@ export async function action({ request }: Route.ActionArgs) {
await db
.update(users)
.set({ username: newUsername, updatedAt: new Date() })
.set({ updatedAt: new Date(), username: newUsername })
.where(eq(users.id, user.id));
const updatedUser = await db.select().from(users).where(eq(users.id, user.id)).get();
return {
success: true,
message: "Username updated successfully",
success: true,
updatedUser,
};
}

View File

@ -1,10 +1,10 @@
import { defineConfig } from "drizzle-kit";
export default defineConfig({
out: "./drizzle",
schema: "./lib/db/schema.ts",
dialect: "sqlite",
dbCredentials: {
url: process.env.DB_FILE_NAME!,
},
dialect: "sqlite",
out: "./drizzle",
schema: "./lib/db/schema.ts",
});

View File

@ -24,11 +24,11 @@ export async function createUser(username: string, password: string) {
const hashedPassword = await Argon2id.hashEncoded(password);
await db.insert(users).values({
id: userId,
username,
password: hashedPassword,
createdAt: now,
id: userId,
password: hashedPassword,
updatedAt: now,
username,
});
return userId;
@ -52,10 +52,10 @@ export async function createSession(userId: string) {
const expiresAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000); // 30 days
await db.insert(sessions).values({
createdAt: new Date(),
expiresAt,
id: sessionId,
userId,
expiresAt,
createdAt: new Date(),
});
return sessionId;

View File

@ -3,26 +3,28 @@ import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
// Users table
export const users = sqliteTable("users", {
id: text("id").primaryKey(),
username: text("username").notNull(),
password: text("password_hash").notNull(),
isAdmin: integer("is_admin", { mode: "boolean" }).default(false),
createdAt: integer("created_at", { mode: "timestamp" }).notNull(),
id: text("id").primaryKey(),
isAdmin: integer("is_admin", { mode: "boolean" }).default(false),
password: text("password_hash").notNull(),
updatedAt: integer("updated_at", { mode: "timestamp" }).notNull(),
username: text("username").notNull(),
});
// Sessions table for authentication
export const sessions = sqliteTable("sessions", {
createdAt: integer("created_at", { mode: "timestamp" }).notNull(),
expiresAt: integer("expires_at", { mode: "timestamp" }).notNull(),
id: text("id").primaryKey(),
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
expiresAt: integer("expires_at", { mode: "timestamp" }).notNull(),
createdAt: integer("created_at", { mode: "timestamp" }).notNull(),
});
// Project permissions table
export const projectPermissions = sqliteTable("project_permissions", {
canEdit: integer("can_edit", { mode: "boolean" }).default(false),
createdAt: integer("created_at", { mode: "timestamp" }).notNull(),
id: text("id").primaryKey(),
projectId: text("project_id")
.notNull()
@ -30,49 +32,47 @@ export const projectPermissions = sqliteTable("project_permissions", {
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
canEdit: integer("can_edit", { mode: "boolean" }).default(false),
createdAt: integer("created_at", { mode: "timestamp" }).notNull(),
});
// Projects table
export const projects = sqliteTable("projects", {
createdAt: integer("created_at", { mode: "timestamp" }).notNull(),
description: text("description"),
id: text("id").primaryKey(),
isPublic: integer("is_public", { mode: "boolean" }).default(false),
name: text("name").notNull(),
ownerId: text("owner_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
name: text("name").notNull(),
description: text("description"),
isPublic: integer("is_public", { mode: "boolean" }).default(false),
createdAt: integer("created_at", { mode: "timestamp" }).notNull(),
updatedAt: integer("updated_at", { mode: "timestamp" }).notNull(),
});
// Columns table for Kanban board
export const columns = sqliteTable("columns", {
createdAt: integer("created_at", { mode: "timestamp" }).notNull(),
id: text("id").primaryKey(),
name: text("name").notNull(),
position: integer("position").notNull(),
projectId: text("project_id")
.notNull()
.references(() => projects.id, { onDelete: "cascade" }),
name: text("name").notNull(),
position: integer("position").notNull(),
createdAt: integer("created_at", { mode: "timestamp" }).notNull(),
updatedAt: integer("updated_at", { mode: "timestamp" }).notNull(),
});
// Tasks table
export const tasks = sqliteTable("tasks", {
id: text("id").primaryKey(),
projectId: text("project_id")
.notNull()
.references(() => projects.id, { onDelete: "cascade" }),
columnId: text("column_id")
.notNull()
.references(() => columns.id, { onDelete: "cascade" }),
title: text("title").notNull(),
description: text("description"),
priority: text("priority", { enum: ["low", "medium", "high"] }).default("medium"),
dueDate: integer("due_date", { mode: "timestamp" }),
createdAt: integer("created_at", { mode: "timestamp" }).notNull(),
description: text("description"),
dueDate: integer("due_date", { mode: "timestamp" }),
id: text("id").primaryKey(),
priority: text("priority", { enum: ["low", "medium", "high"] }).default("medium"),
projectId: text("project_id")
.notNull()
.references(() => projects.id, { onDelete: "cascade" }),
title: text("title").notNull(),
updatedAt: integer("updated_at", { mode: "timestamp" }).notNull(),
});
@ -91,13 +91,13 @@ export const sessionsRelations = relations(sessions, ({ one }) => ({
}));
export const projectsRelations = relations(projects, ({ one, many }) => ({
columns: many(columns),
owner: one(users, {
fields: [projects.ownerId],
references: [users.id],
relationName: "owner",
}),
permissions: many(projectPermissions),
columns: many(columns),
tasks: many(tasks),
}));
@ -121,14 +121,14 @@ export const columnsRelations = relations(columns, ({ one, many }) => ({
}));
export const tasksRelations = relations(tasks, ({ one }) => ({
project: one(projects, {
fields: [tasks.projectId],
references: [projects.id],
}),
column: one(columns, {
fields: [tasks.columnId],
references: [columns.id],
}),
project: one(projects, {
fields: [tasks.projectId],
references: [projects.id],
}),
}));
// Types

View File

@ -35,8 +35,8 @@ export const handler = async (event, _context) => {
const randomUserAgent = userAgents[Math.floor(Math.random() * userAgents.length)];
const response = await fetch(eventObj.url, {
headers: {
"User-Agent": randomUserAgent,
Referer: refererUrl,
"User-Agent": randomUserAgent,
},
});
statusCode = response.status;
@ -51,19 +51,19 @@ export const handler = async (event, _context) => {
const randomUserAgent = userAgents[Math.floor(Math.random() * userAgents.length)];
const response = await fetch(url, {
headers: {
"User-Agent": randomUserAgent,
Referer: refererUrl,
"User-Agent": randomUserAgent,
},
});
const responseBody = await response.text();
return {
statusCode: response.status,
body: responseBody,
statusCode: response.status,
};
} catch (error) {
return {
statusCode: 500,
body: `Error fetching URL: ${error.message}`,
statusCode: 500,
};
}
});
@ -73,7 +73,7 @@ export const handler = async (event, _context) => {
}
return {
statusCode: statusCode,
body: JSON.stringify(body),
statusCode: statusCode,
};
};

View File

@ -10,8 +10,8 @@ const quit = (reason?: string) => {
};
const args = arg({
"--db": String,
"--aids": String,
"--db": String,
});
const dbPath = args["--db"];