diff --git a/package-lock.json b/package-lock.json
index 380933e..24fdaf4 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "s3-browser",
- "version": "0.1.0",
+ "version": "0.2.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "s3-browser",
- "version": "0.1.0",
+ "version": "0.2.0",
"dependencies": {
"@auth/core": "^0.41.0",
"@aws-sdk/client-s3": "^3.967.0",
@@ -1049,6 +1049,7 @@
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0",
@@ -4488,6 +4489,7 @@
"integrity": "sha512-WPigyYuGhgZ/cTPRXB2EwUw+XvsRA3GqHlsP4qteqrnnjDrApbS7MxcGr/hke5iUoeB7E/gQtrs9I37zAJ0Vjw==",
"devOptional": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"csstype": "^3.2.2"
}
@@ -4498,6 +4500,7 @@
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
"devOptional": true,
"license": "MIT",
+ "peer": true,
"peerDependencies": {
"@types/react": "^19.2.0"
}
@@ -4547,6 +4550,7 @@
"integrity": "sha512-BtE0k6cjwjLZoZixN0t5AKP0kSzlGu7FctRXYuPAm//aaiZhmfq1JwdYpYr1brzEspYyFeF+8XF5j2VK6oalrA==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.54.0",
"@typescript-eslint/types": "8.54.0",
@@ -5046,6 +5050,7 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
+ "peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -5404,6 +5409,7 @@
}
],
"license": "MIT",
+ "peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@@ -6068,6 +6074,7 @@
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -6253,6 +6260,7 @@
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@rtsao/scc": "^1.1.0",
"array-includes": "^3.1.9",
@@ -8501,6 +8509,7 @@
"resolved": "https://registry.npmjs.org/preact/-/preact-10.24.3.tgz",
"integrity": "sha512-Z2dPnBnMUfyQfSQ+GBdsGa16hz35YmLmtTLhM169uW944hYL6xzTYkJjC07j+Wosz733pMWx0fgON3JNw1jJQA==",
"license": "MIT",
+ "peer": true,
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/preact"
@@ -8573,6 +8582,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz",
"integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==",
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -8582,6 +8592,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz",
"integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
@@ -9387,6 +9398,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=12"
},
@@ -9568,6 +9580,7 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
+ "peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -9900,6 +9913,7 @@
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
"license": "MIT",
+ "peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
diff --git a/scripts/init-buckets.ts b/scripts/init-buckets.ts
index c92e7d5..8fa2756 100644
--- a/scripts/init-buckets.ts
+++ b/scripts/init-buckets.ts
@@ -6,7 +6,8 @@
// Or: npm run init-buckets
import { config } from "dotenv";
-config({ path: ".env.local" });
+config({ path: ".env" });
+config({ path: ".env.local", override: true });
import { initializeBuckets } from "../src/services/s3Service";
diff --git a/scripts/sync-permissions.ts b/scripts/sync-permissions.ts
index 5757242..011f9c4 100644
--- a/scripts/sync-permissions.ts
+++ b/scripts/sync-permissions.ts
@@ -6,7 +6,8 @@
// Or: npm run sync-permissions
import { config } from "dotenv";
-config({ path: ".env.local" });
+config({ path: ".env" });
+config({ path: ".env.local", override: true });
import { syncPermissions, validatePermissionsFile } from "../src/services/syncService";
import { disconnectRedis } from "../src/lib/redis";
diff --git a/src/app/(dashboard)/explorer/[[...path]]/page.tsx b/src/app/(dashboard)/explorer/[[...path]]/page.tsx
index 7fd8c95..1e8de88 100644
--- a/src/app/(dashboard)/explorer/[[...path]]/page.tsx
+++ b/src/app/(dashboard)/explorer/[[...path]]/page.tsx
@@ -27,7 +27,7 @@ export default async function ExplorerPage({ params }: ExplorerPageProps) {
const explorerPath = currentPath || defaultPath;
return (
-
+
);
diff --git a/src/app/(dashboard)/layout.tsx b/src/app/(dashboard)/layout.tsx
index f4c7ce6..9253490 100644
--- a/src/app/(dashboard)/layout.tsx
+++ b/src/app/(dashboard)/layout.tsx
@@ -4,6 +4,7 @@
import { redirect } from "next/navigation";
import { auth, signOut } from "@/lib/auth";
+import { ThemeToggle } from "@/components/theme/ThemeToggle";
export default async function DashboardLayout({
children,
@@ -21,18 +22,20 @@ export default async function DashboardLayout({
const displayName = user.username || user.email || "User";
return (
-
+
{/* Header */}
-
-
-
🚀 S3-Browser
+
+
+
🚀 S3-Browser
-
-
+
+
{displayName}
+
@@ -50,7 +54,9 @@ export default async function DashboardLayout({
{/* Main content */}
- {children}
+
+ {children}
+
);
}
diff --git a/src/app/api/files/folders/route.ts b/src/app/api/files/folders/route.ts
new file mode 100644
index 0000000..4cb6403
--- /dev/null
+++ b/src/app/api/files/folders/route.ts
@@ -0,0 +1,137 @@
+// ================================
+// API Route: List Move Destinations
+// ================================
+
+import { NextRequest, NextResponse } from "next/server";
+import { auth } from "@/lib/auth";
+import { listFiles } from "@/services/s3Service";
+import { checkPermission } from "@/services/permissionService";
+import type { ApiResponse } from "@/types";
+
+export const dynamic = "force-dynamic";
+export const revalidate = 0;
+
+const noStoreHeaders = {
+ "Cache-Control": "no-store, no-cache, must-revalidate, proxy-revalidate",
+};
+
+interface FolderDestination {
+ path: string;
+ name: string;
+ depth: number;
+}
+
+function getName(path: string): string {
+ return path.split("/").filter(Boolean).pop() || path;
+}
+
+function isValidDestination(
+ folderPath: string,
+ sourcePath: string | null,
+ sourceType: string | null
+): boolean {
+ if (!sourcePath) return true;
+
+ const normalizedSource = sourcePath.replace(/^\/+|\/+$/g, "");
+ const sourceParent = normalizedSource.split("/").slice(0, -1).join("/");
+
+ if (folderPath === sourceParent) return false;
+ if (folderPath === normalizedSource) return false;
+
+ if (
+ sourceType === "folder" &&
+ folderPath.startsWith(`${normalizedSource}/`)
+ ) {
+ return false;
+ }
+
+ return true;
+}
+
+async function canWriteToPath(
+ user: { email: string; username: string; roles: string[] },
+ path: string
+) {
+ const result = await checkPermission({
+ userId: user.email,
+ username: user.username,
+ roles: user.roles,
+ path,
+ requiredLevel: "EDITOR",
+ });
+
+ return result.allowed;
+}
+
+async function collectFolders(
+ path: string,
+ user: { email: string; username: string; roles: string[] },
+ folders: FolderDestination[],
+ depth = 0,
+ maxDepth = 8
+) {
+ if (await canWriteToPath(user, path)) {
+ folders.push({
+ path,
+ name: path === "home" ? "home" : getName(path),
+ depth,
+ });
+ }
+
+ if (depth >= maxDepth) return;
+
+ const listing = await listFiles(path, { maxItems: 1000 });
+ const childFolders = listing.items.filter((item) => item.type === "folder");
+
+ for (const folder of childFolders) {
+ await collectFolders(folder.key, user, folders, depth + 1, maxDepth);
+ }
+}
+
+export async function GET(request: NextRequest) {
+ try {
+ const session = await auth();
+ if (!session?.user) {
+ return NextResponse.json
(
+ { success: false, error: "Unauthorized" },
+ { status: 401 }
+ );
+ }
+
+ const user = session.user as {
+ id: string;
+ email: string;
+ username: string;
+ roles: string[];
+ };
+
+ const sourcePath = request.nextUrl.searchParams.get("sourcePath");
+ const sourceType = request.nextUrl.searchParams.get("sourceType");
+ const folders: FolderDestination[] = [];
+ await collectFolders("home", user, folders);
+
+ const uniqueFolders = Array.from(
+ new Map(folders.map((folder) => [folder.path, folder])).values()
+ )
+ .filter((folder) => isValidDestination(folder.path, sourcePath, sourceType))
+ .sort((a, b) => a.path.localeCompare(b.path));
+
+ return NextResponse.json>(
+ {
+ success: true,
+ data: { folders: uniqueFolders },
+ },
+ { headers: noStoreHeaders }
+ );
+ } catch (error) {
+ console.error("[API] List folders error:", error);
+ return NextResponse.json(
+ {
+ success: false,
+ error: "Failed to list folders",
+ message: error instanceof Error ? error.message : "Unknown error",
+ },
+ { status: 500 }
+ );
+ }
+}
diff --git a/src/app/api/files/list/route.ts b/src/app/api/files/list/route.ts
index d359f6b..dac9fef 100644
--- a/src/app/api/files/list/route.ts
+++ b/src/app/api/files/list/route.ts
@@ -4,11 +4,18 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/lib/auth";
-import { listFiles } from "@/services/s3Service";
+import { listFiles, pathExists } from "@/services/s3Service";
import { checkPermission, getPermissionLevel } from "@/services/permissionService";
import { audit } from "@/services/auditService";
import type { ApiResponse, PaginatedResponse, FileItem } from "@/types";
+export const dynamic = "force-dynamic";
+export const revalidate = 0;
+
+const noStoreHeaders = {
+ "Cache-Control": "no-store, no-cache, must-revalidate, proxy-revalidate",
+};
+
export async function GET(request: NextRequest) {
try {
// Authenticate user
@@ -53,6 +60,17 @@ export async function GET(request: NextRequest) {
);
}
+ if (path && !(await pathExists(path))) {
+ return NextResponse.json(
+ {
+ success: false,
+ error: "Folder not found",
+ message: "This folder does not exist or has been moved.",
+ },
+ { status: 404, headers: noStoreHeaders }
+ );
+ }
+
// List files
const result = await listFiles(path, { maxItems, continuationToken });
@@ -72,14 +90,17 @@ export async function GET(request: NextRequest) {
// Log audit
await audit.list(user.id, user.username, path);
- return NextResponse.json>>({
- success: true,
- data: {
- items: enrichedItems,
- nextContinuationToken: result.nextContinuationToken,
- hasMore: result.hasMore,
+ return NextResponse.json>>(
+ {
+ success: true,
+ data: {
+ items: enrichedItems,
+ nextContinuationToken: result.nextContinuationToken,
+ hasMore: result.hasMore,
+ },
},
- });
+ { headers: noStoreHeaders }
+ );
} catch (error) {
console.error("[API] List files error:", error);
return NextResponse.json(
diff --git a/src/app/api/files/move/route.ts b/src/app/api/files/move/route.ts
new file mode 100644
index 0000000..3068b67
--- /dev/null
+++ b/src/app/api/files/move/route.ts
@@ -0,0 +1,142 @@
+// ================================
+// API Route: Move File/Folder
+// ================================
+
+import { NextRequest, NextResponse } from "next/server";
+import { z } from "zod";
+import { auth } from "@/lib/auth";
+import { resolvePath } from "@/lib/s3-client";
+import { moveObject } from "@/services/s3Service";
+import { checkPermission } from "@/services/permissionService";
+import { audit } from "@/services/auditService";
+import { invalidatePathListingCache } from "@/services/cacheService";
+import type { ApiResponse } from "@/types";
+
+const moveSchema = z.object({
+ sourcePath: z.string().min(1, "Source path is required"),
+ destinationPath: z.string().min(1, "Destination path is required"),
+ sourceType: z.enum(["file", "folder"]).default("file"),
+});
+
+function getParentKey(path: string): { bucket: string; key: string } {
+ const { bucket, key } = resolvePath(path);
+ return {
+ bucket,
+ key: key.split("/").slice(0, -1).join("/"),
+ };
+}
+
+export async function POST(request: NextRequest) {
+ try {
+ const session = await auth();
+ if (!session?.user) {
+ return NextResponse.json(
+ { success: false, error: "Unauthorized" },
+ { status: 401 }
+ );
+ }
+
+ const user = session.user as {
+ id: string;
+ email: string;
+ username: string;
+ roles: string[];
+ };
+
+ const body = await request.json();
+ const validation = moveSchema.safeParse(body);
+
+ if (!validation.success) {
+ return NextResponse.json(
+ {
+ success: false,
+ error: "Invalid request",
+ message: validation.error.issues[0].message,
+ },
+ { status: 400 }
+ );
+ }
+
+ const { sourcePath, destinationPath, sourceType } = validation.data;
+ const normalizedDestination = destinationPath.replace(/^\/+|\/+$/g, "");
+
+ if (!normalizedDestination) {
+ return NextResponse.json(
+ { success: false, error: "Destination path is required" },
+ { status: 400 }
+ );
+ }
+
+ const sourcePermission = await checkPermission({
+ userId: user.email,
+ username: user.username,
+ roles: user.roles,
+ path: sourcePath,
+ requiredLevel: "EDITOR",
+ });
+
+ if (!sourcePermission.allowed) {
+ await audit.error(user.id, user.username, "MOVE", sourcePath, "Access denied");
+ return NextResponse.json(
+ {
+ success: false,
+ error: "Access denied",
+ message: sourcePermission.denyReason,
+ },
+ { status: 403 }
+ );
+ }
+
+ const destinationPermission = await checkPermission({
+ userId: user.email,
+ username: user.username,
+ roles: user.roles,
+ path: normalizedDestination,
+ requiredLevel: "EDITOR",
+ });
+
+ if (!destinationPermission.allowed) {
+ await audit.error(
+ user.id,
+ user.username,
+ "MOVE",
+ normalizedDestination,
+ "Access denied"
+ );
+ return NextResponse.json(
+ {
+ success: false,
+ error: "Access denied",
+ message: destinationPermission.denyReason,
+ },
+ { status: 403 }
+ );
+ }
+
+ const newPath = await moveObject(sourcePath, normalizedDestination, sourceType);
+
+ const sourceParent = getParentKey(sourcePath);
+ await invalidatePathListingCache(sourceParent.bucket, sourceParent.key);
+
+ const destination = resolvePath(normalizedDestination);
+ await invalidatePathListingCache(destination.bucket, destination.key);
+
+ await audit.move(user.id, user.username, sourcePath, newPath);
+
+ return NextResponse.json>({
+ success: true,
+ data: { oldPath: sourcePath, newPath },
+ message: "Moved successfully",
+ });
+ } catch (error) {
+ console.error("[API] Move error:", error);
+ return NextResponse.json(
+ {
+ success: false,
+ error: "Failed to move",
+ message: error instanceof Error ? error.message : "Unknown error",
+ },
+ { status: 500 }
+ );
+ }
+}
diff --git a/src/app/api/files/upload-url/route.ts b/src/app/api/files/upload-url/route.ts
index ffd8a2e..d236754 100644
--- a/src/app/api/files/upload-url/route.ts
+++ b/src/app/api/files/upload-url/route.ts
@@ -125,10 +125,10 @@ export async function POST(request: NextRequest) {
contentType,
expiresIn: 3600,
metadata: {
- "x-amz-meta-owner-id": user.id,
- "x-amz-meta-owner-username": user.username,
- "x-amz-meta-created-at": new Date().toISOString(),
- "x-amz-meta-original-name": fileName,
+ "owner-id": user.id,
+ "owner-username": user.username,
+ "created-at": new Date().toISOString(),
+ "original-name": fileName,
},
});
diff --git a/src/app/api/files/upload/route.ts b/src/app/api/files/upload/route.ts
new file mode 100644
index 0000000..31d5e8b
--- /dev/null
+++ b/src/app/api/files/upload/route.ts
@@ -0,0 +1,145 @@
+// ================================
+// API Route: Server-side Upload
+// ================================
+
+import { NextRequest, NextResponse } from "next/server";
+import { auth } from "@/lib/auth";
+import {
+ getMaxFileSize,
+ isAllowedFileType,
+ validateFileName,
+} from "@/lib/s3-client";
+import { uploadObject } from "@/services/s3Service";
+import { checkPermission } from "@/services/permissionService";
+import { audit } from "@/services/auditService";
+import type { ApiResponse } from "@/types";
+
+export const dynamic = "force-dynamic";
+export const revalidate = 0;
+
+function buildFinalPath(path: string, fileName: string) {
+ const normalizedPath = path.replace(/\/+$/, "");
+ return normalizedPath ? `${normalizedPath}/${fileName}` : fileName;
+}
+
+export async function POST(request: NextRequest) {
+ try {
+ const session = await auth();
+ if (!session?.user) {
+ return NextResponse.json(
+ { success: false, error: "Unauthorized" },
+ { status: 401 }
+ );
+ }
+
+ const user = session.user as {
+ id: string;
+ email: string;
+ username: string;
+ roles: string[];
+ };
+
+ const formData = await request.formData();
+ const file = formData.get("file");
+ const fileName = String(formData.get("fileName") || "").trim();
+ const path = String(formData.get("path") || "").trim();
+
+ if (!(file instanceof File)) {
+ return NextResponse.json(
+ { success: false, error: "File is required" },
+ { status: 400 }
+ );
+ }
+
+ if (!fileName) {
+ return NextResponse.json(
+ { success: false, error: "File name is required" },
+ { status: 400 }
+ );
+ }
+
+ const fileNameError = validateFileName(fileName);
+ if (fileNameError) {
+ return NextResponse.json(
+ { success: false, error: fileNameError },
+ { status: 400 }
+ );
+ }
+
+ if (!isAllowedFileType(fileName)) {
+ return NextResponse.json(
+ { success: false, error: "File type not allowed" },
+ { status: 400 }
+ );
+ }
+
+ const maxSize = getMaxFileSize();
+ if (file.size > maxSize) {
+ return NextResponse.json(
+ {
+ success: false,
+ error: `File size exceeds maximum allowed (${Math.round(maxSize / 1024 / 1024)}MB)`,
+ },
+ { status: 400 }
+ );
+ }
+
+ const finalPath = buildFinalPath(path, fileName);
+ const permissionResult = await checkPermission({
+ userId: user.email,
+ username: user.username,
+ roles: user.roles,
+ path: finalPath,
+ requiredLevel: "EDITOR",
+ });
+
+ if (!permissionResult.allowed) {
+ await audit.error(
+ user.id,
+ user.username,
+ "UPLOAD",
+ finalPath,
+ "Access denied"
+ );
+
+ return NextResponse.json(
+ {
+ success: false,
+ error: "Access denied",
+ message: permissionResult.denyReason,
+ },
+ { status: 403 }
+ );
+ }
+
+ const body = new Uint8Array(await file.arrayBuffer());
+ await uploadObject(finalPath, {
+ body,
+ contentType: file.type || "application/octet-stream",
+ metadata: {
+ "owner-id": user.id,
+ "owner-username": user.username,
+ "created-at": new Date().toISOString(),
+ "original-name": file.name,
+ },
+ });
+
+ await audit.upload(user.id, user.username, finalPath);
+
+ return NextResponse.json>({
+ success: true,
+ data: { path: finalPath },
+ message: "File uploaded successfully",
+ });
+ } catch (error) {
+ console.error("[API] Upload error:", error);
+ return NextResponse.json(
+ {
+ success: false,
+ error: "Failed to upload file",
+ message: error instanceof Error ? error.message : "Unknown error",
+ },
+ { status: 500 }
+ );
+ }
+}
diff --git a/src/app/globals.css b/src/app/globals.css
index 1685ea7..f649394 100644
--- a/src/app/globals.css
+++ b/src/app/globals.css
@@ -1,6 +1,7 @@
@import "tailwindcss";
:root {
+ color-scheme: light;
--background: 0 0% 100%;
--foreground: 0 0% 3.9%;
--card: 0 0% 100%;
@@ -47,8 +48,36 @@
--font-mono: var(--font-geist-mono);
}
+:root[data-theme="dark"] {
+ color-scheme: dark;
+ --background: 0 0% 3.9%;
+ --foreground: 0 0% 98%;
+ --card: 0 0% 3.9%;
+ --card-foreground: 0 0% 98%;
+ --popover: 0 0% 3.9%;
+ --popover-foreground: 0 0% 98%;
+ --primary: 0 0% 98%;
+ --primary-foreground: 0 0% 9%;
+ --secondary: 0 0% 14.9%;
+ --secondary-foreground: 0 0% 98%;
+ --muted: 0 0% 14.9%;
+ --muted-foreground: 0 0% 63.9%;
+ --accent: 0 0% 14.9%;
+ --accent-foreground: 0 0% 98%;
+ --destructive: 0 62.8% 30.6%;
+ --destructive-foreground: 0 0% 98%;
+ --border: 0 0% 14.9%;
+ --input: 0 0% 14.9%;
+ --ring: 0 0% 83.1%;
+}
+
+:root[data-theme="light"] {
+ color-scheme: light;
+}
+
@media (prefers-color-scheme: dark) {
- :root {
+ :root:not([data-theme="light"]) {
+ color-scheme: dark;
--background: 0 0% 3.9%;
--foreground: 0 0% 98%;
--card: 0 0% 3.9%;
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index f7fa87e..e6dfb36 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -22,8 +22,20 @@ export default function RootLayout({
}: Readonly<{
children: React.ReactNode;
}>) {
+ const themeScript = `
+ try {
+ var theme = localStorage.getItem("s3-browser-theme");
+ if (theme === "light" || theme === "dark") {
+ document.documentElement.dataset.theme = theme;
+ }
+ } catch (_) {}
+ `;
+
return (
-
+
+
+
+
diff --git a/src/components/explorer/Breadcrumb.tsx b/src/components/explorer/Breadcrumb.tsx
index 09a651e..ac299c6 100644
--- a/src/components/explorer/Breadcrumb.tsx
+++ b/src/components/explorer/Breadcrumb.tsx
@@ -26,7 +26,7 @@ export function Breadcrumb({ path, className }: BreadcrumbProps) {
return (