feat(storage): complete phase 3 branding foundation

This commit is contained in:
poyrazavsever
2026-07-16 17:05:59 +03:00
parent b155132acf
commit 5d863280bf
31 changed files with 5302 additions and 57 deletions
+15
View File
@@ -0,0 +1,15 @@
import { apiError } from "@/server/api/responses";
import { getFileService } from "@/server/files/runtime";
import { fileResponse } from "@/server/files/http";
export async function GET(
_request: Request,
{ params }: { params: Promise<{ id: string }> },
) {
try {
const file = getFileService().readPublicBranding((await params).id);
return fileResponse(file.metadata, file.bytes, "public, max-age=3600, stale-while-revalidate=86400");
} catch (error) {
return apiError(error);
}
}
+24
View File
@@ -0,0 +1,24 @@
import { apiError, apiSuccess } from "@/server/api/responses";
import { domainActorFromSession } from "@/server/auth/domain-actor";
import { getSessionContextFromHeaders } from "@/server/auth/session";
import { getBrandingService, getPublicBranding } from "@/server/branding/runtime";
import { DomainError } from "@/server/domain/errors";
export function GET() {
return apiSuccess(getPublicBranding());
}
export async function PATCH(request: Request) {
const context = await getSessionContextFromHeaders(new Headers(request.headers));
if (!context) return apiError(new DomainError("UNAUTHENTICATED", "Oturum gerekli."));
try {
const branding = getBrandingService().update(
domainActorFromSession(context),
await request.json(),
);
return apiSuccess(branding);
} catch (error) {
return apiError(error);
}
}
+36
View File
@@ -0,0 +1,36 @@
import { apiError } from "@/server/api/responses";
import { domainActorFromSession } from "@/server/auth/domain-actor";
import { getSessionContextFromHeaders } from "@/server/auth/session";
import { DomainError } from "@/server/domain/errors";
import { getFileService } from "@/server/files/runtime";
import { fileResponse } from "@/server/files/http";
export async function GET(
request: Request,
{ params }: { params: Promise<{ id: string }> },
) {
const context = await getSessionContextFromHeaders(new Headers(request.headers));
if (!context) return apiError(new DomainError("UNAUTHENTICATED", "Oturum gerekli."));
try {
const file = getFileService().read(domainActorFromSession(context), (await params).id);
return fileResponse(file.metadata, file.bytes, "private, no-store");
} catch (error) {
return apiError(error);
}
}
export async function DELETE(
request: Request,
{ params }: { params: Promise<{ id: string }> },
) {
const context = await getSessionContextFromHeaders(new Headers(request.headers));
if (!context) return apiError(new DomainError("UNAUTHENTICATED", "Oturum gerekli."));
try {
getFileService().delete(domainActorFromSession(context), (await params).id);
return new Response(null, { status: 204 });
} catch (error) {
return apiError(error);
}
}
+60
View File
@@ -0,0 +1,60 @@
import { apiError, apiSuccess } from "@/server/api/responses";
import { domainActorFromSession } from "@/server/auth/domain-actor";
import { getSessionContextFromHeaders } from "@/server/auth/session";
import { DomainError } from "@/server/domain/errors";
import { fileKinds, type FileKind } from "@/server/domain/types";
import { getFileService } from "@/server/files/runtime";
import { MAX_UPLOAD_BYTES } from "@/server/files/policy";
export async function POST(request: Request) {
const context = await getSessionContextFromHeaders(new Headers(request.headers));
if (!context) return apiError(new DomainError("UNAUTHENTICATED", "Oturum gerekli."));
try {
const formData = await request.formData();
const upload = formData.get("file");
const rawKind = formData.get("kind");
if (!(upload instanceof File) || typeof rawKind !== "string" || !isFileKind(rawKind)) {
throw new DomainError("VALIDATION_ERROR", "file ve geçerli kind alanları zorunludur.");
}
if (upload.size > MAX_UPLOAD_BYTES) {
throw new DomainError("VALIDATION_ERROR", "Dosya boyutu 5 MB sınırını aşıyor.");
}
const stored = getFileService().upload(domainActorFromSession(context), {
kind: rawKind,
originalName: upload.name,
claimedMimeType: upload.type,
bytes: new Uint8Array(await upload.arrayBuffer()),
projectId: stringValue(formData.get("projectId")),
portalVisible: formData.get("portalVisible") === "true",
});
return apiSuccess(toFileResponse(stored), { status: 201 });
} catch (error) {
return apiError(error);
}
}
function isFileKind(value: string): value is FileKind {
return fileKinds.includes(value as FileKind);
}
function stringValue(value: FormDataEntryValue | null): string | undefined {
return typeof value === "string" && value.trim() ? value.trim() : undefined;
}
function toFileResponse(file: ReturnType<ReturnType<typeof getFileService>["upload"]>) {
return {
id: file.id,
kind: file.kind,
visibility: file.visibility,
originalName: file.originalName,
mimeType: file.mimeType,
byteSize: file.byteSize,
sha256: file.sha256,
projectId: file.projectId,
url: `/api/files/${file.id}`,
createdAt: file.createdAt,
};
}
+44
View File
@@ -90,6 +90,50 @@
color-scheme: light;
}
:root[data-color-mode="dark"] {
color-scheme: dark;
--background: #0b1018;
--background-dark: #0b1018;
--foreground: #f2f4f7;
--surface: #121926;
--surface-raised: #182230;
--card: #121926;
--card-foreground: #f2f4f7;
--popover: #182230;
--popover-foreground: #f2f4f7;
--secondary: #253044;
--secondary-foreground: #f2f4f7;
--muted: #253044;
--muted-foreground: #98a2b3;
--border: #344054;
--border-strong: #475467;
--input: #475467;
--input-bg: #121926;
}
@media (prefers-color-scheme: dark) {
:root[data-color-mode="system"] {
color-scheme: dark;
--background: #0b1018;
--background-dark: #0b1018;
--foreground: #f2f4f7;
--surface: #121926;
--surface-raised: #182230;
--card: #121926;
--card-foreground: #f2f4f7;
--popover: #182230;
--popover-foreground: #f2f4f7;
--secondary: #253044;
--secondary-foreground: #f2f4f7;
--muted: #253044;
--muted-foreground: #98a2b3;
--border: #344054;
--border-strong: #475467;
--input: #475467;
--input-bg: #121926;
}
}
body {
@apply min-h-screen bg-background text-foreground antialiased;
font-size: 14px;
+23 -14
View File
@@ -1,36 +1,45 @@
import type { Metadata, Viewport } from "next";
import type { CSSProperties } from "react";
import "./globals.css";
import { Geist } from "next/font/google";
import { cn } from "@/lib/utils";
import { OfflineIndicator } from "@/components/ui/offline-indicator";
import { Toaster } from "@/components/ui/toast";
import { getPublicBranding } from "@/server/branding/runtime";
const geist = Geist({ subsets: ["latin"], variable: "--font-geist-sans" });
export const metadata: Metadata = {
title: "Neta",
description: "Self-hosted freelancer operating dashboard",
manifest: "/manifest.json",
appleWebApp: {
capable: true,
statusBarStyle: "default",
title: "Neta",
},
};
export function generateMetadata(): Metadata {
const branding = getPublicBranding();
return {
title: { default: branding.applicationName, template: `%s · ${branding.applicationName}` },
description: "Self-hosted freelancer operating dashboard",
manifest: "/manifest.webmanifest",
icons: branding.iconUrl ? { icon: branding.iconUrl, apple: branding.iconUrl } : undefined,
appleWebApp: {
capable: true,
statusBarStyle: "default",
title: branding.shortName,
},
};
}
export const viewport: Viewport = {
themeColor: "#ffffff",
};
export function generateViewport(): Viewport {
return { themeColor: getPublicBranding().primaryColor };
}
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
const branding = getPublicBranding();
return (
<html
lang="tr"
className={cn("font-sans", geist.variable)}
className={cn("font-sans", geist.variable, branding.defaultColorMode === "dark" && "dark")}
data-color-mode={branding.defaultColorMode}
style={branding.cssVariables as CSSProperties}
suppressHydrationWarning
>
<body>
+23
View File
@@ -0,0 +1,23 @@
import type { MetadataRoute } from "next";
import { getPublicBranding } from "@/server/branding/runtime";
export const dynamic = "force-dynamic";
export default function manifest(): MetadataRoute.Manifest {
const branding = getPublicBranding();
return {
name: branding.applicationName,
short_name: branding.shortName,
description: "Self-hosted freelancer operating dashboard",
start_url: "/",
display: "standalone",
background_color: "#FFFFFF",
theme_color: branding.primaryColor,
icons: branding.iconUrl
? [{ src: branding.iconUrl, sizes: "any", type: "image/png" }]
: [
{ src: "/icons/icon-192x192.png", sizes: "192x192", type: "image/png" },
{ src: "/icons/icon-512x512.png", sizes: "512x512", type: "image/png" },
],
};
}