Initial commit - Sitemap Builder app

This commit is contained in:
Karol Głowacki
2026-01-09 18:52:15 +01:00
parent 4e5625f03e
commit 318dcc88ac
54 changed files with 7969 additions and 103 deletions

13
.dockerignore Normal file
View File

@@ -0,0 +1,13 @@
.git
.gitignore
.next
node_modules
*.md
.env
.env.*
!.env.example
data
uploads
Dockerfile*
docker-compose*
.dockerignore

11
.env.example Normal file
View File

@@ -0,0 +1,11 @@
# Database
DATABASE_URL="file:./dev.db"
# Auth - Change this to a secure password!
APP_ACCESS_PASSWORD="your-secure-password-here"
# JWT Secret - Generate a random string for production
JWT_SECRET="your-jwt-secret-change-in-production"
# Upload directory (inside container)
UPLOAD_DIR="./uploads"

3
.gitignore vendored
View File

@@ -32,6 +32,7 @@ yarn-error.log*
# env files (can opt-in for committing if needed) # env files (can opt-in for committing if needed)
.env* .env*
!.env.example
# vercel # vercel
.vercel .vercel
@@ -39,3 +40,5 @@ yarn-error.log*
# typescript # typescript
*.tsbuildinfo *.tsbuildinfo
next-env.d.ts next-env.d.ts
/src/generated/prisma

55
Dockerfile Normal file
View File

@@ -0,0 +1,55 @@
# Stage 1: Dependencies
FROM node:20-alpine AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci
# Stage 2: Build
FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# Generate Prisma Client
RUN npx prisma generate
# Build Next.js
ENV NEXT_TELEMETRY_DISABLED=1
RUN npm run build
# Stage 3: Production runner
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
# Copy necessary files
COPY --from=builder /app/public ./public
COPY --from=builder /app/prisma ./prisma
# Set the correct permission for prerender cache
RUN mkdir .next
RUN chown nextjs:nodejs .next
# Automatically leverage output traces to reduce image size
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
# Create upload and data directories
RUN mkdir -p uploads data
RUN chown -R nextjs:nodejs uploads data
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
CMD ["node", "server.js"]

22
components.json Normal file
View File

@@ -0,0 +1,22 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/app/globals.css",
"baseColor": "zinc",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"registries": {}
}

BIN
dev.db Normal file

Binary file not shown.

19
docker-compose.yml Normal file
View File

@@ -0,0 +1,19 @@
services:
app:
build:
context: .
dockerfile: Dockerfile
container_name: sitemap-builder
restart: unless-stopped
ports:
- "3000:3000"
environment:
- DATABASE_URL=file:/app/data/database.db
- APP_ACCESS_PASSWORD=${APP_ACCESS_PASSWORD:-admin}
- JWT_SECRET=${JWT_SECRET:-your-secret-key-change-me}
- UPLOAD_DIR=/app/uploads
volumes:
# Persist SQLite database
- ./data:/app/data
# Persist uploaded files
- ./uploads:/app/uploads

View File

@@ -1,7 +1,7 @@
import type { NextConfig } from "next"; import type { NextConfig } from "next";
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
/* config options here */ output: "standalone",
}; };
export default nextConfig; export default nextConfig;

3628
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -9,9 +9,29 @@
"lint": "eslint" "lint": "eslint"
}, },
"dependencies": { "dependencies": {
"@libsql/client": "^0.17.0",
"@prisma/adapter-libsql": "^7.2.0",
"@prisma/client": "^7.2.0",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@tiptap/extension-placeholder": "^3.15.3",
"@tiptap/react": "^3.15.3",
"@tiptap/starter-kit": "^3.15.3",
"@xyflow/react": "^12.10.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"dotenv": "^17.2.3",
"jose": "^6.1.3",
"lucide-react": "^0.562.0",
"next": "16.1.1", "next": "16.1.1",
"react": "19.2.3", "react": "19.2.3",
"react-dom": "19.2.3" "react-dom": "19.2.3",
"react-dropzone": "^14.3.8",
"tailwind-merge": "^3.4.0"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
@@ -20,7 +40,9 @@
"@types/react-dom": "^19", "@types/react-dom": "^19",
"eslint": "^9", "eslint": "^9",
"eslint-config-next": "16.1.1", "eslint-config-next": "16.1.1",
"prisma": "^7.2.0",
"tailwindcss": "^4", "tailwindcss": "^4",
"tw-animate-css": "^1.4.0",
"typescript": "^5" "typescript": "^5"
} }
} }

14
prisma.config.ts Normal file
View File

@@ -0,0 +1,14 @@
// This file was generated by Prisma, and assumes you have installed the following:
// npm install --save-dev prisma dotenv
import "dotenv/config";
import { defineConfig } from "prisma/config";
export default defineConfig({
schema: "prisma/schema.prisma",
migrations: {
path: "prisma/migrations",
},
datasource: {
url: process.env["DATABASE_URL"],
},
});

View File

