feat(storage): complete phase 3 branding foundation
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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" },
|
||||
],
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user