Initial commit

This commit is contained in:
twotalesanimation
2026-05-19 22:20:29 +02:00
commit 0fbe856dce
173 changed files with 38316 additions and 0 deletions
+16
View File
@@ -0,0 +1,16 @@
import { PrismaClient } from "@prisma/client";
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined;
};
export const db =
globalForPrisma.prisma ??
new PrismaClient({
log:
process.env.NODE_ENV === "development"
? ["query", "error", "warn"]
: ["error"],
});
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = db;
+78
View File
@@ -0,0 +1,78 @@
/**
* Frame-accurate utilities for VFX review.
* All frame calculations use 0-based frame numbers
* unless documented otherwise.
*/
/** Convert frame number to video currentTime in seconds */
export function frameToTime(frame: number, fps: number): number {
return frame / fps;
}
/** Convert video currentTime to frame number (0-based) */
export function timeToFrame(time: number, fps: number): number {
return Math.floor(time * fps);
}
/** Format a frame number as a timecode string: MM:SS:FF */
export function frameToTimecode(frame: number, fps: number): string {
const totalSeconds = frame / fps;
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = Math.floor(totalSeconds % 60);
const frames = frame % Math.round(fps);
if (hours > 0) {
return `${String(hours).padStart(2, "0")}:${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}:${String(frames).padStart(2, "0")}`;
}
return `${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}:${String(frames).padStart(2, "0")}`;
}
/** Format a raw time (seconds) as MM:SS.mmm */
export function timeToDisplay(time: number): string {
const minutes = Math.floor(time / 60);
const seconds = Math.floor(time % 60);
const ms = Math.floor((time % 1) * 1000);
return `${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}.${String(ms).padStart(3, "0")}`;
}
/** Given duration and fps, compute total frame count */
export function durationToFrameCount(duration: number, fps: number): number {
return Math.floor(duration * fps);
}
/** Clamp a frame number to [0, totalFrames - 1] */
export function clampFrame(frame: number, totalFrames: number): number {
return Math.max(0, Math.min(totalFrames - 1, frame));
}
/** Step a single frame forward or backward */
export function stepFrame(
currentFrame: number,
delta: number,
totalFrames: number
): number {
return clampFrame(currentFrame + delta, totalFrames);
}
/** Convert a timeline position (0-1) to frame number */
export function positionToFrame(position: number, totalFrames: number): number {
return clampFrame(Math.floor(position * totalFrames), totalFrames);
}
/** Convert a frame number to timeline position (0-1) */
export function frameToPosition(frame: number, totalFrames: number): number {
if (totalFrames === 0) return 0;
return frame / totalFrames;
}
/** Common VFX frame rates */
export const COMMON_FPS = [23.976, 24, 25, 29.97, 30, 48, 50, 59.94, 60] as const;
export type CommonFPS = (typeof COMMON_FPS)[number];
/** Human-readable fps label */
export function fpsLabel(fps: number): string {
if (fps === 23.976) return "23.976";
return String(fps);
}
+252
View File
@@ -0,0 +1,252 @@
import { db } from "@/lib/db";
import { NotificationType } from "@prisma/client";
export interface CreateNotificationPayload {
userId: string;
type: NotificationType;
title: string;
message: string;
data?: Record<string, unknown>;
}
/**
* Create a single in-app notification.
*/
export async function createNotification(
payload: CreateNotificationPayload
) {
return db.notification.create({
data: {
userId: payload.userId,
type: payload.type,
title: payload.title,
message: payload.message,
data: (payload.data ?? {}) as any,
},
});
}
/**
* Create notifications for multiple users at once.
*/
export async function createNotifications(
payloads: CreateNotificationPayload[]
) {
return db.notification.createMany({
data: payloads.map((p) => ({
userId: p.userId,
type: p.type,
title: p.title,
message: p.message,
data: (p.data ?? {}) as any,
})),
});
}
/**
* Notify all ADMIN, PRODUCER, and SUPERVISOR users about a new version upload.
*/
export async function notifyNewVersionUploaded(params: {
shotCode: string;
versionNumber: number;
projectId: string;
versionId: string;
artistName: string;
}) {
const staff = await db.user.findMany({
where: {
role: { in: ["ADMIN", "PRODUCER", "SUPERVISOR"] },
isActive: true,
},
select: { id: true },
});
const label = `v${String(params.versionNumber).padStart(3, "0")}`;
await createNotifications(
staff.map((u) => ({
userId: u.id,
type: NotificationType.VERSION_UPLOADED,
title: "New version uploaded",
message: `${params.shotCode} ${label} uploaded by ${params.artistName}`,
data: {
versionId: params.versionId,
shotCode: params.shotCode,
versionNumber: params.versionNumber,
},
}))
);
}
/**
* Notify the shot's artist about new feedback.
*/
export async function notifyFeedbackAdded(params: {
artistId: string;
shotCode: string;
frameNumber: number;
versionId: string;
authorName: string;
}) {
await createNotification({
userId: params.artistId,
type: NotificationType.FEEDBACK_ADDED,
title: "New feedback on your shot",
message: `${params.authorName} commented on ${params.shotCode} at frame ${params.frameNumber}`,
data: {
versionId: params.versionId,
shotCode: params.shotCode,
frameNumber: params.frameNumber,
},
});
}
/**
* Notify relevant users of an approval status change.
*/
export async function notifyApprovalChange(params: {
artistId: string | null;
shotCode: string;
versionId: string;
status: "APPROVED" | "REJECTED" | "NEEDS_CHANGES";
reviewerName: string;
}) {
if (!params.artistId) return;
const typeMap = {
APPROVED: NotificationType.SHOT_APPROVED,
REJECTED: NotificationType.SHOT_REJECTED,
NEEDS_CHANGES: NotificationType.REVISION_REQUESTED,
} as const;
const msgMap = {
APPROVED: `${params.shotCode} was approved by ${params.reviewerName}`,
REJECTED: `${params.shotCode} was rejected by ${params.reviewerName}`,
NEEDS_CHANGES: `${params.shotCode} needs changes — ${params.reviewerName}`,
} as const;
await createNotification({
userId: params.artistId,
type: typeMap[params.status],
title: msgMap[params.status],
message: msgMap[params.status],
data: { versionId: params.versionId, shotCode: params.shotCode },
});
}
/**
* Notify an artist that a task has been assigned to them.
*/
export async function notifyTaskAssigned(params: {
artistId: string;
taskTitle: string;
taskId: string;
contextCode: string | null;
assignedByName: string;
}) {
await createNotification({
userId: params.artistId,
type: NotificationType.TASK_ASSIGNED,
title: "New task assigned to you",
message: `${params.assignedByName} assigned you: ${params.taskTitle}${
params.contextCode ? ` (${params.contextCode})` : ""
}`,
data: { taskId: params.taskId },
});
}
/**
* Notify supervisors/producers that a task is ready for internal review.
*/
export async function notifyTaskReadyForReview(params: {
taskId: string;
taskTitle: string;
contextCode: string | null;
artistName: string;
projectId: string;
taskUrl: string;
}) {
const staff = await db.user.findMany({
where: {
role: { in: ["ADMIN", "PRODUCER", "SUPERVISOR"] },
isActive: true,
},
select: { id: true },
});
const label = params.contextCode ? `${params.contextCode}` : "";
await createNotifications(
staff.map((u) => ({
userId: u.id,
type: NotificationType.TASK_READY_FOR_REVIEW,
title: "Task ready for review",
message: `${label}${params.taskTitle} marked ready by ${params.artistName}`,
data: { taskId: params.taskId, taskUrl: params.taskUrl },
}))
);
}
/**
* Notify an artist that a task has been approved.
*/
export async function notifyTaskApproved(params: {
artistId: string;
taskTitle: string;
taskId: string;
contextCode: string | null;
reviewerName: string;
}) {
await createNotification({
userId: params.artistId,
type: NotificationType.TASK_APPROVED,
title: "Task approved",
message: `${params.reviewerName} approved: ${params.taskTitle}${
params.contextCode ? ` (${params.contextCode})` : ""
}`,
data: { taskId: params.taskId },
});
}
/**
* Notify an artist that changes were requested on their task.
*/
export async function notifyTaskChangesRequested(params: {
artistId: string;
taskTitle: string;
taskId: string;
contextCode: string | null;
reviewerName: string;
}) {
await createNotification({
userId: params.artistId,
type: NotificationType.TASK_CHANGES_REQUESTED,
title: "Changes requested on task",
message: `${params.reviewerName} requested changes: ${params.taskTitle}${
params.contextCode ? ` (${params.contextCode})` : ""
}`,
data: { taskId: params.taskId },
});
}
/**
* Notify a comment author about a new reply.
*/
export async function notifyCommentReply(params: {
commentAuthorId: string;
replierName: string;
shotCode: string;
frameNumber: number;
versionId: string;
}) {
await createNotification({
userId: params.commentAuthorId,
type: NotificationType.COMMENT_REPLY,
title: "New reply to your comment",
message: `${params.replierName} replied on ${params.shotCode} frame ${params.frameNumber}`,
data: {
versionId: params.versionId,
shotCode: params.shotCode,
frameNumber: params.frameNumber,
},
});
}
+53
View File
@@ -0,0 +1,53 @@
import { ShotStatus, TaskStatus } from "@prisma/client";
import { db } from "@/lib/db";
import type { PrismaClient } from "@prisma/client";
type TxClient = Omit<
PrismaClient,
"$connect" | "$disconnect" | "$on" | "$transaction" | "$use" | "$extends"
>;
/**
* Derive the shot status from its tasks.
* Priority order (highest → lowest):
* CHANGES on any task → REVISIONS
* INTERNAL_REVIEW / CLIENT_REVIEW on any task → IN_REVIEW
* TODO / IN_PROGRESS on any task → IN_PROGRESS
* All tasks DONE → COMPLETE
* No tasks → WAITING
*/
export function deriveShotStatus(
tasks: { status: TaskStatus }[]
): ShotStatus {
if (tasks.length === 0) return "WAITING";
if (tasks.some((t) => t.status === "CHANGES")) return "REVISIONS";
if (tasks.some((t) => t.status === "INTERNAL_REVIEW" || t.status === "CLIENT_REVIEW"))
return "IN_REVIEW";
if (tasks.some((t) => t.status === "TODO" || t.status === "IN_PROGRESS"))
return "IN_PROGRESS";
if (tasks.every((t) => t.status === "DONE")) return "COMPLETE";
return "WAITING";
}
/**
* Query all tasks for a shot, derive the correct ShotStatus, and persist it.
* Accepts an optional Prisma transaction client (tx) for use inside transactions.
*/
export async function recalcShotStatus(
shotId: string,
tx?: TxClient
): Promise<void> {
const client = tx ?? db;
const tasks = await client.task.findMany({
where: { shotId },
select: { status: true },
});
const newStatus = deriveShotStatus(tasks);
await client.shot.update({
where: { id: shotId },
data: { status: newStatus },
});
}
+233
View File
@@ -0,0 +1,233 @@
export interface SlackMessage {
text?: string;
blocks?: SlackBlock[];
channel?: string;
}
interface SlackBlock {
type: string;
text?: { type: string; text: string };
elements?: unknown[];
}
/**
* Send a message to a Slack webhook URL.
*/
export async function sendSlackMessage(
webhookUrl: string,
message: SlackMessage
): Promise<void> {
const res = await fetch(webhookUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(message),
});
if (!res.ok) {
const body = await res.text();
console.error("[Slack] Webhook failed:", res.status, body);
}
}
/**
* Send a plain text Slack notification.
*/
export async function sendSlackText(
webhookUrl: string,
text: string
): Promise<void> {
return sendSlackMessage(webhookUrl, { text });
}
// ── Pre-built notification templates ─────────────────────────────────────────
export async function slackNotifyVersionUploaded(
webhookUrl: string,
params: {
shotCode: string;
versionLabel: string;
artistName: string;
projectName: string;
reviewUrl: string;
}
) {
await sendSlackMessage(webhookUrl, {
blocks: [
{
type: "section",
text: {
type: "mrkdwn",
text: `🎬 *New version uploaded*\n*${params.projectName}* · ${params.shotCode} ${params.versionLabel} by ${params.artistName}`,
},
},
{
type: "actions",
elements: [
{
type: "button",
text: { type: "plain_text", text: "Review Now" },
url: params.reviewUrl,
style: "primary",
},
],
},
],
});
}
export async function slackNotifyApproval(
webhookUrl: string,
params: {
shotCode: string;
versionLabel: string;
status: "APPROVED" | "REJECTED" | "NEEDS_CHANGES";
reviewerName: string;
projectName: string;
reviewUrl: string;
}
) {
const icons = {
APPROVED: "✅",
REJECTED: "❌",
NEEDS_CHANGES: "⚠️",
};
const labels = {
APPROVED: "approved",
REJECTED: "rejected",
NEEDS_CHANGES: "needs changes",
};
const icon = icons[params.status];
const label = labels[params.status];
await sendSlackMessage(webhookUrl, {
blocks: [
{
type: "section",
text: {
type: "mrkdwn",
text: `${icon} *${params.shotCode} ${params.versionLabel} ${label}*\nBy ${params.reviewerName} · ${params.projectName}`,
},
},
{
type: "actions",
elements: [
{
type: "button",
text: { type: "plain_text", text: "View Version" },
url: params.reviewUrl,
},
],
},
],
});
}
export async function slackNotifyTaskReadyForReview(
webhookUrl: string,
params: {
taskTitle: string;
contextCode: string | null;
artistName: string;
projectName: string;
taskUrl: string;
}
) {
const label = params.contextCode ? `${params.contextCode}` : "";
await sendSlackMessage(webhookUrl, {
blocks: [
{
type: "section",
text: {
type: "mrkdwn",
text: `👁 *Task ready for review*\n*${params.projectName}* · ${label}${params.taskTitle}\nSubmitted by ${params.artistName}`,
},
},
{
type: "actions",
elements: [
{
type: "button",
text: { type: "plain_text", text: "Review Task" },
url: params.taskUrl,
style: "primary",
},
],
},
],
});
}
export async function slackNotifyTaskAssigned(
webhookUrl: string,
params: {
taskTitle: string;
contextCode: string | null;
artistName: string;
assignedByName: string;
projectName: string;
taskUrl: string;
}
) {
const label = params.contextCode ? `${params.contextCode}` : "";
await sendSlackMessage(webhookUrl, {
blocks: [
{
type: "section",
text: {
type: "mrkdwn",
text: `📋 *Task assigned*\n*${params.projectName}* · ${label}${params.taskTitle}\nAssigned to ${params.artistName} by ${params.assignedByName}`,
},
},
{
type: "actions",
elements: [
{
type: "button",
text: { type: "plain_text", text: "View Task" },
url: params.taskUrl,
},
],
},
],
});
}
export async function slackNotifyNewFeedback(
webhookUrl: string,
params: {
shotCode: string;
frameNumber: number;
authorName: string;
commentText: string;
reviewUrl: string;
}
) {
const truncated =
params.commentText.length > 120
? params.commentText.slice(0, 117) + "..."
: params.commentText;
await sendSlackMessage(webhookUrl, {
blocks: [
{
type: "section",
text: {
type: "mrkdwn",
text: `💬 *New feedback* on ${params.shotCode} frame ${params.frameNumber}\n*${params.authorName}:* ${truncated}`,
},
},
{
type: "actions",
elements: [
{
type: "button",
text: { type: "plain_text", text: "Jump to Frame" },
url: params.reviewUrl,
},
],
},
],
});
}
+221
View File
@@ -0,0 +1,221 @@
/**
* S3-compatible storage abstraction.
*
* STORAGE_PROVIDER env controls which backend is used:
* local local filesystem (dev only, no CDN)
* uploadthing UploadThing managed (default for quick start)
* s3 AWS S3
* r2 Cloudflare R2
* b2 Backblaze B2 (S3-compatible endpoint)
* minio Self-hosted MinIO
*/
import {
S3Client,
PutObjectCommand,
DeleteObjectCommand,
GetObjectCommand,
} from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import fs from "fs";
import path from "path";
import { randomUUID } from "crypto";
export type StorageProvider = "local" | "uploadthing" | "s3" | "r2" | "b2" | "minio";
function getProvider(): StorageProvider {
return (process.env.STORAGE_PROVIDER as StorageProvider) ?? "uploadthing";
}
// ── S3 Client factory ────────────────────────────────────────────────────────
function buildS3Client(): S3Client {
const provider = getProvider();
if (provider === "r2") {
return new S3Client({
region: "auto",
endpoint: `https://${process.env.R2_ACCOUNT_ID!}.r2.cloudflarestorage.com`,
credentials: {
accessKeyId: process.env.R2_ACCESS_KEY_ID!,
secretAccessKey: process.env.R2_SECRET_ACCESS_KEY!,
},
});
}
if (provider === "b2") {
return new S3Client({
region: "auto",
endpoint: process.env.B2_ENDPOINT!,
credentials: {
accessKeyId: process.env.B2_APPLICATION_KEY_ID!,
secretAccessKey: process.env.B2_APPLICATION_KEY!,
},
});
}
if (provider === "minio") {
return new S3Client({
region: "us-east-1",
endpoint: process.env.MINIO_ENDPOINT ?? "http://localhost:9000",
forcePathStyle: true,
credentials: {
accessKeyId: process.env.MINIO_ACCESS_KEY!,
secretAccessKey: process.env.MINIO_SECRET_KEY!,
},
});
}
// Default: AWS S3
return new S3Client({
region: process.env.AWS_REGION ?? "us-east-1",
credentials: {
accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
},
});
}
function getBucketName(): string {
const provider = getProvider();
const map: Record<string, string | undefined> = {
s3: process.env.AWS_BUCKET_NAME,
r2: process.env.R2_BUCKET_NAME,
b2: process.env.B2_BUCKET_NAME,
minio: process.env.MINIO_BUCKET_NAME,
};
return map[provider] ?? "vfx-review";
}
// ── Public API ───────────────────────────────────────────────────────────────
export interface UploadResult {
url: string;
key: string;
provider: StorageProvider;
}
/**
* Upload a file buffer to the configured storage backend.
* Returns the public URL and storage key.
*/
export async function uploadFile(
buffer: Buffer,
fileName: string,
contentType: string,
folder: string = "uploads"
): Promise<UploadResult> {
const provider = getProvider();
const key = `${folder}/${randomUUID()}-${fileName}`;
if (provider === "local") {
return uploadLocal(buffer, key);
}
// All S3-compatible providers share the same logic
const client = buildS3Client();
const bucket = getBucketName();
await client.send(
new PutObjectCommand({
Bucket: bucket,
Key: key,
Body: buffer,
ContentType: contentType,
})
);
const url = getPublicUrl(key, provider);
return { url, key, provider };
}
/**
* Generate a presigned URL for direct browser-to-storage upload.
* Expires in 1 hour by default.
*/
export async function generatePresignedUploadUrl(
key: string,
contentType: string,
expiresIn: number = 3600
): Promise<string> {
const provider = getProvider();
if (provider === "local" || provider === "uploadthing") {
// Not applicable for local / uploadthing (use their own flow)
throw new Error(`Presigned URLs not supported for provider: ${provider}`);
}
const client = buildS3Client();
const command = new PutObjectCommand({
Bucket: getBucketName(),
Key: key,
ContentType: contentType,
});
return getSignedUrl(client, command, { expiresIn });
}
/**
* Generate a presigned download URL for a private object.
*/
export async function generatePresignedDownloadUrl(
key: string,
expiresIn: number = 3600
): Promise<string> {
const provider = getProvider();
if (provider === "local") {
return `/api/files/${encodeURIComponent(key)}`;
}
const client = buildS3Client();
const command = new GetObjectCommand({
Bucket: getBucketName(),
Key: key,
});
return getSignedUrl(client, command, { expiresIn });
}
/**
* Delete a file from storage by key.
*/
export async function deleteFile(key: string): Promise<void> {
const provider = getProvider();
if (provider === "local") {
const filePath = path.join(process.env.LOCAL_UPLOAD_DIR ?? "./uploads", key);
if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
return;
}
const client = buildS3Client();
await client.send(
new DeleteObjectCommand({ Bucket: getBucketName(), Key: key })
);
}
// ── Private helpers ──────────────────────────────────────────────────────────
function uploadLocal(buffer: Buffer, key: string): UploadResult {
const uploadDir = process.env.LOCAL_UPLOAD_DIR ?? "./uploads";
const filePath = path.join(uploadDir, key);
const dir = path.dirname(filePath);
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(filePath, buffer);
const url = `/api/files/${encodeURIComponent(key)}`;
return { url, key, provider: "local" };
}
function getPublicUrl(key: string, provider: StorageProvider): string {
if (provider === "r2" && process.env.R2_PUBLIC_URL) {
return `${process.env.R2_PUBLIC_URL}/${key}`;
}
if (provider === "s3") {
return `https://${process.env.AWS_BUCKET_NAME}.s3.${process.env.AWS_REGION ?? "us-east-1"}.amazonaws.com/${key}`;
}
if (provider === "minio") {
return `${process.env.MINIO_ENDPOINT}/${getBucketName()}/${key}`;
}
// Fallback
return `/${key}`;
}
+4
View File
@@ -0,0 +1,4 @@
import { generateReactHelpers } from "@uploadthing/react";
import type { OurFileRouter } from "@/lib/uploadthing";
export const { useUploadThing, uploadFiles } = generateReactHelpers<OurFileRouter>();
+40
View File
@@ -0,0 +1,40 @@
import { createUploadthing, type FileRouter } from "uploadthing/next";
import { auth } from "@/auth";
const f = createUploadthing();
export const uploadRouter = {
/** Full-resolution video upload (mp4, mov) */
versionVideo: f({
video: {
maxFileSize: "2GB",
maxFileCount: 1,
},
})
.middleware(async () => {
const session = await auth();
if (!session?.user) throw new Error("Unauthorized");
return { userId: session.user.id };
})
.onUploadComplete(async ({ metadata, file }) => {
return { uploadedBy: metadata.userId, url: file.url };
}),
/** Thumbnail / poster image upload */
versionThumbnail: f({
image: {
maxFileSize: "4MB",
maxFileCount: 1,
},
})
.middleware(async () => {
const session = await auth();
if (!session?.user) throw new Error("Unauthorized");
return { userId: session.user.id };
})
.onUploadComplete(async ({ metadata, file }) => {
return { uploadedBy: metadata.userId, url: file.url };
}),
} satisfies FileRouter;
export type OurFileRouter = typeof uploadRouter;
+75
View File
@@ -0,0 +1,75 @@
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export function formatDuration(seconds: number): string {
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = Math.floor(seconds % 60);
const frames = Math.floor((seconds % 1) * 24); // rough
if (h > 0) return `${h}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
return `${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
}
export function formatTimecode(seconds: number, fps: number = 24): string {
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = Math.floor(seconds % 60);
const f = Math.floor((seconds % 1) * fps);
if (h > 0) {
return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}:${String(f).padStart(2, "0")}`;
}
return `${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}:${String(f).padStart(2, "0")}`;
}
export function frameToTimecode(frame: number, fps: number): string {
return formatTimecode(frame / fps, fps);
}
export function formatFileSize(bytes: number | bigint): string {
const b = typeof bytes === "bigint" ? Number(bytes) : bytes;
if (b < 1024) return `${b} B`;
if (b < 1024 * 1024) return `${(b / 1024).toFixed(1)} KB`;
if (b < 1024 * 1024 * 1024) return `${(b / 1024 / 1024).toFixed(1)} MB`;
return `${(b / 1024 / 1024 / 1024).toFixed(2)} GB`;
}
export function formatRelativeDate(date: Date | string): string {
const d = typeof date === "string" ? new Date(date) : date;
const now = new Date();
const diff = now.getTime() - d.getTime();
const minutes = Math.floor(diff / 60000);
const hours = Math.floor(diff / 3600000);
const days = Math.floor(diff / 86400000);
if (minutes < 1) return "just now";
if (minutes < 60) return `${minutes}m ago`;
if (hours < 24) return `${hours}h ago`;
if (days < 7) return `${days}d ago`;
return d.toLocaleDateString("en-US", { month: "short", day: "numeric" });
}
export function getInitials(name: string | null | undefined): string {
if (!name) return "?";
return name
.split(" ")
.map((n) => n[0])
.join("")
.toUpperCase()
.slice(0, 2);
}
export function slugify(text: string): string {
return text
.toLowerCase()
.replace(/[^\w\s-]/g, "")
.replace(/[\s_-]+/g, "-")
.replace(/^-+|-+$/g, "");
}
export function versionLabel(n: number): string {
return `v${String(n).padStart(3, "0")}`;
}