feat: full site build — Project/Melody schema (Option A), admin CRUD, public sections, uploads, email+SMTP, internal analytics, legal pages, docs
This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const analyticsEventTypeSchema = z.enum([
|
||||
"PAGE_VIEW",
|
||||
"PROJECT_OPEN",
|
||||
"MELODY_PLAY",
|
||||
"PROJECT_LINK_CLICK",
|
||||
"CONTACT_SUBMITTED",
|
||||
]);
|
||||
|
||||
export const analyticsEventSchema = z.object({
|
||||
type: analyticsEventTypeSchema,
|
||||
path: z.string().trim().max(2048).optional(),
|
||||
entityId: z.string().cuid().optional(),
|
||||
});
|
||||
|
||||
export type AnalyticsEventInput = z.infer<typeof analyticsEventSchema>;
|
||||
@@ -0,0 +1,8 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const loginSchema = z.object({
|
||||
email: z.string().trim().email("Enter a valid email address.").max(254).transform((value) => value.toLowerCase()),
|
||||
password: z.string().min(12, "Password must be at least 12 characters.").max(128),
|
||||
});
|
||||
|
||||
export type LoginInput = z.infer<typeof loginSchema>;
|
||||
@@ -0,0 +1,20 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const categoryKindSchema = z.enum(["PROJECT", "MELODY"]);
|
||||
|
||||
export const categorySchema = z.object({
|
||||
kind: categoryKindSchema,
|
||||
slug: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, "Slug is required.")
|
||||
.max(80, "Slug must be 80 characters or fewer.")
|
||||
.regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, "Use lowercase letters, numbers, and hyphens only."),
|
||||
nameAr: z.string().trim().min(1, "Arabic name is required.").max(120),
|
||||
nameEn: z.string().trim().min(1, "English name is required.").max(120),
|
||||
order: z.number().int().min(0).max(9999),
|
||||
});
|
||||
|
||||
export const categoryIdSchema = z.string().cuid("Invalid category id.");
|
||||
|
||||
export type CategoryInput = z.infer<typeof categorySchema>;
|
||||
@@ -0,0 +1,10 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const contactSchema = z.object({
|
||||
name: z.string().trim().min(2).max(100),
|
||||
email: z.string().trim().email().max(254),
|
||||
message: z.string().trim().min(10).max(5000),
|
||||
website: z.string().max(0).optional(),
|
||||
});
|
||||
|
||||
export type ContactInput = z.infer<typeof contactSchema>;
|
||||
@@ -0,0 +1,29 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const melodyStatusSchema = z.enum(["DRAFT", "PUBLISHED", "ARCHIVED"]);
|
||||
const audioPath = z.string().trim().regex(/^\/api\/uploads\/audios\/[^/]+$/, "Use an uploaded audio path.");
|
||||
const imagePath = z.string().trim().regex(/^\/api\/uploads\/images\/[^/]+$/, "Use an uploaded image path.");
|
||||
|
||||
export const melodySchema = z.object({
|
||||
slug: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, "Slug is required.")
|
||||
.max(100, "Slug must be 100 characters or fewer.")
|
||||
.regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, "Use lowercase letters, numbers, and hyphens only."),
|
||||
titleAr: z.string().trim().min(1, "Arabic title is required.").max(160),
|
||||
titleEn: z.string().trim().min(1, "English title is required.").max(160),
|
||||
descAr: z.string().trim().max(4000).optional(),
|
||||
descEn: z.string().trim().max(4000).optional(),
|
||||
audioFile: audioPath,
|
||||
coverImage: z.union([z.literal(""), imagePath]).optional(),
|
||||
durationSec: z.number().int().min(0).max(86400).nullable(),
|
||||
isDownloadable: z.boolean(),
|
||||
status: melodyStatusSchema,
|
||||
isFeatured: z.boolean(),
|
||||
sortOrder: z.number().int().min(0).max(9999),
|
||||
categoryId: z.string().cuid("Choose a valid melody category."),
|
||||
});
|
||||
|
||||
export const melodyIdSchema = z.string().cuid("Invalid melody id.");
|
||||
export type MelodyInput = z.infer<typeof melodySchema>;
|
||||
@@ -0,0 +1,46 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const projectTypeSchema = z.enum(["PORTFOLIO", "APP", "WEBSITE", "DESIGN"]);
|
||||
export const publishStatusSchema = z.enum(["DRAFT", "PUBLISHED", "ARCHIVED"]);
|
||||
|
||||
const optionalText = (max: number) => z.string().trim().max(max).optional();
|
||||
const optionalUrl = z.union([z.literal(""), z.string().trim().url().max(500)]).optional();
|
||||
const mediaPath = z.string().trim().regex(/^\/api\/uploads\/(images|audios)\/[^/]+$/, "Use an uploaded media path.");
|
||||
const optionalMediaPath = z.union([z.literal(""), mediaPath]).optional();
|
||||
|
||||
export const projectSchema = z.object({
|
||||
type: projectTypeSchema,
|
||||
slug: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, "Slug is required.")
|
||||
.max(100, "Slug must be 100 characters or fewer.")
|
||||
.regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, "Use lowercase letters, numbers, and hyphens only."),
|
||||
titleAr: z.string().trim().min(1, "Arabic title is required.").max(160),
|
||||
titleEn: z.string().trim().min(1, "English title is required.").max(160),
|
||||
summaryAr: optionalText(300),
|
||||
summaryEn: optionalText(300),
|
||||
descAr: optionalText(4000),
|
||||
descEn: optionalText(4000),
|
||||
coverImage: optionalMediaPath,
|
||||
images: z.array(mediaPath).max(50, "You can add up to 50 images."),
|
||||
technologies: z.array(z.string().trim().min(1).max(60)).max(30, "You can add up to 30 technologies."),
|
||||
externalUrl: optionalUrl,
|
||||
repoUrl: optionalUrl,
|
||||
platform: optionalText(120),
|
||||
appStoreUrl: optionalUrl,
|
||||
testflightUrl: optionalUrl,
|
||||
appVersion: optionalText(80),
|
||||
supportUrl: optionalUrl,
|
||||
appPrivacyUrl: optionalUrl,
|
||||
status: publishStatusSchema,
|
||||
isFeatured: z.boolean(),
|
||||
sortOrder: z.number().int().min(0).max(9999),
|
||||
categoryId: z.union([z.literal(""), z.string().cuid()]).optional(),
|
||||
});
|
||||
|
||||
export const projectIdSchema = z.string().cuid("Invalid project id.");
|
||||
export const projectTypeFilterSchema = projectTypeSchema;
|
||||
|
||||
export type ProjectInput = z.infer<typeof projectSchema>;
|
||||
export type ProjectTypeFilter = z.infer<typeof projectTypeFilterSchema>;
|
||||
@@ -0,0 +1,56 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const uploadKindSchema = z.enum(["image", "audio"]);
|
||||
|
||||
export type UploadKind = z.infer<typeof uploadKindSchema>;
|
||||
|
||||
export const uploadRequestSchema = z.object({
|
||||
kind: uploadKindSchema,
|
||||
fileName: z.string().trim().min(1).max(255),
|
||||
mimeType: z.string().trim().min(1).max(120),
|
||||
size: z.number().int().positive(),
|
||||
});
|
||||
|
||||
export const uploadedFileSchema = z.object({
|
||||
kind: uploadKindSchema,
|
||||
url: z.string().startsWith("/api/uploads/"),
|
||||
fileName: z.string().min(1),
|
||||
mimeType: z.string().min(1),
|
||||
size: z.number().int().positive(),
|
||||
});
|
||||
|
||||
export type UploadRequest = z.infer<typeof uploadRequestSchema>;
|
||||
export type UploadedFileMetadata = z.infer<typeof uploadedFileSchema>;
|
||||
|
||||
export const UPLOAD_RULES: Record<
|
||||
UploadKind,
|
||||
{
|
||||
maxBytes: number;
|
||||
accept: string;
|
||||
mimeTypes: readonly string[];
|
||||
}
|
||||
> = {
|
||||
image: {
|
||||
maxBytes: 10 * 1024 * 1024,
|
||||
accept: "image/jpeg,image/png,image/webp",
|
||||
mimeTypes: ["image/jpeg", "image/png", "image/webp"],
|
||||
},
|
||||
audio: {
|
||||
maxBytes: 50 * 1024 * 1024,
|
||||
accept: "audio/mpeg,audio/wav,audio/x-wav,audio/ogg,audio/webm,audio/mp4,audio/aac,audio/flac",
|
||||
mimeTypes: [
|
||||
"audio/mpeg",
|
||||
"audio/wav",
|
||||
"audio/x-wav",
|
||||
"audio/ogg",
|
||||
"audio/webm",
|
||||
"audio/mp4",
|
||||
"audio/aac",
|
||||
"audio/flac",
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export function isAllowedUploadMimeType(kind: UploadKind, mimeType: string) {
|
||||
return UPLOAD_RULES[kind].mimeTypes.includes(mimeType.toLowerCase());
|
||||
}
|
||||
Reference in New Issue
Block a user