@@ -0,0 +1,58 @@
-- CreateTable
CREATE TABLE "Project" (
"id" TEXT NOT NULL PRIMARY KEY,
"name" TEXT NOT NULL,
"thumbnail" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
-- CreateTable
CREATE TABLE "Node" (
"id" TEXT NOT NULL PRIMARY KEY,
"projectId" TEXT NOT NULL,
"type" TEXT NOT NULL DEFAULT 'page',
"title" TEXT NOT NULL DEFAULT 'Nowa strona',
"content" TEXT,
"notes" TEXT,
"status" TEXT NOT NULL DEFAULT 'draft',
"positionX" REAL NOT NULL DEFAULT 0,
"positionY" REAL NOT NULL DEFAULT 0,
"width" REAL,
"height" REAL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "Node_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "Edge" (
"id" TEXT NOT NULL PRIMARY KEY,
"projectId" TEXT NOT NULL,
"sourceId" TEXT NOT NULL,
"targetId" TEXT NOT NULL,
"type" TEXT NOT NULL DEFAULT 'default',
CONSTRAINT "Edge_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "Attachment" (
"id" TEXT NOT NULL PRIMARY KEY,
"nodeId" TEXT NOT NULL,
"filename" TEXT NOT NULL,
"originalName" TEXT NOT NULL,
"mimeType" TEXT NOT NULL,
"size" INTEGER NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Attachment_nodeId_fkey" FOREIGN KEY ("nodeId") REFERENCES "Node" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "Link" (
"id" TEXT NOT NULL PRIMARY KEY,
"nodeId" TEXT NOT NULL,
"url" TEXT NOT NULL,
"label" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Link_nodeId_fkey" FOREIGN KEY ("nodeId") REFERENCES "Node" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);

View File

@@ -0,0 +1,6 @@
-- AlterTable
ALTER TABLE "Project" ADD COLUMN "colorPalette" TEXT;
ALTER TABLE "Project" ADD COLUMN "designNotes" TEXT;
ALTER TABLE "Project" ADD COLUMN "fontPrimary" TEXT;
ALTER TABLE "Project" ADD COLUMN "fontSecondary" TEXT;
ALTER TABLE "Project" ADD COLUMN "globalNotes" TEXT;

View File

@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "sqlite"

74
prisma/schema.prisma Normal file
View File

@@ -0,0 +1,74 @@
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "sqlite"
}
model Project {
id String @id @default(cuid())
name String
thumbnail String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Design settings for handoff
globalNotes String? // Global project notes
fontPrimary String? // Primary font name
fontSecondary String? // Secondary font name
colorPalette String? // JSON array of colors
designNotes String? // Additional design notes (WordPress specifics, etc.)
nodes Node[]
edges Edge[]
}
model Node {
id String @id @default(cuid())
projectId String
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
type String @default("page")
title String @default("Nowa strona")
content String? // Rich text content
notes String? // Developer notes
status String @default("draft") // draft, ready, review
positionX Float @default(0)
positionY Float @default(0)
width Float?
height Float?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
attachments Attachment[]
links Link[]
}
model Edge {
id String @id @default(cuid())
projectId String
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
sourceId String
targetId String
type String @default("default")
}
model Attachment {
id String @id @default(cuid())
nodeId String
node Node @relation(fields: [nodeId], references: [id], onDelete: Cascade)
filename String // Stored filename
originalName String // Original upload name
mimeType String
size Int
createdAt DateTime @default(now())
}
model Link {
id String @id @default(cuid())
nodeId String
node Node @relation(fields: [nodeId], references: [id], onDelete: Cascade)
url String
label String?
createdAt DateTime @default(now())
}

View File

@@ -0,0 +1,50 @@
import { NextRequest, NextResponse } from "next/server";
import { unlink } from "fs/promises";
import { join } from "path";
import { prisma } from "@/lib/prisma";
const UPLOAD_DIR = process.env.UPLOAD_DIR || "./uploads";
interface Params {
params: Promise<{ id: string }>;
}
// DELETE an attachment
export async function DELETE(request: NextRequest, { params }: Params) {
try {
const { id } = await params;
// Get attachment to find filename
const attachment = await prisma.attachment.findUnique({
where: { id },
});
if (!attachment) {
return NextResponse.json(
{ error: "Attachment not found" },
{ status: 404 }
);
}
// Delete file from disk
const filePath = join(process.cwd(), UPLOAD_DIR, attachment.filename);
try {
await unlink(filePath);
} catch {
// File might already be deleted, continue
}
// Delete database record
await prisma.attachment.delete({
where: { id },
});
return NextResponse.json({ success: true });
} catch (error) {
console.error("Failed to delete attachment:", error);
return NextResponse.json(
{ error: "Failed to delete attachment" },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,42 @@
import { NextRequest, NextResponse } from "next/server";
import { verifyPassword, createToken } from "@/lib/auth";
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { password } = body;
if (!password) {
return NextResponse.json(
{ error: "Password is required" },
{ status: 400 }
);
}
const isValid = await verifyPassword(password);
if (!isValid) {
return NextResponse.json(
{ error: "Invalid password" },
{ status: 401 }
);
}
const token = await createToken();
const response = NextResponse.json({ success: true });
response.cookies.set("auth-token", token, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
maxAge: 60 * 60 * 24 * 7, // 7 days
});
return response;
} catch {
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,12 @@
import { NextResponse } from "next/server";
export async function POST() {
const response = NextResponse.json({ success: true });
response.cookies.set("auth-token", "", {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
maxAge: 0,
});
return response;
}

View File

@@ -0,0 +1,25 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
interface Params {
params: Promise<{ id: string }>;
}
// DELETE a link
export async function DELETE(request: NextRequest, { params }: Params) {
try {
const { id } = await params;
await prisma.link.delete({
where: { id },
});
return NextResponse.json({ success: true });
} catch (error) {
console.error("Failed to delete link:", error);
return NextResponse.json(
{ error: "Failed to delete link" },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,58 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
interface Params {
params: Promise<{ id: string }>;
}
// GET links for a node
export async function GET(request: NextRequest, { params }: Params) {
try {
const { id } = await params;
const links = await prisma.link.findMany({
where: { nodeId: id },
orderBy: { createdAt: "desc" },
});
return NextResponse.json(links);
} catch (error) {
console.error("Failed to fetch links:", error);
return NextResponse.json(
{ error: "Failed to fetch links" },
{ status: 500 }
);
}
}
// POST add a link to a node
export async function POST(request: NextRequest, { params }: Params) {
try {
const { id: nodeId } = await params;
const body = await request.json();
const { url, label } = body;
if (!url) {
return NextResponse.json(
{ error: "URL is required" },
{ status: 400 }
);
}
const link = await prisma.link.create({
data: {
nodeId,
url,
label: label || null,
},
});
return NextResponse.json(link, { status: 201 });
} catch (error) {
console.error("Failed to create link:", error);
return NextResponse.json(
{ error: "Failed to create link" },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,76 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
interface Params {
params: Promise<{ id: string }>;
}
// GET node details
export async function GET(request: NextRequest, { params }: Params) {
try {
const { id } = await params;
const node = await prisma.node.findUnique({
where: { id },
include: {
attachments: true,
links: true,
},
});
if (!node) {
return NextResponse.json(
{ error: "Node not found" },
{ status: 404 }
);
}
return NextResponse.json(node);
} catch (error) {
console.error("Failed to fetch node:", error);
return NextResponse.json(
{ error: "Failed to fetch node" },
{ status: 500 }
);
}
}
// PATCH update node
export async function PATCH(request: NextRequest, { params }: Params) {
try {
const { id } = await params;
const body = await request.json();
const node = await prisma.node.update({
where: { id },
data: body,
});
return NextResponse.json(node);
} catch (error) {
console.error("Failed to update node:", error);
return NextResponse.json(
{ error: "Failed to update node" },
{ status: 500 }
);
}
}
// DELETE node
export async function DELETE(request: NextRequest, { params }: Params) {
try {
const { id } = await params;
await prisma.node.delete({
where: { id },
});
return NextResponse.json({ success: true });
} catch (error) {
console.error("Failed to delete node:", error);
return NextResponse.json(
{ error: "Failed to delete node" },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,121 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
interface Params {
params: Promise<{ id: string }>;
}
// GET nodes and edges for a project
export async function GET(request: NextRequest, { params }: Params) {
try {
const { id } = await params;
const [nodes, edges] = await Promise.all([
prisma.node.findMany({
where: { projectId: id },
include: {
attachments: true,
links: true,
},
}),
prisma.edge.findMany({
where: { projectId: id },
}),
]);
return NextResponse.json({ nodes, edges });
} catch (error) {
console.error("Failed to fetch graph:", error);
return NextResponse.json(
{ error: "Failed to fetch graph" },
{ status: 500 }
);
}
}
// POST save entire graph (nodes + edges)
export async function POST(request: NextRequest, { params }: Params) {
try {
const { id: projectId } = await params;
const body = await request.json();
const { nodes, edges } = body;
// Use a transaction to sync all nodes and edges
await prisma.$transaction(async (tx) => {
// Get existing nodes to determine which to update/create/delete
const existingNodes = await tx.node.findMany({
where: { projectId },
select: { id: true },
});
const existingNodeIds = new Set(existingNodes.map((n) => n.id));
const incomingNodeIds = new Set(nodes.map((n: { id: string }) => n.id));
// Delete nodes that are no longer in the graph
const nodesToDelete = [...existingNodeIds].filter(
(id) => !incomingNodeIds.has(id)
);
if (nodesToDelete.length > 0) {
await tx.node.deleteMany({
where: { id: { in: nodesToDelete } },
});
}
// Upsert nodes
for (const node of nodes) {
await tx.node.upsert({
where: { id: node.id },
create: {
id: node.id,
projectId,
type: node.type || "page",
title: node.data?.title || "Nowa strona",
content: node.data?.content || null,
notes: node.data?.notes || null,
status: node.data?.status || "draft",
positionX: node.position?.x || 0,
positionY: node.position?.y || 0,
width: node.width || null,
height: node.height || null,
},
update: {
type: node.type || "page",
title: node.data?.title || "Nowa strona",
content: node.data?.content || null,
notes: node.data?.notes || null,
status: node.data?.status || "draft",
positionX: node.position?.x || 0,
positionY: node.position?.y || 0,
width: node.width || null,
height: node.height || null,
},
});
}
// Delete all existing edges and recreate
await tx.edge.deleteMany({
where: { projectId },
});
// Create new edges
if (edges.length > 0) {
await tx.edge.createMany({
data: edges.map((edge: { id: string; source: string; target: string; type?: string }) => ({
id: edge.id,
projectId,
sourceId: edge.source,
targetId: edge.target,
type: edge.type || "default",
})),
});
}
});
return NextResponse.json({ success: true });
} catch (error) {
console.error("Failed to save graph:", error);
return NextResponse.json(
{ error: "Failed to save graph" },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,83 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
interface Params {
params: Promise<{ id: string }>;
}
// GET single project
export async function GET(request: NextRequest, { params }: Params) {
try {
const { id } = await params;
const project = await prisma.project.findUnique({
where: { id },
include: {
nodes: {
include: {
attachments: true,
links: true,
},
},
edges: true,
},
});
if (!project) {
return NextResponse.json(
{ error: "Project not found" },
{ status: 404 }
);
}
return NextResponse.json(project);
} catch (error) {
console.error("Failed to fetch project:", error);
return NextResponse.json(
{ error: "Failed to fetch project" },
{ status: 500 }
);
}
}
// PATCH update project
export async function PATCH(request: NextRequest, { params }: Params) {
try {
const { id } = await params;
const body = await request.json();
const { name, thumbnail } = body;
const project = await prisma.project.update({
where: { id },
data: {
...(name && { name: name.trim() }),
...(thumbnail !== undefined && { thumbnail }),
},
});
return NextResponse.json(project);
} catch (error) {
console.error("Failed to update project:", error);
return NextResponse.json(
{ error: "Failed to update project" },
{ status: 500 }
);
}
}
// DELETE project
export async function DELETE(request: NextRequest, { params }: Params) {
try {
const { id } = await params;
await prisma.project.delete({
where: { id },
});
return NextResponse.json({ success: true });
} catch (error) {
console.error("Failed to delete project:", error);
return NextResponse.json(
{ error: "Failed to delete project" },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,76 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
interface Params {
params: Promise<{ id: string }>;
}
// GET project settings
export async function GET(request: NextRequest, { params }: Params) {
try {
const { id } = await params;
const project = await prisma.project.findUnique({
where: { id },
select: {
id: true,
globalNotes: true,
fontPrimary: true,
fontSecondary: true,
colorPalette: true,
designNotes: true,
},
});
if (!project) {
return NextResponse.json(
{ error: "Project not found" },
{ status: 404 }
);
}
return NextResponse.json(project);
} catch (error) {
console.error("Failed to fetch project settings:", error);
return NextResponse.json(
{ error: "Failed to fetch settings" },
{ status: 500 }
);
}
}
// PATCH update project settings
export async function PATCH(request: NextRequest, { params }: Params) {
try {
const { id } = await params;
const body = await request.json();
const { globalNotes, fontPrimary, fontSecondary, colorPalette, designNotes } = body;
const project = await prisma.project.update({
where: { id },
data: {
...(globalNotes !== undefined && { globalNotes }),
...(fontPrimary !== undefined && { fontPrimary }),
...(fontSecondary !== undefined && { fontSecondary }),
...(colorPalette !== undefined && { colorPalette }),
...(designNotes !== undefined && { designNotes }),
},
select: {
id: true,
globalNotes: true,
fontPrimary: true,
fontSecondary: true,
colorPalette: true,
designNotes: true,
},
});
return NextResponse.json(project);
} catch (error) {
console.error("Failed to update project settings:", error);
return NextResponse.json(
{ error: "Failed to update settings" },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,55 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
// GET all projects
export async function GET() {
try {
const projects = await prisma.project.findMany({
orderBy: { updatedAt: "desc" },
include: {
_count: {
select: { nodes: true },
},
},
});
return NextResponse.json(projects);
} catch (error) {
console.error("Failed to fetch projects:", error);
return NextResponse.json(
{ error: "Failed to fetch projects" },
{ status: 500 }
);
}
}
// POST create new project
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { name } = body;
if (!name || typeof name !== "string") {
return NextResponse.json(
{ error: "Project name is required" },
{ status: 400 }
);
}
const project = await prisma.project.create({
data: { name: name.trim() },
include: {
_count: {
select: { nodes: true },
},
},
});
return NextResponse.json(project, { status: 201 });
} catch (error) {
console.error("Failed to create project:", error);
return NextResponse.json(
{ error: "Failed to create project" },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,62 @@
import { NextRequest, NextResponse } from "next/server";
import { writeFile, mkdir } from "fs/promises";
import { join } from "path";
import { prisma } from "@/lib/prisma";
import { randomUUID } from "crypto";
const UPLOAD_DIR = process.env.UPLOAD_DIR || "./uploads";
export async function POST(request: NextRequest) {
try {
const formData = await request.formData();
const file = formData.get("file") as File | null;
const nodeId = formData.get("nodeId") as string | null;
if (!file) {
return NextResponse.json(
{ error: "No file provided" },
{ status: 400 }
);
}
if (!nodeId) {
return NextResponse.json(
{ error: "Node ID is required" },
{ status: 400 }
);
}
// Ensure upload directory exists
const uploadPath = join(process.cwd(), UPLOAD_DIR);
await mkdir(uploadPath, { recursive: true });
// Generate unique filename
const ext = file.name.split(".").pop() || "bin";
const filename = `${randomUUID()}.${ext}`;
const filePath = join(uploadPath, filename);
// Write file to disk
const bytes = await file.arrayBuffer();
const buffer = Buffer.from(bytes);
await writeFile(filePath, buffer);
// Save attachment record in database
const attachment = await prisma.attachment.create({
data: {
nodeId,
filename,
originalName: file.name,
mimeType: file.type || "application/octet-stream",
size: file.size,
},
});
return NextResponse.json(attachment, { status: 201 });
} catch (error) {
console.error("Failed to upload file:", error);
return NextResponse.json(
{ error: "Failed to upload file" },
{ status: 500 }
);
}
}

427
src/app/dashboard/page.tsx Normal file
View File

@@ -0,0 +1,427 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Label } from "@/components/ui/label";
interface Project {
id: string;
name: string;
thumbnail: string | null;
createdAt: string;
updatedAt: string;
_count?: {
nodes: number;
};
}
export default function DashboardPage() {
const [projects, setProjects] = useState<Project[]>([]);
const [loading, setLoading] = useState(true);
const [newProjectName, setNewProjectName] = useState("");
const [createDialogOpen, setCreateDialogOpen] = useState(false);
const [editDialogOpen, setEditDialogOpen] = useState(false);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [selectedProject, setSelectedProject] = useState<Project | null>(null);
const [editName, setEditName] = useState("");
const router = useRouter();
const fetchProjects = useCallback(async () => {
try {
const res = await fetch("/api/projects");
if (res.ok) {
const data = await res.json();
setProjects(data);
}
} catch (error) {
console.error("Failed to fetch projects:", error);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchProjects();
}, [fetchProjects]);
const handleCreateProject = async () => {
if (!newProjectName.trim()) return;
try {
const res = await fetch("/api/projects", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: newProjectName }),
});
if (res.ok) {
const newProject = await res.json();
setProjects((prev) => [newProject, ...prev]);
setNewProjectName("");
setCreateDialogOpen(false);
router.push(`/project/${newProject.id}`);
}
} catch (error) {
console.error("Failed to create project:", error);
}
};
const handleEditProject = async () => {
if (!selectedProject || !editName.trim()) return;
try {
const res = await fetch(`/api/projects/${selectedProject.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: editName }),
});
if (res.ok) {
const updatedProject = await res.json();
setProjects((prev) =>
prev.map((p) => (p.id === updatedProject.id ? { ...p, ...updatedProject } : p))
);
setEditDialogOpen(false);
setSelectedProject(null);
}
} catch (error) {
console.error("Failed to update project:", error);
}
};
const handleDeleteProject = async () => {
if (!selectedProject) return;
try {
const res = await fetch(`/api/projects/${selectedProject.id}`, {
method: "DELETE",
});
if (res.ok) {
setProjects((prev) => prev.filter((p) => p.id !== selectedProject.id));
setDeleteDialogOpen(false);
setSelectedProject(null);
}
} catch (error) {
console.error("Failed to delete project:", error);
}
};
const handleLogout = async () => {
await fetch("/api/auth/logout", { method: "POST" });
router.push("/login");
};
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString("pl-PL", {
day: "numeric",
month: "long",
year: "numeric",
});
};
return (
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900">
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_top,_var(--tw-gradient-stops))] from-blue-900/20 via-transparent to-transparent pointer-events-none" />
{/* Header */}
<header className="relative border-b border-slate-700/50 bg-slate-800/30 backdrop-blur-xl">
<div className="max-w-7xl mx-auto px-6 py-4 flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-gradient-to-br from-blue-500 to-purple-600 rounded-xl flex items-center justify-center shadow-lg">
<svg className="w-5 h-5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 17V7m0 10a2 2 0 01-2 2H5a2 2 0 01-2-2V7a2 2 0 012-2h2a2 2 0 012 2m0 10a2 2 0 002 2h2a2 2 0 002-2M9 7a2 2 0 012-2h2a2 2 0 012 2m0 10V7m0 10a2 2 0 002 2h2a2 2 0 002-2V7a2 2 0 00-2-2h-2a2 2 0 00-2 2" />
</svg>
</div>
<h1 className="text-xl font-bold text-white">Sitemap Builder</h1>
</div>
<Button
variant="ghost"
onClick={handleLogout}
className="text-slate-400 hover:text-white hover:bg-slate-700/50"
>
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
</svg>
Wyloguj
</Button>
</div>
</header>
{/* Main Content */}
<main className="relative max-w-7xl mx-auto px-6 py-8">
<div className="flex items-center justify-between mb-8">
<div>
<h2 className="text-2xl font-bold text-white">Projekty</h2>
<p className="text-slate-400 mt-1">
{projects.length} {projects.length === 1 ? "projekt" : "projektów"}
</p>
</div>
<Button
onClick={() => setCreateDialogOpen(true)}
className="bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-500 hover:to-purple-500 text-white shadow-lg shadow-blue-500/25"
>
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
Nowy projekt
</Button>
</div>
{/* Projects Grid */}
{loading ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{[1, 2, 3].map((i) => (
<Card key={i} className="bg-slate-800/50 border-slate-700 animate-pulse">
<CardHeader>
<div className="h-6 bg-slate-700 rounded w-3/4" />
</CardHeader>
<CardContent>
<div className="h-32 bg-slate-700/50 rounded" />
</CardContent>
<CardFooter>
<div className="h-4 bg-slate-700 rounded w-1/2" />
</CardFooter>
</Card>
))}
</div>
) : projects.length === 0 ? (
<Card className="bg-slate-800/30 border-slate-700 border-dashed">
<CardContent className="flex flex-col items-center justify-center py-16">
<div className="w-16 h-16 bg-slate-700/50 rounded-2xl flex items-center justify-center mb-4">
<svg className="w-8 h-8 text-slate-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
</div>
<h3 className="text-lg font-semibold text-white mb-2">Brak projektów</h3>
<p className="text-slate-400 text-center mb-6">
Utwórz swój pierwszy projekt, aby rozpocząć planowanie sitemapy
</p>
<Button
onClick={() => setCreateDialogOpen(true)}
className="bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-500 hover:to-purple-500"
>
Utwórz pierwszy projekt
</Button>
</CardContent>
</Card>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{projects.map((project) => (
<Card
key={project.id}
className="group bg-slate-800/50 border-slate-700 hover:border-slate-600 hover:bg-slate-800/70 transition-all duration-200 cursor-pointer"
onClick={() => router.push(`/project/${project.id}`)}
>
<CardHeader className="flex flex-row items-start justify-between space-y-0">
<CardTitle className="text-lg font-semibold text-white group-hover:text-blue-400 transition-colors">
{project.name}
</CardTitle>
<DropdownMenu>
<DropdownMenuTrigger asChild onClick={(e) => e.stopPropagation()}>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0 text-slate-400 hover:text-white opacity-0 group-hover:opacity-100 transition-opacity"
>
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 8c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm0 2c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm0 6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z" />
</svg>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="bg-slate-800 border-slate-700"
onClick={(e) => e.stopPropagation()}
>
<DropdownMenuItem
onClick={() => {
setSelectedProject(project);
setEditName(project.name);
setEditDialogOpen(true);
}}
className="text-slate-300 focus:text-white focus:bg-slate-700"
>
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
Zmień nazwę
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
setSelectedProject(project);
setDeleteDialogOpen(true);
}}
className="text-red-400 focus:text-red-300 focus:bg-red-500/10"
>
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
Usuń
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</CardHeader>
<CardContent>
<div className="h-32 bg-slate-700/30 rounded-lg flex items-center justify-center border border-slate-700/50">
{project.thumbnail ? (
<img
src={project.thumbnail}
alt={project.name}
className="w-full h-full object-cover rounded-lg"
/>
) : (
<div className="text-center">
<svg className="w-12 h-12 mx-auto text-slate-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9 17V7m0 10a2 2 0 01-2 2H5a2 2 0 01-2-2V7a2 2 0 012-2h2a2 2 0 012 2m0 10a2 2 0 002 2h2a2 2 0 002-2M9 7a2 2 0 012-2h2a2 2 0 012 2m0 10V7m0 10a2 2 0 002 2h2a2 2 0 002-2V7a2 2 0 00-2-2h-2a2 2 0 00-2 2" />
</svg>
<p className="text-sm text-slate-500 mt-2">{project._count?.nodes ?? 0} węzłów</p>
</div>
)}
</div>
</CardContent>
<CardFooter className="text-sm text-slate-500">
Utworzono {formatDate(project.createdAt)}
</CardFooter>
</Card>
))}
</div>
)}
</main>
{/* Create Project Dialog */}
<Dialog open={createDialogOpen} onOpenChange={setCreateDialogOpen}>
<DialogContent className="bg-slate-800 border-slate-700">
<DialogHeader>
<DialogTitle className="text-white">Nowy projekt</DialogTitle>
<DialogDescription className="text-slate-400">
Wprowadź nazwę dla nowego projektu sitemapy
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="name" className="text-slate-300">
Nazwa projektu
</Label>
<Input
id="name"
value={newProjectName}
onChange={(e) => setNewProjectName(e.target.value)}
placeholder="np. Strona korporacyjna XYZ"
className="bg-slate-700/50 border-slate-600 text-white"
onKeyDown={(e) => e.key === "Enter" && handleCreateProject()}
autoFocus
/>
</div>
</div>
<DialogFooter>
<Button
variant="ghost"
onClick={() => setCreateDialogOpen(false)}
className="text-slate-400 hover:text-white"
>
Anuluj
</Button>
<Button
onClick={handleCreateProject}
disabled={!newProjectName.trim()}
className="bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-500 hover:to-purple-500"
>
Utwórz
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Edit Project Dialog */}
<Dialog open={editDialogOpen} onOpenChange={setEditDialogOpen}>
<DialogContent className="bg-slate-800 border-slate-700">
<DialogHeader>
<DialogTitle className="text-white">Zmień nazwę projektu</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="edit-name" className="text-slate-300">
Nowa nazwa
</Label>
<Input
id="edit-name"
value={editName}
onChange={(e) => setEditName(e.target.value)}
className="bg-slate-700/50 border-slate-600 text-white"
onKeyDown={(e) => e.key === "Enter" && handleEditProject()}
autoFocus
/>
</div>
</div>
<DialogFooter>
<Button
variant="ghost"
onClick={() => setEditDialogOpen(false)}
className="text-slate-400 hover:text-white"
>
Anuluj
</Button>
<Button
onClick={handleEditProject}
disabled={!editName.trim()}
className="bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-500 hover:to-purple-500"
>
Zapisz
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Delete Confirmation Dialog */}
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<DialogContent className="bg-slate-800 border-slate-700">
<DialogHeader>
<DialogTitle className="text-white">Usuń projekt</DialogTitle>
<DialogDescription className="text-slate-400">
Czy na pewno chcesz usunąć projekt &quot;{selectedProject?.name}&quot;? Ta operacja jest
nieodwracalna.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="ghost"
onClick={() => setDeleteDialogOpen(false)}
className="text-slate-400 hover:text-white"
>
Anuluj
</Button>
<Button
variant="destructive"
onClick={handleDeleteProject}
className="bg-red-600 hover:bg-red-500"
>
Usuń
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -1,26 +1,125 @@
@import "tailwindcss"; @import "tailwindcss";
@import "tw-animate-css";
:root { @custom-variant dark (&:is(.dark *));
--background: #ffffff;
--foreground: #171717;
}
@theme inline { @theme inline {
--color-background: var(--background); --color-background: var(--background);
--color-foreground: var(--foreground); --color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans); --font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono); --font-mono: var(--font-geist-mono);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--radius-2xl: calc(var(--radius) + 8px);
--radius-3xl: calc(var(--radius) + 12px);
--radius-4xl: calc(var(--radius) + 16px);
} }
@media (prefers-color-scheme: dark) { :root {
:root { --radius: 0.625rem;
--background: #0a0a0a; --background: oklch(1 0 0);
--foreground: #ededed; --foreground: oklch(0.141 0.005 285.823);
--card: oklch(1 0 0);
--card-foreground: oklch(0.141 0.005 285.823);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.141 0.005 285.823);
--primary: oklch(0.21 0.006 285.885);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.967 0.001 286.375);
--secondary-foreground: oklch(0.21 0.006 285.885);
--muted: oklch(0.967 0.001 286.375);
--muted-foreground: oklch(0.552 0.016 285.938);
--accent: oklch(0.967 0.001 286.375);
--accent-foreground: oklch(0.21 0.006 285.885);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.92 0.004 286.32);
--input: oklch(0.92 0.004 286.32);
--ring: oklch(0.705 0.015 286.067);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.141 0.005 285.823);
--sidebar-primary: oklch(0.21 0.006 285.885);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.967 0.001 286.375);
--sidebar-accent-foreground: oklch(0.21 0.006 285.885);
--sidebar-border: oklch(0.92 0.004 286.32);
--sidebar-ring: oklch(0.705 0.015 286.067);
}
.dark {
--background: oklch(0.141 0.005 285.823);
--foreground: oklch(0.985 0 0);
--card: oklch(0.21 0.006 285.885);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.21 0.006 285.885);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.92 0.004 286.32);
--primary-foreground: oklch(0.21 0.006 285.885);
--secondary: oklch(0.274 0.006 286.033);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.274 0.006 286.033);
--muted-foreground: oklch(0.705 0.015 286.067);
--accent: oklch(0.274 0.006 286.033);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.552 0.016 285.938);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.21 0.006 285.885);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.274 0.006 286.033);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.552 0.016 285.938);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
} }
} }
body {
background: var(--background);
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
}

View File

@@ -13,8 +13,8 @@ const geistMono = Geist_Mono({
}); });
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Create Next App", title: "Sitemap Builder",
description: "Generated by create next app", description: "Narzędzie do planowania sitemap i zbierania materiałów od klientów",
}; };
export default function RootLayout({ export default function RootLayout({
@@ -23,9 +23,10 @@ export default function RootLayout({
children: React.ReactNode; children: React.ReactNode;
}>) { }>) {
return ( return (
<html lang="en"> <html lang="pl" suppressHydrationWarning>
<body <body
className={`${geistSans.variable} ${geistMono.variable} antialiased`} className={`${geistSans.variable} ${geistMono.variable} antialiased`}
suppressHydrationWarning
> >
{children} {children}
</body> </body>

131
src/app/login/page.tsx Normal file
View File

@@ -0,0 +1,131 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
export default function LoginPage() {
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const router = useRouter();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError("");
setLoading(true);
try {
const res = await fetch("/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ password }),
});
if (res.ok) {
router.push("/dashboard");
router.refresh();
} else {
const data = await res.json();
setError(data.error || "Login failed");
}
} catch {
setError("Connection error");
} finally {
setLoading(false);
}
};
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900">
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_top,_var(--tw-gradient-stops))] from-blue-900/20 via-transparent to-transparent" />
<Card className="w-full max-w-md mx-4 bg-slate-800/50 backdrop-blur-xl border-slate-700 shadow-2xl">
<CardHeader className="text-center space-y-2">
<div className="mx-auto w-16 h-16 bg-gradient-to-br from-blue-500 to-purple-600 rounded-2xl flex items-center justify-center mb-4 shadow-lg">
<svg
className="w-8 h-8 text-white"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 17V7m0 10a2 2 0 01-2 2H5a2 2 0 01-2-2V7a2 2 0 012-2h2a2 2 0 012 2m0 10a2 2 0 002 2h2a2 2 0 002-2M9 7a2 2 0 012-2h2a2 2 0 012 2m0 10V7m0 10a2 2 0 002 2h2a2 2 0 002-2V7a2 2 0 00-2-2h-2a2 2 0 00-2 2"
/>
</svg>
</div>
<CardTitle className="text-2xl font-bold text-white">
Sitemap Builder
</CardTitle>
<CardDescription className="text-slate-400">
Wprowadź hasło dostępu, aby kontynuować
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-6">
<div className="space-y-2">
<Label htmlFor="password" className="text-slate-300">
Hasło
</Label>
<Input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••"
className="bg-slate-700/50 border-slate-600 text-white placeholder:text-slate-500 focus:border-blue-500 focus:ring-blue-500/20"
autoFocus
/>
</div>
{error && (
<div className="p-3 bg-red-500/10 border border-red-500/20 rounded-lg">
<p className="text-sm text-red-400 text-center">{error}</p>
</div>
)}
<Button
type="submit"
disabled={loading || !password}
className="w-full bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-500 hover:to-purple-500 text-white font-medium py-2.5 shadow-lg shadow-blue-500/25 transition-all duration-200"
>
{loading ? (
<span className="flex items-center gap-2">
<svg
className="animate-spin h-4 w-4"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
Logowanie...
</span>
) : (
"Zaloguj się"
)}
</Button>
</form>
</CardContent>
</Card>
</div>
);
}

View File

@@ -1,65 +1,6 @@
import Image from "next/image"; import { redirect } from "next/navigation";
export default function Home() { export default function Home() {
return ( redirect("/dashboard");
<div className="flex min-h-screen items-center justify-center bg-zinc-50 font-sans dark:bg-black">
<main className="flex min-h-screen w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
<Image
className="dark:invert"
src="/next.svg"
alt="Next.js logo"
width={100}
height={20}
priority
/>
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
To get started, edit the page.tsx file.
</h1>
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
Looking for a starting point or more instructions? Head over to{" "}
<a
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Templates
</a>{" "}
or the{" "}
<a
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Learning
</a>{" "}
center.
</p>
</div>
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
<a
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
className="dark:invert"
src="/vercel.svg"
alt="Vercel logomark"
width={16}
height={16}
/>
Deploy Now
</a>
<a
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Documentation
</a>
</div>
</main>
</div>
);
} }

View File

@@ -0,0 +1,208 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { Node, Edge } from "@xyflow/react";
import { Button } from "@/components/ui/button";
import FlowCanvas from "@/components/flow/FlowCanvas";
import { PageNodeData } from "@/components/flow/CustomNode";
import ProjectSettingsDrawer from "@/components/flow/ProjectSettingsDrawer";
import { use } from "react";
interface ProjectPageProps {
params: Promise<{ id: string }>;
}
interface ProjectData {
id: string;
name: string;
}
interface DbNode {
id: string;
type: string;
title: string;
content: string | null;
notes: string | null;
status: string;
positionX: number;
positionY: number;
attachments: { id: string }[];
links: { id: string }[];
}
interface DbEdge {
id: string;
sourceId: string;
targetId: string;
type: string;
}
export default function ProjectPage({ params }: ProjectPageProps) {
const { id } = use(params);
const [project, setProject] = useState<ProjectData | null>(null);
const [initialNodes, setInitialNodes] = useState<Node<PageNodeData>[]>([]);
const [initialEdges, setInitialEdges] = useState<Edge[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [settingsOpen, setSettingsOpen] = useState(false);
const router = useRouter();
useEffect(() => {
async function loadProject() {
try {
const projectRes = await fetch(`/api/projects/${id}`);
if (!projectRes.ok) {
throw new Error("Failed to fetch project");
}
const projectData = await projectRes.json();
setProject(projectData);
const graphRes = await fetch(`/api/projects/${id}/graph`);
if (!graphRes.ok) {
throw new Error("Failed to fetch graph");
}
const { nodes, edges } = await graphRes.json();
const flowNodes: Node<PageNodeData>[] = nodes.map((node: DbNode) => ({
id: node.id,
type: node.type,
position: { x: node.positionX, y: node.positionY },
data: {
title: node.title,
status: node.status as "draft" | "ready" | "review",
content: node.content || "",
notes: node.notes || "",
hasAttachments: node.attachments?.length > 0,
hasLinks: node.links?.length > 0,
},
}));
const flowEdges: Edge[] = edges.map((edge: DbEdge) => ({
id: edge.id,
source: edge.sourceId,
target: edge.targetId,
type: edge.type,
}));
setInitialNodes(flowNodes);
setInitialEdges(flowEdges);
} catch (err) {
console.error("Failed to load project:", err);
setError("Nie udało się załadować projektu");
} finally {
setLoading(false);
}
}
loadProject();
}, [id]);
const handleSave = useCallback(
async (nodes: Node[], edges: Edge[]) => {
try {
await fetch(`/api/projects/${id}/graph`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ nodes, edges }),
});
} catch (err) {
console.error("Failed to save:", err);
}
},
[id]
);
if (loading) {
return (
<div className="min-h-screen bg-slate-900 flex items-center justify-center">
<div className="flex items-center gap-3 text-slate-400">
<svg className="animate-spin h-6 w-6" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
<span>Ładowanie projektu...</span>
</div>
</div>
);
}
if (error) {
return (
<div className="min-h-screen bg-slate-900 flex items-center justify-center">
<div className="text-center">
<p className="text-red-400 mb-4">{error}</p>
<Button onClick={() => router.push("/dashboard")}>
Powrót do dashboardu
</Button>
</div>
</div>
);
}
return (
<div className="h-screen flex flex-col bg-slate-900">
{/* Header */}
<header className="flex-shrink-0 border-b border-slate-700/50 bg-slate-800/50 backdrop-blur-xl">
<div className="px-4 py-3 flex items-center justify-between">
<div className="flex items-center gap-4">
<Button
variant="ghost"
size="sm"
onClick={() => router.push("/dashboard")}
className="text-slate-400 hover:text-white"
>
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
Powrót
</Button>
<div className="h-4 w-px bg-slate-700" />
<h1 className="text-lg font-semibold text-white">
{project?.name}
</h1>
</div>
<div className="flex items-center gap-3">
<div className="text-xs text-slate-500 flex items-center gap-2">
<div className="w-2 h-2 bg-green-500 rounded-full animate-pulse" />
Auto-save
</div>
{/* Settings Button */}
<Button
variant="ghost"
size="sm"
onClick={() => setSettingsOpen(true)}
className="text-slate-400 hover:text-white hover:bg-slate-700"
>
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
Ustawienia
</Button>
</div>
</div>
</header>
{/* Canvas */}
<main className="flex-1 relative overflow-hidden">
<FlowCanvas
projectId={id}
initialNodes={initialNodes}
initialEdges={initialEdges}
onSave={handleSave}
/>
</main>
{/* Settings Drawer */}
<ProjectSettingsDrawer
open={settingsOpen}
onOpenChange={setSettingsOpen}
projectId={id}
/>
</div>
);
}

View File

@@ -0,0 +1,134 @@
"use client";
import { useEditor, EditorContent } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import Placeholder from "@tiptap/extension-placeholder";
import { useEffect } from "react";
interface RichTextEditorProps {
content: string;
onChange: (content: string) => void;
placeholder?: string;
}
export default function RichTextEditor({
content,
onChange,
placeholder,
}: RichTextEditorProps) {
const editor = useEditor({
extensions: [
StarterKit,
Placeholder.configure({
placeholder: placeholder || "Zacznij pisać...",
emptyEditorClass: "is-editor-empty",
}),
],
content,
immediatelyRender: false,
editorProps: {
attributes: {
class:
"prose prose-invert prose-sm max-w-none min-h-[150px] p-4 focus:outline-none",
},
},
onUpdate: ({ editor }) => {
onChange(editor.getHTML());
},
});
// Sync content from props
useEffect(() => {
if (editor && content !== editor.getHTML()) {
editor.commands.setContent(content);
}
}, [content, editor]);
if (!editor) {
return (
<div className="min-h-[150px] bg-slate-700/50 border border-slate-600 rounded-lg animate-pulse" />
);
}
return (
<div className="bg-slate-700/50 border border-slate-600 rounded-lg overflow-hidden">
{/* Toolbar */}
<div className="flex items-center gap-1 p-2 border-b border-slate-600 bg-slate-700/30">
<button
onClick={() => editor.chain().focus().toggleBold().run()}
className={`p-1.5 rounded hover:bg-slate-600 transition-colors ${editor.isActive("bold") ? "bg-slate-600 text-white" : "text-slate-400"
}`}
title="Pogrubienie"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 12h8a4 4 0 100-8H6v8zm0 0h9a4 4 0 110 8H6v-8z" />
</svg>
</button>
<button
onClick={() => editor.chain().focus().toggleItalic().run()}
className={`p-1.5 rounded hover:bg-slate-600 transition-colors ${editor.isActive("italic") ? "bg-slate-600 text-white" : "text-slate-400"
}`}
title="Kursywa"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 20l4-16m4 4l-4 4 4 4M6 16l4-4-4-4" />
</svg>
</button>
<button
onClick={() => editor.chain().focus().toggleStrike().run()}
className={`p-1.5 rounded hover:bg-slate-600 transition-colors ${editor.isActive("strike") ? "bg-slate-600 text-white" : "text-slate-400"
}`}
title="Przekreślenie"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 18c-.77 1.333.192 3 1.732 3z" />
</svg>
</button>
<div className="w-px h-5 bg-slate-600 mx-1" />
<button
onClick={() => editor.chain().focus().toggleBulletList().run()}
className={`p-1.5 rounded hover:bg-slate-600 transition-colors ${editor.isActive("bulletList") ? "bg-slate-600 text-white" : "text-slate-400"
}`}
title="Lista punktowana"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 10h16M4 14h16M4 18h16" />
</svg>
</button>
<button
onClick={() => editor.chain().focus().toggleOrderedList().run()}
className={`p-1.5 rounded hover:bg-slate-600 transition-colors ${editor.isActive("orderedList") ? "bg-slate-600 text-white" : "text-slate-400"
}`}
title="Lista numerowana"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 20l4-16m2 16l4-16M6 9h14M4 15h14" />
</svg>
</button>
<div className="w-px h-5 bg-slate-600 mx-1" />
<button
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
className={`p-1.5 rounded hover:bg-slate-600 transition-colors ${editor.isActive("heading", { level: 2 }) ? "bg-slate-600 text-white" : "text-slate-400"
}`}
title="Nagłówek"
>
<span className="text-sm font-bold">H2</span>
</button>
<button
onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}
className={`p-1.5 rounded hover:bg-slate-600 transition-colors ${editor.isActive("heading", { level: 3 }) ? "bg-slate-600 text-white" : "text-slate-400"
}`}
title="Podtytuł"
>
<span className="text-sm font-bold">H3</span>
</button>
</div>
{/* Editor */}
<EditorContent
editor={editor}
className="text-white [&_.is-editor-empty:first-child::before]:text-slate-500 [&_.is-editor-empty:first-child::before]:content-[attr(data-placeholder)] [&_.is-editor-empty:first-child::before]:float-left [&_.is-editor-empty:first-child::before]:h-0 [&_.is-editor-empty:first-child::before]:pointer-events-none"
/>
</div>
);
}

View File

@@ -0,0 +1,149 @@
"use client";
import { memo, useMemo } from "react";
import { Handle, Position, NodeProps } from "@xyflow/react";
import { Badge } from "@/components/ui/badge";
export interface PageNodeData {
title: string;
status: "draft" | "ready" | "review";
content?: string;
notes?: string;
hasAttachments?: boolean;
hasLinks?: boolean;
[key: string]: unknown;
}
const statusConfig = {
draft: {
label: "Szkic",
className: "bg-yellow-500/20 text-yellow-400 border-yellow-500/30",
},
ready: {
label: "Gotowe",
className: "bg-green-500/20 text-green-400 border-green-500/30",
},
review: {
label: "Do poprawki",
className: "bg-red-500/20 text-red-400 border-red-500/30",
},
};
// Different styles for node types
const nodeTypeConfig = {
page: {
bgClass: "bg-slate-800/90",
borderClass: "border-slate-600",
selectedBorderClass: "border-blue-500",
accentColor: "blue",
icon: (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
),
label: "Strona",
},
note: {
bgClass: "bg-amber-900/40",
borderClass: "border-amber-600/50",
selectedBorderClass: "border-amber-400",
accentColor: "amber",
icon: (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
),
label: "Notatka",
},
section: {
bgClass: "bg-purple-900/40",
borderClass: "border-purple-600/50",
selectedBorderClass: "border-purple-400",
accentColor: "purple",
icon: (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
),
label: "Sekcja",
},
};
function CustomNode({ data, selected, type }: NodeProps) {
const nodeData = data as PageNodeData;
const status = statusConfig[nodeData.status] || statusConfig.draft;
const nodeType = nodeTypeConfig[type as keyof typeof nodeTypeConfig] || nodeTypeConfig.page;
const hasContent = useMemo(() => {
return nodeData.hasAttachments || nodeData.hasLinks;
}, [nodeData.hasAttachments, nodeData.hasLinks]);
const handleColorClass = nodeType.accentColor === "amber"
? "!bg-amber-500"
: nodeType.accentColor === "purple"
? "!bg-purple-500"
: "!bg-blue-500";
return (
<div
className={`
${nodeType.bgClass} backdrop-blur-sm border-2 rounded-xl shadow-xl
min-w-[180px] max-w-[240px] transition-all duration-200
${selected ? `${nodeType.selectedBorderClass} shadow-${nodeType.accentColor}-500/25` : `${nodeType.borderClass} hover:border-opacity-80`}
`}
>
{/* Top Handle */}
<Handle
type="target"
position={Position.Top}
className={`!w-3 !h-3 ${handleColorClass} !border-2 !border-slate-800`}
/>
{/* Type indicator */}
<div className={`px-3 py-1.5 border-b border-white/10 flex items-center gap-2 text-${nodeType.accentColor}-400`}>
{nodeType.icon}
<span className="text-xs font-medium opacity-70">{nodeType.label}</span>
</div>
{/* Content */}
<div className="p-4">
<div className="flex items-start justify-between gap-2 mb-2">
<h3 className="font-semibold text-white text-sm leading-tight line-clamp-2">
{nodeData.title || "Nowa strona"}
</h3>
{hasContent && (
<div className="flex-shrink-0">
<svg
className={`w-4 h-4 text-${nodeType.accentColor}-400`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15.172 7l-6.586 6.586a2 2 0 102.828 2.828l6.414-6.586a4 4 0 00-5.656-5.656l-6.415 6.585a6 6 0 108.486 8.486L20.5 13"
/>
</svg>
</div>
)}
</div>
<Badge variant="outline" className={`text-xs ${status.className}`}>
{status.label}
</Badge>
</div>
{/* Bottom Handle */}
<Handle
type="source"
position={Position.Bottom}
className={`!w-3 !h-3 ${handleColorClass} !border-2 !border-slate-800`}
/>
</div>
);
}
export default memo(CustomNode);

View File

@@ -0,0 +1,261 @@
"use client";
import {
useCallback,
useRef,
useState,
DragEvent,
} from "react";
import {
ReactFlow,
Background,
Controls,
MiniMap,
addEdge,
useNodesState,
useEdgesState,
Connection,
Edge,
Node,
ReactFlowProvider,
useReactFlow,
} from "@xyflow/react";
import "@xyflow/react/dist/style.css";
import CustomNode, { PageNodeData } from "./CustomNode";
import NodeSidebar from "./NodeSidebar";
import NodeDrawer from "./NodeDrawer";
const nodeTypes = {
page: CustomNode,
note: CustomNode,
section: CustomNode,
};
interface FlowCanvasProps {
projectId: string;
initialNodes: Node[];
initialEdges: Edge[];
onSave: (nodes: Node[], edges: Edge[]) => Promise<void>;
}
function FlowCanvasInner({
projectId,
initialNodes,
initialEdges,
onSave,
}: FlowCanvasProps) {
const reactFlowWrapper = useRef<HTMLDivElement>(null);
const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes);
const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges);
const [selectedNode, setSelectedNode] = useState<Node | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false);
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const { screenToFlowPosition } = useReactFlow();
const saveTimeoutRef = useRef<NodeJS.Timeout | null>(null);
// Auto-save with debounce
const triggerAutoSave = useCallback(() => {
if (saveTimeoutRef.current) {
clearTimeout(saveTimeoutRef.current);
}
saveTimeoutRef.current = setTimeout(async () => {
setIsSaving(true);
try {
await onSave(nodes, edges);
} finally {
setIsSaving(false);
}
}, 1500);
}, [nodes, edges, onSave]);
// Trigger auto-save when nodes or edges change
const handleNodesChange = useCallback(
(changes: Parameters<typeof onNodesChange>[0]) => {
onNodesChange(changes);
triggerAutoSave();
},
[onNodesChange, triggerAutoSave]
);
const handleEdgesChange = useCallback(
(changes: Parameters<typeof onEdgesChange>[0]) => {
onEdgesChange(changes);
triggerAutoSave();
},
[onEdgesChange, triggerAutoSave]
);
const onConnect = useCallback(
(params: Connection) => {
setEdges((eds) => addEdge({ ...params, type: "default" }, eds));
triggerAutoSave();
},
[setEdges, triggerAutoSave]
);
const onDragOver = useCallback((event: DragEvent) => {
event.preventDefault();
event.dataTransfer.dropEffect = "move";
}, []);
const onDrop = useCallback(
(event: DragEvent) => {
event.preventDefault();
const type = event.dataTransfer.getData("application/reactflow");
if (!type) return;
const position = screenToFlowPosition({
x: event.clientX,
y: event.clientY,
});
const newNode: Node<PageNodeData> = {
id: `node-${Date.now()}`,
type,
position,
data: {
title: type === "page" ? "Nowa strona" : type === "note" ? "Notatka" : "Sekcja",
status: "draft",
hasAttachments: false,
hasLinks: false,
},
};
setNodes((nds) => nds.concat(newNode));
triggerAutoSave();
},
[screenToFlowPosition, setNodes, triggerAutoSave]
);
const onNodeClick = useCallback((event: React.MouseEvent, node: Node) => {
event.stopPropagation();
setSelectedNode(node);
setDrawerOpen(true);
}, []);
const handleNodeUpdate = useCallback(
(nodeId: string, data: Partial<PageNodeData>) => {
setNodes((nds) =>
nds.map((node) =>
node.id === nodeId
? { ...node, data: { ...node.data, ...data } }
: node
)
);
triggerAutoSave();
},
[setNodes, triggerAutoSave]
);
const handleNodeDelete = useCallback(
(nodeId: string) => {
setNodes((nds) => nds.filter((node) => node.id !== nodeId));
setEdges((eds) =>
eds.filter((edge) => edge.source !== nodeId && edge.target !== nodeId)
);
setDrawerOpen(false);
setSelectedNode(null);
triggerAutoSave();
},
[setNodes, setEdges, triggerAutoSave]
);
return (
<div className="w-full h-full relative" ref={reactFlowWrapper}>
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={handleNodesChange}
onEdgesChange={handleEdgesChange}
onConnect={onConnect}
onDrop={onDrop}
onDragOver={onDragOver}
onNodeClick={onNodeClick}
nodeTypes={nodeTypes}
fitView
className="bg-slate-900"
defaultEdgeOptions={{
style: { stroke: "#64748b", strokeWidth: 2 },
type: "smoothstep",
}}
proOptions={{ hideAttribution: true }}
>
<Background color="#334155" gap={20} size={1} />
<Controls className="!bg-slate-800 !border-slate-700 !shadow-xl [&>button]:!bg-slate-700 [&>button]:!border-slate-600 [&>button]:!text-slate-300 [&>button:hover]:!bg-slate-600" />
<MiniMap
className="!bg-slate-800 !border-slate-700"
nodeColor="#3b82f6"
maskColor="rgba(15, 23, 42, 0.8)"
/>
</ReactFlow>
{/* Node Sidebar */}
<NodeSidebar
isCollapsed={sidebarCollapsed}
onToggle={() => setSidebarCollapsed(!sidebarCollapsed)}
/>
{/* Save Indicator */}
<div className="absolute bottom-4 left-4 z-10">
<div
className={`
px-3 py-1.5 rounded-lg text-xs font-medium transition-all duration-300
${isSaving
? "bg-blue-500/20 text-blue-400 border border-blue-500/30"
: "bg-green-500/20 text-green-400 border border-green-500/30"
}
`}
>
{isSaving ? (
<span className="flex items-center gap-2">
<svg className="animate-spin h-3 w-3" viewBox="0 0 24 24">
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
fill="none"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
Zapisywanie...
</span>
) : (
<span className="flex items-center gap-2">
<svg className="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
Zapisano
</span>
)}
</div>
</div>
{/* Node Detail Drawer */}
<NodeDrawer
open={drawerOpen}
onOpenChange={setDrawerOpen}
node={selectedNode}
projectId={projectId}
onUpdate={handleNodeUpdate}
onDelete={handleNodeDelete}
/>
</div>
);
}
export default function FlowCanvas(props: FlowCanvasProps) {
return (
<ReactFlowProvider>
<FlowCanvasInner {...props} />
</ReactFlowProvider>
);
}

View File

@@ -0,0 +1,436 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { Node } from "@xyflow/react";
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { Badge } from "@/components/ui/badge";
import { Separator } from "@/components/ui/separator";
import { ScrollArea } from "@/components/ui/scroll-area";
import RichTextEditor from "@/components/editor/RichTextEditor";
import FileDropzone from "@/components/ui/FileDropzone";
import { PageNodeData } from "./CustomNode";
interface Attachment {
id: string;
filename: string;
originalName: string;
mimeType: string;
size: number;
}
interface Link {
id: string;
url: string;
label: string | null;
}
interface NodeDrawerProps {
open: boolean;
onOpenChange: (open: boolean) => void;
node: Node | null;
projectId: string;
onUpdate: (nodeId: string, data: Partial<PageNodeData>) => void;
onDelete: (nodeId: string) => void;
}
const statusOptions = [
{ value: "draft", label: "Szkic", color: "bg-yellow-500" },
{ value: "ready", label: "Gotowe", color: "bg-green-500" },
{ value: "review", label: "Do poprawki", color: "bg-red-500" },
] as const;
export default function NodeDrawer({
open,
onOpenChange,
node,
projectId,
onUpdate,
onDelete,
}: NodeDrawerProps) {
const [title, setTitle] = useState("");
const [content, setContent] = useState("");
const [notes, setNotes] = useState("");
const [status, setStatus] = useState<"draft" | "ready" | "review">("draft");
const [attachments, setAttachments] = useState<Attachment[]>([]);
const [links, setLinks] = useState<Link[]>([]);
const [newLinkUrl, setNewLinkUrl] = useState("");
const [newLinkLabel, setNewLinkLabel] = useState("");
const [dbNodeId, setDbNodeId] = useState<string | null>(null);
// Load node data
useEffect(() => {
if (node && open) {
const data = node.data as PageNodeData;
setTitle(data.title || "");
setContent(data.content || "");
setNotes(data.notes || "");
setStatus(data.status || "draft");
// Fetch attachments and links from API
fetchNodeDetails(node.id);
}
}, [node, open]);
const fetchNodeDetails = async (nodeId: string) => {
try {
const res = await fetch(`/api/nodes/${nodeId}`);
if (res.ok) {
const data = await res.json();
setDbNodeId(data.id);
setAttachments(data.attachments || []);
setLinks(data.links || []);
}
} catch (error) {
console.error("Failed to fetch node details:", error);
}
};
// Save changes with debounce
const handleTitleChange = useCallback(
(value: string) => {
setTitle(value);
if (node) {
onUpdate(node.id, { title: value });
}
},
[node, onUpdate]
);
const handleContentChange = useCallback(
(value: string) => {
setContent(value);
if (node) {
onUpdate(node.id, { content: value });
}
},
[node, onUpdate]
);
const handleNotesChange = useCallback(
(value: string) => {
setNotes(value);
if (node) {
onUpdate(node.id, { notes: value });
}
},
[node, onUpdate]
);
const handleStatusChange = useCallback(
(value: "draft" | "ready" | "review") => {
setStatus(value);
if (node) {
onUpdate(node.id, { status: value });
}
},
[node, onUpdate]
);
const handleFileUpload = async (files: File[]) => {
if (!dbNodeId) return;
for (const file of files) {
const formData = new FormData();
formData.append("file", file);
formData.append("nodeId", dbNodeId);
try {
const res = await fetch("/api/upload", {
method: "POST",
body: formData,
});
if (res.ok) {
const newAttachment = await res.json();
setAttachments((prev) => [...prev, newAttachment]);
if (node) {
onUpdate(node.id, { hasAttachments: true });
}
}
} catch (error) {
console.error("Failed to upload file:", error);
}
}
};
const handleDeleteAttachment = async (attachmentId: string) => {
try {
const res = await fetch(`/api/attachments/${attachmentId}`, {
method: "DELETE",
});
if (res.ok) {
setAttachments((prev) => prev.filter((a) => a.id !== attachmentId));
if (node && attachments.length <= 1) {
onUpdate(node.id, { hasAttachments: false });
}
}
} catch (error) {
console.error("Failed to delete attachment:", error);
}
};
const handleAddLink = async () => {
if (!dbNodeId || !newLinkUrl.trim()) return;
try {
const res = await fetch(`/api/nodes/${dbNodeId}/links`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
url: newLinkUrl,
label: newLinkLabel || null,
}),
});
if (res.ok) {
const newLink = await res.json();
setLinks((prev) => [...prev, newLink]);
setNewLinkUrl("");
setNewLinkLabel("");
if (node) {
onUpdate(node.id, { hasLinks: true });
}
}
} catch (error) {
console.error("Failed to add link:", error);
}
};
const handleDeleteLink = async (linkId: string) => {
try {
const res = await fetch(`/api/links/${linkId}`, {
method: "DELETE",
});
if (res.ok) {
setLinks((prev) => prev.filter((l) => l.id !== linkId));
if (node && links.length <= 1) {
onUpdate(node.id, { hasLinks: false });
}
}
} catch (error) {
console.error("Failed to delete link:", error);
}
};
const handleDeleteNode = () => {
if (node) {
onDelete(node.id);
}
};
const formatFileSize = (bytes: number) => {
if (bytes < 1024) return bytes + " B";
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + " KB";
return (bytes / (1024 * 1024)).toFixed(1) + " MB";
};
if (!node) return null;
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className="w-full sm:max-w-xl bg-slate-800 border-slate-700 p-0 overflow-hidden">
<ScrollArea className="h-full">
<div className="p-6">
<SheetHeader className="mb-6">
<SheetTitle className="text-white flex items-center justify-between">
<span>Edycja węzła</span>
<Button
variant="ghost"
size="sm"
onClick={handleDeleteNode}
className="text-red-400 hover:text-red-300 hover:bg-red-500/10"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</Button>
</SheetTitle>
</SheetHeader>
{/* Title */}
<div className="space-y-2 mb-6">
<Label className="text-slate-300">Tytuł</Label>
<Input
value={title}
onChange={(e) => handleTitleChange(e.target.value)}
placeholder="Nazwa strony"
className="bg-slate-700/50 border-slate-600 text-white"
/>
</div>
{/* Status */}
<div className="space-y-2 mb-6">
<Label className="text-slate-300">Status</Label>
<div className="flex gap-2">
{statusOptions.map((option) => (
<Badge
key={option.value}
variant="outline"
onClick={() => handleStatusChange(option.value)}
className={`
cursor-pointer transition-all
${status === option.value
? `${option.color} text-white border-transparent`
: "bg-slate-700/50 text-slate-400 border-slate-600 hover:border-slate-500"
}
`}
>
{option.label}
</Badge>
))}
</div>
</div>
<Separator className="bg-slate-700 my-6" />
{/* Content */}
<div className="space-y-2 mb-6">
<Label className="text-slate-300">Treść (od klienta)</Label>
<RichTextEditor
content={content}
onChange={handleContentChange}
placeholder="Wklej treści od klienta..."
/>
</div>
<Separator className="bg-slate-700 my-6" />
{/* Notes */}
<div className="space-y-2 mb-6">
<Label className="text-slate-300">Uwagi deweloperskie</Label>
<Textarea
value={notes}
onChange={(e) => handleNotesChange(e.target.value)}
placeholder="Notatki techniczne, TODO, etc..."
className="bg-slate-700/50 border-slate-600 text-white min-h-[100px] resize-none"
/>
</div>
<Separator className="bg-slate-700 my-6" />
{/* Attachments */}
<div className="space-y-3 mb-6">
<Label className="text-slate-300">Pliki</Label>
<FileDropzone onUpload={handleFileUpload} />
{attachments.length > 0 && (
<div className="space-y-2 mt-3">
{attachments.map((attachment) => (
<div
key={attachment.id}
className="flex items-center justify-between p-3 bg-slate-700/30 rounded-lg border border-slate-700"
>
<div className="flex items-center gap-3 min-w-0">
<div className="w-8 h-8 bg-slate-600 rounded flex items-center justify-center flex-shrink-0">
{attachment.mimeType.startsWith("image/") ? (
<svg className="w-4 h-4 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
) : (
<svg className="w-4 h-4 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
</svg>
)}
</div>
<div className="min-w-0">
<p className="text-sm text-white truncate">{attachment.originalName}</p>
<p className="text-xs text-slate-500">{formatFileSize(attachment.size)}</p>
</div>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => handleDeleteAttachment(attachment.id)}
className="text-slate-400 hover:text-red-400 flex-shrink-0"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</Button>
</div>
))}
</div>
)}
</div>
<Separator className="bg-slate-700 my-6" />
{/* Links */}
<div className="space-y-3">
<Label className="text-slate-300">Linki do inspiracji</Label>
<div className="flex gap-2">
<Input
value={newLinkUrl}
onChange={(e) => setNewLinkUrl(e.target.value)}
placeholder="https://example.com"
className="bg-slate-700/50 border-slate-600 text-white flex-1"
/>
<Input
value={newLinkLabel}
onChange={(e) => setNewLinkLabel(e.target.value)}
placeholder="Etykieta (opcjonalnie)"
className="bg-slate-700/50 border-slate-600 text-white w-40"
/>
<Button
onClick={handleAddLink}
disabled={!newLinkUrl.trim()}
size="sm"
className="bg-blue-600 hover:bg-blue-500"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
</Button>
</div>
{links.length > 0 && (
<div className="space-y-2 mt-3">
{links.map((link) => (
<div
key={link.id}
className="flex items-center justify-between p-3 bg-slate-700/30 rounded-lg border border-slate-700"
>
<a
href={link.url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 text-blue-400 hover:text-blue-300 truncate"
>
<svg className="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
<span className="truncate">{link.label || link.url}</span>
</a>
<Button
variant="ghost"
size="sm"
onClick={() => handleDeleteLink(link.id)}
className="text-slate-400 hover:text-red-400 flex-shrink-0"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</Button>
</div>
))}
</div>
)}
</div>
</div>
</ScrollArea>
</SheetContent>
</Sheet>
);
}

View File

@@ -0,0 +1,108 @@
"use client";
import { DragEvent } from "react";
const nodeTypes = [
{
type: "page",
label: "Strona",
description: "Podstrona w sitemapie",
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
),
},
{
type: "note",
label: "Notatka",
description: "Luźna notatka/pomysł",
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
),
},
{
type: "section",
label: "Sekcja",
description: "Grupowanie stron",
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
),
},
];
interface NodeSidebarProps {
isCollapsed?: boolean;
onToggle?: () => void;
}
export default function NodeSidebar({ isCollapsed, onToggle }: NodeSidebarProps) {
const onDragStart = (event: DragEvent, nodeType: string) => {
event.dataTransfer.setData("application/reactflow", nodeType);
event.dataTransfer.effectAllowed = "move";
};
if (isCollapsed) {
return (
<div className="absolute left-4 top-4 z-10">
<button
onClick={onToggle}
className="p-3 bg-slate-800/90 backdrop-blur-sm border border-slate-700 rounded-xl shadow-xl hover:bg-slate-700/90 transition-colors"
>
<svg className="w-5 h-5 text-slate-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
</svg>
</button>
</div>
);
}
return (
<div className="absolute left-4 top-4 z-10 w-56">
<div className="bg-slate-800/90 backdrop-blur-sm border border-slate-700 rounded-xl shadow-xl overflow-hidden">
<div className="p-3 border-b border-slate-700 flex items-center justify-between">
<h3 className="text-sm font-semibold text-white">Elementy</h3>
<button
onClick={onToggle}
className="p-1 hover:bg-slate-700 rounded-lg transition-colors"
>
<svg className="w-4 h-4 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
</button>
</div>
<div className="p-2 space-y-1">
{nodeTypes.map((node) => (
<div
key={node.type}
draggable
onDragStart={(e) => onDragStart(e, node.type)}
className="p-3 bg-slate-700/50 hover:bg-slate-700 rounded-lg cursor-grab active:cursor-grabbing transition-colors group"
>
<div className="flex items-center gap-3">
<div className="text-slate-400 group-hover:text-blue-400 transition-colors">
{node.icon}
</div>
<div>
<p className="text-sm font-medium text-white">{node.label}</p>
<p className="text-xs text-slate-500">{node.description}</p>
</div>
</div>
</div>
))}
</div>
<div className="p-3 border-t border-slate-700">
<p className="text-xs text-slate-500 text-center">
Przeciągnij element na tablicę
</p>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,346 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { Separator } from "@/components/ui/separator";
import { ScrollArea } from "@/components/ui/scroll-area";
interface ProjectSettings {
globalNotes: string | null;
fontPrimary: string | null;
fontSecondary: string | null;
colorPalette: string | null;
designNotes: string | null;
}
interface ColorItem {
color: string;
label: string;
}
interface ProjectSettingsDrawerProps {
open: boolean;
onOpenChange: (open: boolean) => void;
projectId: string;
}
export default function ProjectSettingsDrawer({
open,
onOpenChange,
projectId,
}: ProjectSettingsDrawerProps) {
const [settings, setSettings] = useState<ProjectSettings>({
globalNotes: "",
fontPrimary: "",
fontSecondary: "",
colorPalette: "",
designNotes: "",
});
const [colors, setColors] = useState<ColorItem[]>([]);
const [newColor, setNewColor] = useState("#3b82f6");
const [newColorLabel, setNewColorLabel] = useState("");
const [saving, setSaving] = useState(false);
const [copiedColor, setCopiedColor] = useState<string | null>(null);
useEffect(() => {
if (open && projectId) {
fetchSettings();
}
}, [open, projectId]);
const fetchSettings = async () => {
try {
const res = await fetch(`/api/projects/${projectId}/settings`);
if (res.ok) {
const data = await res.json();
setSettings(data);
if (data.colorPalette) {
try {
const parsed = JSON.parse(data.colorPalette);
// Support both old format (string[]) and new format (ColorItem[])
if (Array.isArray(parsed)) {
if (typeof parsed[0] === "string") {
// Old format - convert to new
setColors(parsed.map((c: string) => ({ color: c, label: "" })));
} else {
setColors(parsed);
}
}
} catch {
setColors([]);
}
}
}
} catch (error) {
console.error("Failed to fetch settings:", error);
}
};
const saveSettings = useCallback(
async (updates: Partial<ProjectSettings>) => {
setSaving(true);
try {
await fetch(`/api/projects/${projectId}/settings`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(updates),
});
} catch (error) {
console.error("Failed to save settings:", error);
} finally {
setSaving(false);
}
},
[projectId]
);
const handleAddColor = () => {
if (newColor && !colors.find((c) => c.color === newColor)) {
const updatedColors = [...colors, { color: newColor, label: newColorLabel }];
setColors(updatedColors);
saveSettings({ colorPalette: JSON.stringify(updatedColors) });
setNewColorLabel("");
}
};
const handleRemoveColor = (colorValue: string) => {
const updatedColors = colors.filter((c) => c.color !== colorValue);
setColors(updatedColors);
saveSettings({ colorPalette: JSON.stringify(updatedColors) });
};
const handleUpdateLabel = (colorValue: string, newLabel: string) => {
const updatedColors = colors.map((c) =>
c.color === colorValue ? { ...c, label: newLabel } : c
);
setColors(updatedColors);
saveSettings({ colorPalette: JSON.stringify(updatedColors) });
};
const handleCopyColor = async (colorValue: string) => {
try {
await navigator.clipboard.writeText(colorValue);
setCopiedColor(colorValue);
setTimeout(() => setCopiedColor(null), 2000);
} catch (error) {
console.error("Failed to copy:", error);
}
};
const handleFieldChange = (field: keyof ProjectSettings, value: string) => {
setSettings((prev) => ({ ...prev, [field]: value }));
};
const handleFieldBlur = (field: keyof ProjectSettings) => {
saveSettings({ [field]: settings[field] });
};
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className="w-full sm:max-w-xl bg-slate-800 border-slate-700 p-0 overflow-hidden">
<ScrollArea className="h-full">
<div className="p-6">
<SheetHeader className="mb-6">
<SheetTitle className="text-white flex items-center gap-2">
<svg className="w-5 h-5 text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
Ustawienia projektu
</SheetTitle>
</SheetHeader>
{/* Save indicator */}
{saving && (
<div className="mb-4 px-3 py-1.5 bg-blue-500/20 text-blue-400 border border-blue-500/30 rounded-lg text-xs inline-flex items-center gap-2">
<svg className="animate-spin h-3 w-3" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
Zapisywanie...
</div>
)}
{/* Typography */}
<div className="space-y-4 mb-6">
<h3 className="text-sm font-semibold text-white flex items-center gap-2">
<svg className="w-4 h-4 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h8m-8 6h16" />
</svg>
Typografia
</h3>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label className="text-slate-300">Font główny</Label>
<Input
value={settings.fontPrimary || ""}
onChange={(e) => handleFieldChange("fontPrimary", e.target.value)}
onBlur={() => handleFieldBlur("fontPrimary")}
placeholder="np. Poppins"
className="bg-slate-700/50 border-slate-600 text-white"
/>
</div>
<div className="space-y-2">
<Label className="text-slate-300">Font pomocniczy</Label>
<Input
value={settings.fontSecondary || ""}
onChange={(e) => handleFieldChange("fontSecondary", e.target.value)}
onBlur={() => handleFieldBlur("fontSecondary")}
placeholder="np. Open Sans"
className="bg-slate-700/50 border-slate-600 text-white"
/>
</div>
</div>
</div>
<Separator className="bg-slate-700 my-6" />
{/* Color Palette */}
<div className="space-y-4 mb-6">
<h3 className="text-sm font-semibold text-white flex items-center gap-2">
<svg className="w-4 h-4 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01" />
</svg>
Paleta kolorów
<span className="text-xs text-slate-500 font-normal ml-2">
(kliknij kolor aby skopiować)
</span>
</h3>
{/* Color List */}
<div className="space-y-3">
{colors.map((item) => (
<div
key={item.color}
className="flex items-center gap-3 p-2 bg-slate-700/30 rounded-lg group"
>
{/* Color swatch - clickable to copy */}
<button
onClick={() => handleCopyColor(item.color)}
className="relative w-12 h-12 rounded-lg shadow-lg border-2 border-slate-600 hover:border-slate-400 transition-all hover:scale-105 flex-shrink-0"
style={{ backgroundColor: item.color }}
title="Kliknij aby skopiować"
>
{copiedColor === item.color && (
<div className="absolute inset-0 bg-black/70 rounded-lg flex items-center justify-center">
<svg className="w-5 h-5 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</div>
)}
</button>
{/* Color info */}
<div className="flex-1 min-w-0">
<Input
value={item.label}
onChange={(e) => handleUpdateLabel(item.color, e.target.value)}
placeholder="np. Kolor główny, Akcent, Tło..."
className="bg-slate-700/50 border-slate-600 text-white text-sm h-8 mb-1"
/>
<code className="text-xs text-slate-400 font-mono">{item.color}</code>
</div>
{/* Remove button */}
<button
onClick={() => handleRemoveColor(item.color)}
className="p-1.5 text-slate-500 hover:text-red-400 hover:bg-red-500/10 rounded transition-colors opacity-0 group-hover:opacity-100"
title="Usuń kolor"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
))}
</div>
{/* Add new color */}
<div className="space-y-2 p-3 bg-slate-700/20 rounded-lg border border-dashed border-slate-600">
<div className="flex gap-2">
<input
type="color"
value={newColor}
onChange={(e) => setNewColor(e.target.value)}
className="w-12 h-10 rounded-lg border border-slate-600 cursor-pointer"
/>
<Input
value={newColor}
onChange={(e) => setNewColor(e.target.value)}
placeholder="#000000"
className="bg-slate-700/50 border-slate-600 text-white w-28"
/>
<Input
value={newColorLabel}
onChange={(e) => setNewColorLabel(e.target.value)}
placeholder="Opis koloru (opcjonalnie)"
className="bg-slate-700/50 border-slate-600 text-white flex-1"
/>
</div>
<Button
onClick={handleAddColor}
size="sm"
className="w-full bg-blue-600 hover:bg-blue-500"
>
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
Dodaj kolor
</Button>
</div>
</div>
<Separator className="bg-slate-700 my-6" />
{/* Design Notes */}
<div className="space-y-4 mb-6">
<h3 className="text-sm font-semibold text-white flex items-center gap-2">
<svg className="w-4 h-4 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
Uwagi techniczne (WordPress)
</h3>
<Textarea
value={settings.designNotes || ""}
onChange={(e) => handleFieldChange("designNotes", e.target.value)}
onBlur={() => handleFieldBlur("designNotes")}
placeholder="Wymogi techniczne, pluginy, motyw WP, szczególne funkcjonalności..."
className="bg-slate-700/50 border-slate-600 text-white min-h-[120px] resize-none"
/>
</div>
<Separator className="bg-slate-700 my-6" />
{/* Global Notes */}
<div className="space-y-4">
<h3 className="text-sm font-semibold text-white flex items-center gap-2">
<svg className="w-4 h-4 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
Globalne notatki projektu
</h3>
<Textarea
value={settings.globalNotes || ""}
onChange={(e) => handleFieldChange("globalNotes", e.target.value)}
onBlur={() => handleFieldBlur("globalNotes")}
placeholder="Ogólne informacje o projekcie, terminy, ważne ustalenia..."
className="bg-slate-700/50 border-slate-600 text-white min-h-[150px] resize-none"
/>
</div>
</div>
</ScrollArea>
</SheetContent>
</Sheet>
);
}

View File

@@ -0,0 +1,85 @@
"use client";
import { useDropzone } from "react-dropzone";
import { useCallback } from "react";
interface FileDropzoneProps {
onUpload: (files: File[]) => void;
accept?: Record<string, string[]>;
maxSize?: number;
}
export default function FileDropzone({
onUpload,
accept,
maxSize = 10 * 1024 * 1024, // 10MB default
}: FileDropzoneProps) {
const onDrop = useCallback(
(acceptedFiles: File[]) => {
onUpload(acceptedFiles);
},
[onUpload]
);
const { getRootProps, getInputProps, isDragActive, isDragReject } =
useDropzone({
onDrop,
accept: accept || {
"image/*": [".png", ".jpg", ".jpeg", ".gif", ".webp"],
"application/pdf": [".pdf"],
"application/msword": [".doc"],
"application/vnd.openxmlformats-officedocument.wordprocessingml.document":
[".docx"],
"text/plain": [".txt"],
},
maxSize,
multiple: true,
});
return (
<div
{...getRootProps()}
className={`
border-2 border-dashed rounded-lg p-6 text-center cursor-pointer transition-colors
${isDragActive && !isDragReject
? "border-blue-500 bg-blue-500/10"
: isDragReject
? "border-red-500 bg-red-500/10"
: "border-slate-600 hover:border-slate-500 bg-slate-700/30"
}
`}
>
<input {...getInputProps()} />
<div className="flex flex-col items-center gap-2">
<div
className={`w-10 h-10 rounded-lg flex items-center justify-center ${isDragActive ? "bg-blue-500/20" : "bg-slate-700"
}`}
>
<svg
className={`w-5 h-5 ${isDragActive ? "text-blue-400" : "text-slate-400"}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
/>
</svg>
</div>
<div>
<p className="text-sm text-slate-300">
{isDragActive
? "Upuść pliki tutaj..."
: "Przeciągnij pliki lub kliknij"}
</p>
<p className="text-xs text-slate-500 mt-1">
PNG, JPG, PDF, DOC do {maxSize / (1024 * 1024)}MB
</p>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,46 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary:
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant,
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "span"
return (
<Comp
data-slot="badge"
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
}
export { Badge, badgeVariants }

View File

@@ -0,0 +1,62 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
"icon-sm": "size-8",
"icon-lg": "size-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant = "default",
size = "default",
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot : "button"
return (
<Comp
data-slot="button"
data-variant={variant}
data-size={size}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }

View File

@@ -0,0 +1,92 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}

View File

@@ -0,0 +1,143 @@
"use client"
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { XIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean
}) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 outline-none sm:max-w-lg",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
)
}
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props}
/>
)
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-lg leading-none font-semibold", className)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}

View File

@@ -0,0 +1,257 @@
"use client"
import * as React from "react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function DropdownMenu({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
}
function DropdownMenuPortal({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
)
}
function DropdownMenuTrigger({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return (
<DropdownMenuPrimitive.Trigger
data-slot="dropdown-menu-trigger"
{...props}
/>
)
}
function DropdownMenuContent({
className,
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
)
}
function DropdownMenuGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
)
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<DropdownMenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
)
}
function DropdownMenuRadioGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return (
<DropdownMenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
)
}
function DropdownMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
)
}
function DropdownMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className
)}
{...props}
/>
)
}
function DropdownMenuSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props}
/>
)
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className
)}
{...props}
/>
)
}
function DropdownMenuSub({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
)
}
function DropdownMenuSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
className
)}
{...props}
/>
)
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
}

View File

@@ -0,0 +1,21 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className
)}
{...props}
/>
)
}
export { Input }

View File

@@ -0,0 +1,24 @@
"use client"
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cn } from "@/lib/utils"
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Label }

View File

@@ -0,0 +1,58 @@
"use client"
import * as React from "react"
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
import { cn } from "@/lib/utils"
function ScrollArea({
className,
children,
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
return (
<ScrollAreaPrimitive.Root
data-slot="scroll-area"
className={cn("relative", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport
data-slot="scroll-area-viewport"
className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1"
>
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
)
}
function ScrollBar({
className,
orientation = "vertical",
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
return (
<ScrollAreaPrimitive.ScrollAreaScrollbar
data-slot="scroll-area-scrollbar"
orientation={orientation}
className={cn(
"flex touch-none p-px transition-colors select-none",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent",
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb
data-slot="scroll-area-thumb"
className="bg-border relative flex-1 rounded-full"
/>
</ScrollAreaPrimitive.ScrollAreaScrollbar>
)
}
export { ScrollArea, ScrollBar }

View File

@@ -0,0 +1,28 @@
"use client"
import * as React from "react"
import * as SeparatorPrimitive from "@radix-ui/react-separator"
import { cn } from "@/lib/utils"
function Separator({
className,
orientation = "horizontal",
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator"
decorative={decorative}
orientation={orientation}
className={cn(
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
className
)}
{...props}
/>
)
}
export { Separator }

139
src/components/ui/sheet.tsx Normal file
View File

@@ -0,0 +1,139 @@
"use client"
import * as React from "react"
import * as SheetPrimitive from "@radix-ui/react-dialog"
import { XIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />
}
function SheetTrigger({
...props
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
}
function SheetClose({
...props
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
}
function SheetPortal({
...props
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
}
function SheetOverlay({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
return (
<SheetPrimitive.Overlay
data-slot="sheet-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
)
}
function SheetContent({
className,
children,
side = "right",
...props
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
side?: "top" | "right" | "bottom" | "left"
}) {
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
data-slot="sheet-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
side === "right" &&
"data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm",
side === "left" &&
"data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm",
side === "top" &&
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
side === "bottom" &&
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
className
)}
{...props}
>
{children}
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
<XIcon className="size-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
</SheetPrimitive.Content>
</SheetPortal>
)
}
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-header"
className={cn("flex flex-col gap-1.5 p-4", className)}
{...props}
/>
)
}
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
}
function SheetTitle({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
return (
<SheetPrimitive.Title
data-slot="sheet-title"
className={cn("text-foreground font-semibold", className)}
{...props}
/>
)
}
function SheetDescription({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
return (
<SheetPrimitive.Description
data-slot="sheet-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
export {
Sheet,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
}

View File

@@ -0,0 +1,18 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
{...props}
/>
)
}
export { Textarea }

40
src/lib/auth.ts Normal file
View File

@@ -0,0 +1,40 @@
import { SignJWT, jwtVerify } from "jose";
import { cookies } from "next/headers";
const JWT_SECRET = new TextEncoder().encode(
process.env.JWT_SECRET || "fallback-secret-change-in-production"
);
const APP_PASSWORD = process.env.APP_ACCESS_PASSWORD || "admin";
export async function verifyPassword(password: string): Promise<boolean> {
return password === APP_PASSWORD;
}
export async function createToken(): Promise<string> {
return await new SignJWT({ authenticated: true })
.setProtectedHeader({ alg: "HS256" })
.setIssuedAt()
.setExpirationTime("7d")
.sign(JWT_SECRET);
}
export async function verifyToken(token: string): Promise<boolean> {
try {
await jwtVerify(token, JWT_SECRET);
return true;
} catch {
return false;
}
}
export async function getAuthToken(): Promise<string | null> {
const cookieStore = await cookies();
return cookieStore.get("auth-token")?.value || null;
}
export async function isAuthenticated(): Promise<boolean> {
const token = await getAuthToken();
if (!token) return false;
return await verifyToken(token);
}

17
src/lib/prisma.ts Normal file
View File

@@ -0,0 +1,17 @@
import { PrismaClient } from "@prisma/client";
import { PrismaLibSql } from "@prisma/adapter-libsql";
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined;
};
function createPrismaClient() {
const adapter = new PrismaLibSql({
url: process.env.DATABASE_URL || "file:./prisma/dev.db",
});
return new PrismaClient({ adapter });
}
export const prisma = globalForPrisma.prisma ?? createPrismaClient();
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;

6
src/lib/utils.ts Normal file
View File

@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

45
src/middleware.ts Normal file
View File

@@ -0,0 +1,45 @@
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { jwtVerify } from "jose";
const JWT_SECRET = new TextEncoder().encode(
process.env.JWT_SECRET || "fallback-secret-change-in-production"
);
const publicPaths = ["/login", "/api/auth/login"];
export async function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// Allow public paths
if (publicPaths.some((path) => pathname.startsWith(path))) {
return NextResponse.next();
}
// Allow static files and API routes that don't need auth
if (
pathname.startsWith("/_next") ||
pathname.startsWith("/favicon") ||
pathname.includes(".")
) {
return NextResponse.next();
}
// Check auth token
const token = request.cookies.get("auth-token")?.value;
if (!token) {
return NextResponse.redirect(new URL("/login", request.url));
}
try {
await jwtVerify(token, JWT_SECRET);
return NextResponse.next();
} catch {
return NextResponse.redirect(new URL("/login", request.url));
}
}
export const config = {
matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
};