first commit

This commit is contained in:
mstfyldz
2026-03-24 15:46:27 +03:00
parent 095d830279
commit 34b6a46604
33 changed files with 5212 additions and 81 deletions

View File

@@ -0,0 +1,67 @@
import { NextRequest, NextResponse } from "next/server";
import { appleApiRequest } from "@/app/asc/api";
// GET /api/asc/localizations?versionId=xxx
export async function GET(req: NextRequest) {
const versionId = req.nextUrl.searchParams.get("versionId");
if (!versionId) return NextResponse.json({ error: "versionId gerekli" }, { status: 400 });
const result = await appleApiRequest(
`/appStoreVersions/${versionId}/appStoreVersionLocalizations?fields[appStoreVersionLocalizations]=locale,description,keywords,whatsNew,promotionalText,marketingUrl,supportUrl`
);
if (result.error) return NextResponse.json({ error: result.error }, { status: 502 });
return NextResponse.json(result.data);
}
// PATCH /api/asc/localizations { localizationId, payload }
export async function PATCH(req: NextRequest) {
const body = await req.json();
const { localizationId, payload } = body ?? {};
if (!localizationId) return NextResponse.json({ error: "localizationId gerekli" }, { status: 400 });
// Apple API does not allow 'locale' in UPDATE requests (409 ENTITY_ERROR.ATTRIBUTE.NOT_ALLOWED)
const { locale: _locale, ...attributes } = payload ?? {};
const result = await appleApiRequest(`/appStoreVersionLocalizations/${localizationId}`, {
method: "PATCH",
body: JSON.stringify({
data: {
type: "appStoreVersionLocalizations",
id: localizationId,
attributes,
},
}),
});
if (result.error) return NextResponse.json({ error: result.error }, { status: 502 });
return NextResponse.json(result.data);
}
// POST /api/asc/localizations { versionId, locale }
export async function POST(req: NextRequest) {
const body = await req.json();
const { versionId, locale } = body ?? {};
if (!versionId || !locale)
return NextResponse.json({ error: "versionId ve locale gerekli" }, { status: 400 });
const result = await appleApiRequest(`/appStoreVersionLocalizations`, {
method: "POST",
body: JSON.stringify({
data: {
type: "appStoreVersionLocalizations",
attributes: { locale },
relationships: {
appStoreVersion: {
data: { type: "appStoreVersions", id: versionId },
},
},
},
}),
});
if (result.error) return NextResponse.json({ error: result.error }, { status: 502 });
return NextResponse.json(result.data, { status: 201 });
}

View File

@@ -0,0 +1,19 @@
import { NextRequest, NextResponse } from "next/server";
import { respondToReview } from "@/app/asc/actions";
// POST /api/asc/review-response { reviewId, responseBody }
export async function POST(req: NextRequest) {
const body = await req.json();
const { reviewId, responseBody } = body ?? {};
if (!reviewId || !responseBody?.trim()) {
return NextResponse.json({ error: "reviewId ve responseBody gerekli" }, { status: 400 });
}
const result = await respondToReview(reviewId, responseBody.trim());
if (result.error) {
return NextResponse.json({ error: result.error }, { status: 502 });
}
return NextResponse.json({ ok: true });
}

View File

@@ -0,0 +1,24 @@
import { NextRequest, NextResponse } from "next/server";
import { appleApiRequest } from "@/app/asc/api";
// PATCH /api/asc/screenshots/commit { screenshotId, md5, ... }
// Step 3: Commit the uploaded screenshot to mark upload as complete
export async function PATCH(req: NextRequest) {
const body = await req.json();
const { screenshotId } = body ?? {};
if (!screenshotId) return NextResponse.json({ error: "screenshotId gerekli" }, { status: 400 });
const result = await appleApiRequest(`/appScreenshots/${screenshotId}`, {
method: "PATCH",
body: JSON.stringify({
data: {
type: "appScreenshots",
id: screenshotId,
attributes: { uploaded: true },
},
}),
});
if (result.error) return NextResponse.json({ error: result.error }, { status: 502 });
return NextResponse.json(result.data);
}

View File

@@ -0,0 +1,97 @@
import { NextRequest, NextResponse } from "next/server";
import { appleApiRequest } from "@/app/asc/api";
const DISPLAY_TYPE = "APP_IPHONE_65"; // iPhone 6.5" (6.7" Super Retina)
// GET /api/asc/screenshots?localizationId=xxx
// Returns the APP_IPHONE_65 screenshot set and its screenshots
export async function GET(req: NextRequest) {
const locId = req.nextUrl.searchParams.get("localizationId");
if (!locId) return NextResponse.json({ error: "localizationId gerekli" }, { status: 400 });
// 1. Fetch screenshot sets for this localization
const setsResult = await appleApiRequest(
`/appStoreVersionLocalizations/${locId}/appScreenshotSets?filter[screenshotDisplayType]=${DISPLAY_TYPE}&include=appScreenshots&limit=1`
);
if (setsResult.error) return NextResponse.json({ error: setsResult.error }, { status: 502 });
const sets = setsResult.data?.data ?? [];
if (sets.length === 0) {
return NextResponse.json({ set: null, screenshots: [] });
}
const set = sets[0];
// screenshots are included via ?include=appScreenshots
const included = setsResult.data?.included ?? [];
const screenshots = included.filter((r: { type: string }) => r.type === "appScreenshots");
return NextResponse.json({ set, screenshots });
}
// POST /api/asc/screenshots/reserve { localizationId, fileName, fileSize, md5 }
// Step 1: Creates screenshot set if needed, then creates the upload reservation
export async function POST(req: NextRequest) {
const body = await req.json();
const { localizationId, fileName, fileSize, md5 } = body ?? {};
if (!localizationId || !fileName || !fileSize)
return NextResponse.json({ error: "localizationId, fileName, fileSize gerekli" }, { status: 400 });
// 1. Find or create the APP_IPHONE_65 screenshot set
const setsResult = await appleApiRequest(
`/appStoreVersionLocalizations/${localizationId}/appScreenshotSets?filter[screenshotDisplayType]=${DISPLAY_TYPE}&limit=1`
);
if (setsResult.error) return NextResponse.json({ error: setsResult.error }, { status: 502 });
let setId: string;
if ((setsResult.data?.data ?? []).length > 0) {
setId = setsResult.data.data[0].id;
} else {
// Create the set
const createSet = await appleApiRequest(`/appScreenshotSets`, {
method: "POST",
body: JSON.stringify({
data: {
type: "appScreenshotSets",
attributes: { screenshotDisplayType: DISPLAY_TYPE },
relationships: {
appStoreVersionLocalization: {
data: { type: "appStoreVersionLocalizations", id: localizationId },
},
},
},
}),
});
if (createSet.error) return NextResponse.json({ error: createSet.error }, { status: 502 });
setId = createSet.data.data.id;
}
// 2. Create screenshot upload reservation
const reserveResult = await appleApiRequest(`/appScreenshots`, {
method: "POST",
body: JSON.stringify({
data: {
type: "appScreenshots",
attributes: { fileName, fileSize, ...(md5 ? { md5 } : {}) },
relationships: {
appScreenshotSet: {
data: { type: "appScreenshotSets", id: setId },
},
},
},
}),
});
if (reserveResult.error) return NextResponse.json({ error: reserveResult.error }, { status: 502 });
return NextResponse.json(reserveResult.data, { status: 201 });
}
// DELETE /api/asc/screenshots?screenshotId=xxx
export async function DELETE(req: NextRequest) {
const screenshotId = req.nextUrl.searchParams.get("screenshotId");
if (!screenshotId) return NextResponse.json({ error: "screenshotId gerekli" }, { status: 400 });
const result = await appleApiRequest(`/appScreenshots/${screenshotId}`, { method: "DELETE" });
if (result.error) return NextResponse.json({ error: result.error }, { status: 502 });
return new NextResponse(null, { status: 204 });
}

View File

@@ -0,0 +1,76 @@
import { db } from "@/db";
import { apps, remoteConfig } from "@/db/schema";
import { eq } from "drizzle-orm";
import { NextResponse } from "next/server";
/**
* GET /api/config/:slug
*
* :slug → bundle ID (önerilen) örn: com.sirket.uygulama
* → numeric app ID (geriye dönük uyumluluk) örn: 4
*
* Kimlik doğrulama — iki yöntemden biri:
* Authorization: Bearer <CONFIG_API_TOKEN>
* ?token=<CONFIG_API_TOKEN>
*/
export async function GET(
request: Request,
{ params }: { params: Promise<{ slug: string }> }
) {
// ── Auth ──────────────────────────────────────────────────────────────
const apiToken = process.env.CONFIG_API_TOKEN;
if (apiToken) {
const authHeader = request.headers.get("Authorization") ?? "";
const url = new URL(request.url);
const queryToken = url.searchParams.get("token") ?? "";
const bearerToken = authHeader.startsWith("Bearer ")
? authHeader.slice(7)
: "";
if ((bearerToken || queryToken) !== apiToken) {
return NextResponse.json(
{ error: "Unauthorized" },
{
status: 401,
headers: { "WWW-Authenticate": 'Bearer realm="config"' },
}
);
}
}
// ── Lookup ────────────────────────────────────────────────────────────
const { slug } = await params;
const numericId = parseInt(slug);
const isNumeric = !isNaN(numericId) && String(numericId) === slug;
const app = await db.query.apps.findFirst({
where: isNumeric
? eq(apps.id, numericId)
: eq(apps.bundleId, slug),
});
if (!app) {
return NextResponse.json({ error: "App not found" }, { status: 404 });
}
// Numeric ID ile gelindiyse bundle ID'li URL'e kalıcı yönlendir
if (isNumeric) {
const url = new URL(request.url);
url.pathname = `/api/config/${app.bundleId}`;
return NextResponse.redirect(url.toString(), 301);
}
// ── Config ────────────────────────────────────────────────────────────
const configs = await db.query.remoteConfig.findMany({
where: eq(remoteConfig.appId, app.id),
});
const configObject = configs.reduce<Record<string, unknown>>((acc, c) => {
acc[c.configKey] = c.configValue;
return acc;
}, {});
return NextResponse.json(configObject, {
headers: { "Cache-Control": "no-store" },
});
}

View File

@@ -0,0 +1,21 @@
import { db } from "@/db";
import { items } from "@/db/schema";
import { eq } from "drizzle-orm";
import { NextResponse } from "next/server";
export async function GET(
request: Request,
{ params }: { params: Promise<{ key: string }> }
) {
const { key } = await params;
const item = await db.query.items.findFirst({
where: eq(items.key, key),
});
if (!item) {
return NextResponse.json({ error: "Item not found" }, { status: 404 });
}
return NextResponse.json(item);
}

28
app/apps/DeleteButton.tsx Normal file
View File

@@ -0,0 +1,28 @@
"use client";
import { Trash2 } from "lucide-react";
interface Props {
appId: number;
appName: string;
deleteAction: (formData: FormData) => Promise<void>;
}
export default function DeleteButton({ appId, appName, deleteAction }: Props) {
return (
<form action={deleteAction}>
<input type="hidden" name="id" value={appId} />
<button
type="submit"
onClick={(e) => {
if (!confirm(`"${appName}" silinsin mi? Bu işlem geri alınamaz.`)) {
e.preventDefault();
}
}}
className="flex items-center gap-1.5 px-3 py-2 bg-red-50 dark:bg-red-900/10 text-red-500 border border-red-200 dark:border-red-900 rounded-xl text-xs font-bold hover:bg-red-100 dark:hover:bg-red-900/30 transition-colors"
>
<Trash2 size={13} /> Sil
</button>
</form>
);
}

View File

@@ -0,0 +1,246 @@
import { getAppConfigs, upsertConfig, deleteConfig } from "../../../config/actions";
import { getAppById } from "../../actions";
import { Save, Trash2, Smartphone, Globe, Plus, Code, ArrowLeft } from "lucide-react";
import Link from "next/link";
import { notFound } from "next/navigation";
import { revalidatePath } from "next/cache";
export default async function AppConfigPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const appId = parseInt(id);
if (isNaN(appId)) notFound();
const app = await getAppById(appId);
if (!app) notFound();
const configToken = process.env.CONFIG_API_TOKEN ?? "";
const jsonUrl = `/api/config/${app.bundleId}?token=${configToken}`;
const configs = await getAppConfigs(appId);
// --- Server Actions ---
async function handleAddConfig(formData: FormData) {
"use server";
const key = formData.get("key") as string;
const value = formData.get("value") as string;
const type = formData.get("type") as string;
let parsedValue: any = value;
if (type === "boolean") parsedValue = value === "true";
if (type === "number") parsedValue = parseFloat(value);
await upsertConfig(appId, key, parsedValue);
revalidatePath(`/apps/${appId}/config`);
}
async function handleUpdateConfig(formData: FormData) {
"use server";
const key = formData.get("key") as string;
const rawValue = formData.get("value") as string;
// Auto-detect type
let parsedValue: any = rawValue;
if (rawValue === "true") parsedValue = true;
else if (rawValue === "false") parsedValue = false;
else if (rawValue !== "" && !isNaN(Number(rawValue))) parsedValue = Number(rawValue);
await upsertConfig(appId, key, parsedValue);
revalidatePath(`/apps/${appId}/config`);
}
return (
<div className="flex h-screen bg-slate-50 dark:bg-black font-sans text-slate-900 dark:text-slate-100">
{/* Sidebar */}
<aside className="w-64 bg-white dark:bg-zinc-950 border-r border-slate-200 dark:border-zinc-900 flex flex-col shrink-0 shadow-sm">
<div className="p-6 border-b border-slate-100 dark:border-zinc-900 flex items-center gap-3">
<Link href="/" className="w-7 h-7 bg-slate-900 dark:bg-white rounded-md flex items-center justify-center">
<span className="text-white dark:text-black font-bold text-sm">A</span>
</Link>
<span className="font-bold text-lg uppercase tracking-wider">AppAdmin</span>
</div>
<nav className="flex-1 p-4 space-y-1">
<Link href="/" className="flex items-center gap-2.5 px-3 py-2 text-slate-600 dark:text-zinc-400 hover:bg-slate-50 dark:hover:bg-zinc-900 rounded-lg text-sm font-medium">
<Globe size={18} /> Dashboard
</Link>
<Link href="/apps" className="flex items-center gap-2.5 px-3 py-2 bg-slate-900 dark:bg-white text-white dark:text-black rounded-lg text-sm font-semibold">
<Smartphone size={18} /> Uygulamalar
</Link>
</nav>
</aside>
{/* Main */}
<main className="flex-1 overflow-y-auto p-8">
<div className="max-w-5xl mx-auto space-y-8">
{/* Header */}
<div className="flex items-center justify-between border-b border-slate-200 dark:border-zinc-900 pb-6">
<div className="flex items-center gap-4">
<Link href="/apps" className="p-2 bg-white dark:bg-zinc-950 border border-slate-200 dark:border-zinc-800 rounded-lg text-slate-500 hover:text-slate-900 transition-colors">
<ArrowLeft size={20} />
</Link>
<div>
<h1 className="text-2xl font-bold">{app.name} - Remote Config</h1>
<p className="text-sm text-slate-500 font-mono opacity-70">{app.bundleId}</p>
</div>
</div>
<Link
href={jsonUrl}
target="_blank"
className="px-4 py-2 bg-slate-100 dark:bg-zinc-900 rounded-lg text-xs font-bold flex items-center gap-2 hover:bg-slate-200 transition-all border border-slate-200 dark:border-zinc-800"
>
<Code size={14} /> JSON Çıktısı
</Link>
</div>
{/* API Erişim Bilgisi */}
<div className="bg-slate-900 dark:bg-zinc-900 rounded-xl p-5 space-y-3 border border-slate-800">
<p className="text-[10px] font-bold uppercase tracking-widest text-slate-400">Mobil Uygulama Entegrasyonu</p>
<div className="flex flex-col gap-2">
<div className="flex items-center gap-3">
<span className="text-[10px] font-bold uppercase text-slate-500 w-16 shrink-0">URL</span>
<code className="flex-1 text-xs text-emerald-400 font-mono bg-black/30 px-3 py-1.5 rounded-lg truncate select-all">
{`/api/config/${app.bundleId}`}
</code>
</div>
<div className="flex items-center gap-3">
<span className="text-[10px] font-bold uppercase text-slate-500 w-16 shrink-0">Token</span>
<code className="flex-1 text-xs text-yellow-400 font-mono bg-black/30 px-3 py-1.5 rounded-lg truncate select-all">
{configToken || "— CONFIG_API_TOKEN tanımlı değil —"}
</code>
</div>
<p className="text-[10px] text-slate-500 pl-20">
{`Authorization: Bearer <token> ya da ?token=<token> query parametresi ile erişin.`}
</p>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* Add New Config Panel */}
<div className="lg:col-span-1">
<div className="bg-white dark:bg-zinc-950 p-6 rounded-xl border border-slate-200 dark:border-zinc-900 shadow-sm sticky top-8">
<h3 className="font-bold text-sm mb-4 flex items-center gap-2 text-slate-900 dark:text-white">
<Plus size={16} className="text-blue-500" /> Yeni Ayar Ekle
</h3>
<form action={handleAddConfig} className="space-y-4">
<div className="space-y-1">
<label className="text-[10px] font-bold uppercase text-slate-400">Anahtar (Key)</label>
<input
name="key"
required
placeholder="örn: maintenance_mode"
className="w-full px-3 py-2 bg-slate-50 dark:bg-zinc-900 border border-slate-200 dark:border-zinc-800 rounded-lg text-sm font-mono text-slate-900 dark:text-white outline-none focus:ring-2 focus:ring-slate-400"
/>
</div>
<div className="space-y-1">
<label className="text-[10px] font-bold uppercase text-slate-400">Değer (Value)</label>
<input
name="value"
required
placeholder="örn: true veya 1.0.0"
className="w-full px-3 py-2 bg-slate-50 dark:bg-zinc-900 border border-slate-200 dark:border-zinc-800 rounded-lg text-sm text-slate-900 dark:text-white outline-none focus:ring-2 focus:ring-slate-400"
/>
</div>
<div className="space-y-1">
<label className="text-[10px] font-bold uppercase text-slate-400">Tip</label>
<select
name="type"
className="w-full px-3 py-2 bg-slate-50 dark:bg-zinc-900 border border-slate-200 dark:border-zinc-800 rounded-lg text-sm text-slate-900 dark:text-white outline-none"
>
<option value="string">String</option>
<option value="boolean">Boolean</option>
<option value="number">Number</option>
</select>
</div>
<button
type="submit"
className="w-full py-2.5 bg-slate-900 dark:bg-white text-white dark:text-black rounded-lg text-sm font-bold hover:opacity-90 transition-opacity"
>
Kaydet
</button>
</form>
</div>
</div>
{/* Config Table */}
<div className="lg:col-span-2">
<div className="bg-white dark:bg-zinc-950 rounded-xl border border-slate-200 dark:border-zinc-900 shadow-sm overflow-hidden">
<table className="w-full text-left">
<thead className="bg-slate-50 dark:bg-zinc-900 border-b border-slate-200 dark:border-zinc-800 text-[10px] font-bold uppercase tracking-widest text-slate-400">
<tr>
<th className="px-5 py-4 w-2/5">Key</th>
<th className="px-5 py-4 w-3/5">Value</th>
<th className="px-5 py-4 text-right"></th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100 dark:divide-zinc-900">
{configs.map((config) => (
<tr
key={config.id}
className="hover:bg-slate-50/70 dark:hover:bg-zinc-900/50 transition-colors group"
>
{/* Key — read only */}
<td className="px-5 py-3 font-mono text-xs font-semibold text-slate-600 dark:text-zinc-400 align-middle">
{config.configKey}
</td>
{/* Value — inline editable */}
<td className="px-3 py-2 align-middle">
<form action={handleUpdateConfig} className="flex items-center gap-1.5">
<input type="hidden" name="key" value={config.configKey} />
<input
name="value"
defaultValue={String(config.configValue)}
className="w-full px-3 py-1.5 rounded-lg text-sm font-mono text-slate-900 dark:text-white bg-slate-50 dark:bg-zinc-900 border border-slate-200 dark:border-zinc-800 outline-none focus:ring-2 focus:ring-slate-400 dark:focus:ring-zinc-600 transition-all"
/>
<button
type="submit"
title="Değeri güncelle"
className="shrink-0 w-7 h-7 flex items-center justify-center rounded-lg text-slate-300 dark:text-zinc-600 hover:text-emerald-600 dark:hover:text-emerald-400 hover:bg-emerald-50 dark:hover:bg-emerald-950/50 transition-all opacity-0 group-hover:opacity-100 focus:opacity-100"
>
<Save size={14} />
</button>
</form>
</td>
{/* Delete */}
<td className="px-5 py-3 text-right align-middle">
<form
action={async () => {
"use server";
await deleteConfig(config.id);
revalidatePath(`/apps/${appId}/config`);
}}
>
<button
type="submit"
title="Sil"
className="p-1.5 rounded-lg text-slate-300 dark:text-zinc-700 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-950/40 transition-all"
>
<Trash2 size={15} />
</button>
</form>
</td>
</tr>
))}
{configs.length === 0 && (
<tr>
<td colSpan={3} className="px-6 py-12 text-center text-slate-400 italic text-sm">
Henüz bir ayar eklenmemiş.
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
</div>
</div>
</main>
</div>
);
}

100
app/apps/[id]/edit/page.tsx Normal file
View File

@@ -0,0 +1,100 @@
import { getAppById, updateApp } from "../../actions";
import { ArrowLeft, Save, Smartphone, Globe } from "lucide-react";
import Link from "next/link";
import { notFound } from "next/navigation";
export default async function EditAppPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const appId = parseInt(id);
if (isNaN(appId)) {
notFound();
}
const app = await getAppById(appId);
if (!app) {
notFound();
}
const updateAppWithId = updateApp.bind(null, app.id);
return (
<div className="flex h-screen bg-slate-50 dark:bg-black font-sans text-slate-900 dark:text-slate-100">
<aside className="w-64 bg-white dark:bg-zinc-950 border-r border-slate-200 dark:border-zinc-900 flex flex-col shrink-0 shadow-sm">
<div className="p-6 border-b border-slate-100 dark:border-zinc-900 flex items-center gap-3">
<Link href="/" className="w-7 h-7 bg-slate-900 dark:bg-white rounded-md flex items-center justify-center">
<span className="text-white dark:text-black font-bold text-sm">A</span>
</Link>
<span className="font-bold text-lg tracking-tight uppercase tracking-wider text-slate-900 dark:text-white">AppAdmin</span>
</div>
<nav className="flex-1 p-4 space-y-1">
<Link href="/" className="flex items-center gap-2.5 px-3 py-2 text-slate-600 dark:text-zinc-400 hover:bg-slate-50 dark:hover:bg-zinc-900 rounded-lg text-sm font-medium">
<Globe size={18} /> Dashboard
</Link>
<Link href="/apps" className="flex items-center gap-2.5 px-3 py-2 bg-slate-900 dark:bg-white text-white dark:text-black rounded-lg text-sm font-semibold">
<Smartphone size={18} /> Uygulamalar
</Link>
</nav>
</aside>
<main className="flex-1 overflow-y-auto p-8">
<div className="max-w-2xl mx-auto space-y-8">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Link href="/apps" className="p-2 bg-white dark:bg-zinc-950 border border-slate-200 dark:border-zinc-800 rounded-lg text-slate-500 hover:text-slate-900 transition-colors">
<ArrowLeft size={20} />
</Link>
<div>
<h1 className="text-2xl font-bold text-slate-900 dark:text-white">Uygulamayı Düzenle</h1>
<p className="text-sm text-slate-500 font-mono opacity-70">{app.name} / {app.bundleId}</p>
</div>
</div>
</div>
<div className="bg-white dark:bg-zinc-950 p-8 rounded-xl border border-slate-200 dark:border-zinc-900 shadow-sm">
<form action={updateAppWithId} className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-1.5">
<label className="text-xs font-bold uppercase text-slate-400 tracking-wider">Uygulama Adı</label>
<input name="name" defaultValue={app.name} required className="w-full px-4 py-2 bg-slate-50 dark:bg-zinc-900 border border-slate-200 dark:border-zinc-800 rounded-lg text-sm outline-none focus:ring-1 focus:ring-slate-900 text-slate-900 dark:text-white" />
</div>
<div className="space-y-1.5">
<label className="text-xs font-bold uppercase text-slate-400 tracking-wider">Bundle ID</label>
<input name="bundleId" defaultValue={app.bundleId} required className="w-full px-4 py-2 bg-slate-50 dark:bg-zinc-900 border border-slate-200 dark:border-zinc-800 rounded-lg text-sm font-mono outline-none focus:ring-1 focus:ring-slate-900 text-slate-900 dark:text-white" />
</div>
<div className="space-y-1.5">
<label className="text-xs font-bold uppercase text-slate-400 tracking-wider">Platform</label>
<select name="platform" defaultValue={app.platform} className="w-full px-4 py-2 bg-slate-50 dark:bg-zinc-900 border border-slate-200 dark:border-zinc-800 rounded-lg text-sm outline-none focus:ring-1 focus:ring-slate-900 text-slate-900 dark:text-white">
<option value="ios">iOS</option>
<option value="android">Android</option>
<option value="dual">Dual</option>
</select>
</div>
<div className="space-y-1.5">
<label className="text-xs font-bold uppercase text-slate-400 tracking-wider">Durum</label>
<select name="status" defaultValue={app.status} className="w-full px-4 py-2 bg-slate-50 dark:bg-zinc-900 border border-slate-200 dark:border-zinc-800 rounded-lg text-sm outline-none focus:ring-1 focus:ring-slate-900 text-slate-900 dark:text-white">
<option value="active">Aktif</option>
<option value="inactive">Pasif</option>
<option value="archived">Arşivlendi</option>
</select>
</div>
</div>
<div className="space-y-1.5">
<label className="text-xs font-bold uppercase text-slate-400 tracking-wider">Apple App ID (Opsiyonel)</label>
<input name="appleId" defaultValue={app.appleId || ''} className="w-full px-4 py-2 bg-slate-50 dark:bg-zinc-900 border border-slate-200 dark:border-zinc-800 rounded-lg text-sm outline-none focus:ring-1 focus:ring-slate-900 text-slate-900 dark:text-white" placeholder="Örn: 123456789" />
</div>
<div className="pt-4 flex items-center gap-4">
<button type="submit" className="flex-1 flex items-center justify-center gap-2 bg-slate-900 dark:bg-white text-white dark:text-black py-2.5 rounded-lg text-sm font-bold hover:opacity-90 transition-all shadow-md">
<Save size={18} /> Güncellemeleri Kaydet
</button>
</div>
</form>
</div>
</div>
</main>
</div>
);
}

View File

@@ -0,0 +1,295 @@
"use client";
import { useState, useEffect, useTransition } from "react";
import { Globe, ChevronDown, Save, Loader2, CheckCircle2, Plus, X } from "lucide-react";
interface Localization {
id: string;
attributes: {
locale: string;
description: string;
keywords: string;
whatsNew: string;
promotionalText: string;
marketingUrl: string;
supportUrl: string;
};
}
const LOCALE_LABELS: Record<string, string> = {
"en-US": "English (US)",
"tr-TR": "Türkçe",
"de-DE": "Deutsch",
"fr-FR": "Français",
"es-ES": "Español",
"it-IT": "Italiano",
"pt-PT": "Português (PT)",
"pt-BR": "Português (BR)",
"ja": "日本語",
"ko": "한국어",
"zh-Hans": "简体中文",
"zh-Hant": "繁體中文",
"ar-SA": "العربية",
"ru": "Русский",
"nl-NL": "Nederlands",
"sv-SE": "Svenska",
"pl-PL": "Polski",
"da": "Dansk",
"fi": "Suomi",
"nb": "Norsk",
"el": "Ελληνικά",
"cs": "Čeština",
"hu": "Magyar",
"ro": "Română",
"sk": "Slovenčina",
"uk": "Українська",
"hr": "Hrvatski",
"id": "Bahasa Indonesia",
"ms": "Bahasa Malaysia",
"th": "ภาษาไทย",
"vi": "Tiếng Việt",
"hi": "हिन्दी",
};
const FIELDS: {
key: keyof Localization["attributes"];
label: string;
multiline?: boolean;
maxLen?: number;
}[] = [
{ key: "description", label: "Açıklama", multiline: true, maxLen: 4000 },
{ key: "keywords", label: "Anahtar Kelimeler", maxLen: 100 },
{ key: "whatsNew", label: "Bu Sürümdeki Yenilikler", multiline: true, maxLen: 4000 },
{ key: "promotionalText", label: "Tanıtım Metni", multiline: true, maxLen: 170 },
{ key: "marketingUrl", label: "Pazarlama URL" },
{ key: "supportUrl", label: "Destek URL" },
];
export default function LocalizationEditor({
versionId,
versionString,
}: {
versionId: string;
versionString: string;
}) {
const [localizations, setLocalizations] = useState<Localization[]>([]);
const [selectedLocale, setSelectedLocale] = useState<string>("");
const [form, setForm] = useState<Partial<Localization["attributes"]>>({});
const [loading, setLoading] = useState(true);
const [saving, startSaveTransition] = useTransition();
const [adding, startAddTransition] = useTransition();
const [saved, setSaved] = useState(false);
const [error, setError] = useState<string | null>(null);
const [showAddLocale, setShowAddLocale] = useState(false);
function fetchLocalizations(selectId?: string) {
setLoading(true);
fetch(`/api/asc/localizations?versionId=${versionId}`)
.then((r) => r.json())
.then((d) => {
const locs: Localization[] = d.data ?? [];
setLocalizations(locs);
if (locs.length > 0) {
const target = selectId ? locs.find((l) => l.id === selectId) ?? locs[0] : locs[0];
setSelectedLocale(target.id);
setForm(target.attributes);
}
})
.finally(() => setLoading(false));
}
// eslint-disable-next-line react-hooks/exhaustive-deps
useEffect(() => { fetchLocalizations(); }, [versionId]);
function selectLocale(id: string) {
const loc = localizations.find((l) => l.id === id);
if (!loc) return;
setSelectedLocale(id);
setForm(loc.attributes);
setSaved(false);
setError(null);
}
function handleChange(key: keyof Localization["attributes"], value: string) {
setForm((prev) => ({ ...prev, [key]: value }));
setSaved(false);
}
function handleSave() {
setError(null);
startSaveTransition(async () => {
const res = await fetch(`/api/asc/localizations`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ localizationId: selectedLocale, payload: form }),
});
if (res.ok) {
setSaved(true);
setTimeout(() => setSaved(false), 3000);
} else {
const d = await res.json();
setError(d.error ?? "Kaydedilemedi");
}
});
}
function handleAddLocale(localeCode: string) {
setShowAddLocale(false);
setError(null);
startAddTransition(async () => {
const res = await fetch(`/api/asc/localizations`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ versionId, locale: localeCode }),
});
const d = await res.json();
if (res.ok) {
fetchLocalizations(d.data?.id);
} else {
setError(d.error ?? "Dil eklenemedi");
}
});
}
const existingCodes = new Set(localizations.map((l) => l.attributes.locale));
const availableToAdd = Object.entries(LOCALE_LABELS).filter(([code]) => !existingCodes.has(code));
return (
<section className="rounded-2xl border border-white/5 bg-white/[0.03] overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-white/5">
<h2 className="font-bold text-sm flex items-center gap-2">
<Globe size={15} className="text-emerald-400" />
Metadata Düzenle
<span className="text-[10px] text-slate-500 font-normal">v{versionString}</span>
</h2>
<div className="flex items-center gap-2">
{/* Locale selector */}
{localizations.length > 0 && (
<div className="relative">
<select
value={selectedLocale}
onChange={(e) => selectLocale(e.target.value)}
className="appearance-none bg-white/5 border border-white/10 rounded-lg text-xs font-semibold pl-3 pr-8 py-2 text-slate-300 focus:outline-none focus:border-violet-500 cursor-pointer"
>
{localizations.map((l) => (
<option key={l.id} value={l.id}>
{LOCALE_LABELS[l.attributes.locale] ?? l.attributes.locale}
</option>
))}
</select>
<ChevronDown size={12} className="absolute right-2.5 top-1/2 -translate-y-1/2 text-slate-400 pointer-events-none" />
</div>
)}
{/* Add locale */}
<div className="relative">
<button
onClick={() => setShowAddLocale((v) => !v)}
disabled={adding}
className="flex items-center gap-1 px-2.5 py-2 rounded-lg bg-white/5 hover:bg-white/10 border border-white/10 text-xs font-semibold text-slate-300 hover:text-white transition-colors disabled:opacity-50"
>
{adding ? <Loader2 size={12} className="animate-spin" /> : <Plus size={12} />}
Dil Ekle
</button>
{showAddLocale && (
<div className="absolute right-0 top-full mt-1 z-50 w-52 rounded-xl border border-white/10 bg-[#1a1a2e] shadow-2xl overflow-hidden">
<div className="flex items-center justify-between px-3 py-2 border-b border-white/5">
<span className="text-[10px] font-bold text-slate-400 uppercase tracking-wider">Dil Seç</span>
<button onClick={() => setShowAddLocale(false)} className="text-slate-500 hover:text-slate-300">
<X size={12} />
</button>
</div>
<div className="max-h-60 overflow-y-auto">
{availableToAdd.length === 0 ? (
<p className="text-xs text-slate-500 px-3 py-3">Tüm diller eklenmiş.</p>
) : (
availableToAdd.map(([code, label]) => (
<button
key={code}
onClick={() => handleAddLocale(code)}
className="w-full text-left px-3 py-2 text-xs text-slate-300 hover:bg-white/5 hover:text-white transition-colors flex items-center justify-between group"
>
<span>{label}</span>
<span className="text-[10px] text-slate-600 group-hover:text-slate-400">{code}</span>
</button>
))
)}
</div>
</div>
)}
</div>
</div>
</div>
{/* Body */}
{loading ? (
<div className="p-8 flex items-center justify-center">
<Loader2 size={20} className="animate-spin text-slate-500" />
</div>
) : localizations.length === 0 ? (
<div className="p-6 text-center text-slate-500 text-sm">
Bu sürüm için lokalizasyon verisi bulunamadı.
</div>
) : (
<div className="p-6 space-y-5">
{FIELDS.map((f) => (
<div key={f.key} className="space-y-1.5">
<div className="flex items-center justify-between">
<label className="text-[10px] font-bold uppercase tracking-wider text-slate-400">
{f.label}
</label>
{f.maxLen && (
<span
className={`text-[10px] font-mono ${
((form[f.key] as string)?.length ?? 0) > f.maxLen
? "text-red-400"
: "text-slate-600"
}`}
>
{(form[f.key] as string)?.length ?? 0}/{f.maxLen}
</span>
)}
</div>
{f.multiline ? (
<textarea
value={(form[f.key] as string) ?? ""}
onChange={(e) => handleChange(f.key, e.target.value)}
rows={f.key === "description" ? 6 : 3}
className="w-full bg-black/30 border border-white/8 rounded-xl px-4 py-3 text-sm text-white placeholder-slate-600 focus:outline-none focus:border-violet-500/50 resize-none font-mono leading-relaxed"
/>
) : (
<input
type="text"
value={(form[f.key] as string) ?? ""}
onChange={(e) => handleChange(f.key, e.target.value)}
className="w-full bg-black/30 border border-white/8 rounded-xl px-4 py-2.5 text-sm text-white placeholder-slate-600 focus:outline-none focus:border-violet-500/50"
/>
)}
</div>
))}
{error && (
<p className="text-xs text-red-400 bg-red-400/10 rounded-lg px-4 py-2">{error}</p>
)}
<button
onClick={handleSave}
disabled={saving}
className="flex items-center gap-2 px-5 py-2.5 rounded-xl bg-violet-600 hover:bg-violet-500 disabled:opacity-50 disabled:cursor-not-allowed text-sm font-bold transition-colors"
>
{saving ? (
<><Loader2 size={14} className="animate-spin" /> Kaydediliyor</>
) : saved ? (
<><CheckCircle2 size={14} className="text-emerald-300" /> Kaydedildi</>
) : (
<><Save size={14} /> Kaydet</>
)}
</button>
</div>
)}
</section>
);
}

View File

@@ -0,0 +1,143 @@
"use client";
import { useState, useTransition } from "react";
import { Star, MessageSquare, Loader2, CheckCircle2, ChevronDown, ChevronUp } from "lucide-react";
function StarRating({ rating }: { rating: number }) {
return (
<div className="flex items-center gap-0.5">
{[1, 2, 3, 4, 5].map((s) => (
<Star
key={s}
size={10}
className={s <= rating ? "fill-yellow-400 text-yellow-400" : "text-slate-700"}
/>
))}
</div>
);
}
const TERRITORY_NAMES: Record<string, string> = {
USA: "🇺🇸", TUR: "🇹🇷", GBR: "🇬🇧", DEU: "🇩🇪", FRA: "🇫🇷",
JPN: "🇯🇵", KOR: "🇰🇷", CHN: "🇨🇳", AUS: "🇦🇺", CAN: "🇨🇦",
BRA: "🇧🇷", IND: "🇮🇳", RUS: "🇷🇺", ESP: "🇪🇸", ITA: "🇮🇹",
};
export default function ReviewCard({ review }: { review: any }) {
const attr = review.attributes ?? {};
const [expanded, setExpanded] = useState(false);
const [showReply, setShowReply] = useState(false);
const [replyText, setReplyText] = useState("");
const [submitting, startTransition] = useTransition();
const [submitted, setSubmitted] = useState(false);
const [error, setError] = useState<string | null>(null);
const bodyPreview = attr.body?.length > 140 ? attr.body.slice(0, 140) + "…" : attr.body;
function handleReply() {
setError(null);
startTransition(async () => {
const res = await fetch("/api/asc/review-response", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ reviewId: review.id, responseBody: replyText }),
});
if (res.ok) {
setSubmitted(true);
setShowReply(false);
setTimeout(() => setSubmitted(false), 3000);
} else {
const d = await res.json();
setError(d.error ?? "Gönderilemedi");
}
});
}
return (
<div className="px-5 py-4 hover:bg-white/[0.02] transition-colors">
{/* Header */}
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<StarRating rating={attr.rating ?? 0} />
<span className="text-[10px] text-slate-500">
{TERRITORY_NAMES[attr.territory] ?? ""} {attr.territory}
</span>
</div>
{attr.title && (
<p className="text-xs font-semibold text-white truncate">{attr.title}</p>
)}
</div>
<div className="text-[10px] text-slate-600 shrink-0">
{attr.createdDate
? new Date(attr.createdDate).toLocaleDateString("tr-TR", {
month: "short", day: "numeric", year: "numeric",
})
: ""}
</div>
</div>
{/* Body */}
<div className="mt-2">
<p className="text-[11px] text-slate-400 leading-relaxed">
{expanded ? attr.body : bodyPreview}
</p>
{attr.body?.length > 140 && (
<button
onClick={() => setExpanded(!expanded)}
className="mt-1 flex items-center gap-0.5 text-[10px] text-slate-500 hover:text-slate-300 transition-colors"
>
{expanded ? <><ChevronUp size={10} /> Daralt</> : <><ChevronDown size={10} /> Devamını gör</>}
</button>
)}
</div>
{/* Reviewer */}
{attr.reviewerNickname && (
<p className="mt-1.5 text-[10px] text-slate-600 italic"> {attr.reviewerNickname}</p>
)}
{/* Reply section */}
<div className="mt-3 flex items-center gap-3">
{submitted ? (
<span className="flex items-center gap-1 text-[10px] text-emerald-400">
<CheckCircle2 size={10} /> Yanıt gönderildi
</span>
) : (
<button
onClick={() => setShowReply(!showReply)}
className="flex items-center gap-1 text-[10px] font-semibold text-violet-400 hover:text-violet-300 transition-colors"
>
<MessageSquare size={10} />
{showReply ? "İptal" : "Yanıtla"}
</button>
)}
</div>
{showReply && (
<div className="mt-3 space-y-2">
<textarea
value={replyText}
onChange={(e) => setReplyText(e.target.value)}
placeholder="Kullanıcıya yanıtınızı yazın…"
rows={3}
maxLength={5900}
className="w-full bg-black/30 border border-white/8 rounded-xl px-3 py-2 text-xs text-white placeholder-slate-600 focus:outline-none focus:border-violet-500/50 resize-none"
/>
<div className="flex items-center justify-between">
{error && <p className="text-[10px] text-red-400">{error}</p>}
<span className="text-[10px] text-slate-600 ml-auto">{replyText.length}/5900</span>
</div>
<button
onClick={handleReply}
disabled={submitting || !replyText.trim()}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-violet-600 hover:bg-violet-500 disabled:opacity-40 text-[11px] font-bold transition-colors"
>
{submitting ? <Loader2 size={10} className="animate-spin" /> : <MessageSquare size={10} />}
Gönder
</button>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,266 @@
"use client";
import { useState, useEffect, useRef, useTransition, useCallback } from "react";
import { Image as ImageIcon, Upload, Trash2, Loader2, Plus, AlertCircle } from "lucide-react";
interface Screenshot {
id: string;
attributes: {
fileName: string;
fileSize: number;
uploadOperations: UploadOperation[] | null;
assetDeliveryState: {
state: "COMPLETE" | "FAILED" | "UPLOAD_COMPLETE" | "AWAITING_UPLOAD";
errors?: { code: string; description: string }[];
} | null;
templateUrl: string | null;
};
}
interface UploadOperation {
method: string;
url: string;
length: number;
offset: number;
requestHeaders: { name: string; value: string }[];
}
interface ScreenshotManagerProps {
versionId: string;
versionString: string;
}
export default function ScreenshotManager({ versionId, versionString }: ScreenshotManagerProps) {
const [localizationId, setLocalizationId] = useState<string | null>(null);
const [screenshots, setScreenshots] = useState<Screenshot[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [uploading, setUploading] = useState(false);
const [deletingId, setDeletingId] = useState<string | null>(null);
const [dragging, setDragging] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const [, startTransition] = useTransition();
const fetchScreenshots = useCallback(async (locId: string) => {
setError(null);
const res = await fetch(`/api/asc/screenshots?localizationId=${locId}`);
const d = await res.json();
if (!res.ok) { setError(d.error ?? "Ekran görüntüleri yüklenemedi"); return; }
setScreenshots(d.screenshots ?? []);
}, []);
useEffect(() => {
setLoading(true);
// Step 1: resolve the first localization id for this version
fetch(`/api/asc/localizations?versionId=${versionId}`)
.then((r) => r.json())
.then(async (d) => {
const locs = d.data ?? [];
if (locs.length === 0) { setLoading(false); return; }
const firstId: string = locs[0].id;
setLocalizationId(firstId);
await fetchScreenshots(firstId);
})
.catch((e) => setError(e.message))
.finally(() => setLoading(false));
}, [versionId, fetchScreenshots]);
async function uploadFile(file: File) {
if (!localizationId) return;
setUploading(true);
setError(null);
try {
// Step 1: Reserve
const reserveRes = await fetch(`/api/asc/screenshots`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ localizationId, fileName: file.name, fileSize: file.size }),
});
const reserveData = await reserveRes.json();
if (!reserveRes.ok) { setError(reserveData.error ?? "Yükleme başlatılamadı"); return; }
const screenshotId: string = reserveData.data?.id;
const uploadOps: UploadOperation[] = reserveData.data?.attributes?.uploadOperations ?? [];
// Step 2: Upload each part
for (const op of uploadOps) {
const slice = file.slice(op.offset, op.offset + op.length);
const headers: Record<string, string> = {};
for (const h of op.requestHeaders) headers[h.name] = h.value;
await fetch(op.url, { method: op.method, headers, body: slice });
}
// Step 3: Commit
const commitRes = await fetch(`/api/asc/screenshots/commit`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ screenshotId }),
});
if (!commitRes.ok) {
const d = await commitRes.json();
setError(d.error ?? "Yükleme tamamlanamadı");
return;
}
await fetchScreenshots(localizationId);
} catch (e) {
setError(e instanceof Error ? e.message : "Bilinmeyen hata");
} finally {
setUploading(false);
}
}
function handleFiles(files: FileList | null) {
if (!files || files.length === 0) return;
startTransition(() => {
Array.from(files).forEach((f) => uploadFile(f));
});
}
async function handleDelete(id: string) {
setDeletingId(id);
setError(null);
try {
const res = await fetch(`/api/asc/screenshots?screenshotId=${id}`, { method: "DELETE" });
if (!res.ok) {
const d = await res.json();
setError(d.error ?? "Silinemedi");
} else {
setScreenshots((prev) => prev.filter((s) => s.id !== id));
}
} finally {
setDeletingId(null);
}
}
function onDragOver(e: React.DragEvent) { e.preventDefault(); setDragging(true); }
function onDragLeave() { setDragging(false); }
function onDrop(e: React.DragEvent) {
e.preventDefault();
setDragging(false);
handleFiles(e.dataTransfer.files);
}
return (
<section className="rounded-2xl border border-white/5 bg-white/[0.03] overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-white/5">
<h2 className="font-bold text-sm flex items-center gap-2">
<ImageIcon size={15} className="text-sky-400" />
Ekran Görüntüleri
<span className="text-[10px] text-slate-500 font-normal">iPhone 6.5 · v{versionString}</span>
<span className="text-[10px] bg-sky-500/10 text-sky-400 border border-sky-500/20 rounded-full px-2 py-0.5">
{screenshots.length}/10
</span>
</h2>
<button
onClick={() => fileInputRef.current?.click()}
disabled={uploading || screenshots.length >= 10 || !localizationId}
className="flex items-center gap-1.5 px-3 py-2 rounded-lg bg-sky-600/20 hover:bg-sky-600/30 border border-sky-500/20 text-xs font-semibold text-sky-300 hover:text-sky-200 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
>
{uploading ? <Loader2 size={12} className="animate-spin" /> : <Plus size={12} />}
Ekle
</button>
<input
ref={fileInputRef}
type="file"
accept="image/png,image/jpeg"
multiple
className="hidden"
onChange={(e) => handleFiles(e.target.files)}
/>
</div>
{/* Body */}
<div className="p-6">
{error && (
<div className="flex items-center gap-2 text-xs text-red-400 bg-red-400/10 rounded-lg px-4 py-2 mb-4">
<AlertCircle size={13} />
{error}
</div>
)}
{loading ? (
<div className="flex items-center justify-center py-12">
<Loader2 size={22} className="animate-spin text-slate-500" />
</div>
) : !localizationId ? (
<p className="text-sm text-slate-500 text-center py-8">
Bu sürüm için lokalizasyon bulunamadı.
</p>
) : (
<>
{screenshots.length > 0 && (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-3 mb-4">
{screenshots.map((s) => {
const src = s.attributes.templateUrl;
const state = s.attributes.assetDeliveryState?.state;
return (
<div
key={s.id}
className="relative group rounded-xl overflow-hidden border border-white/8 bg-black/30 aspect-[9/19.5]"
>
{src ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={src} alt={s.attributes.fileName} className="w-full h-full object-cover" />
) : (
<div className="w-full h-full flex flex-col items-center justify-center gap-1 text-slate-600">
<ImageIcon size={20} />
<span className="text-[9px]">
{state === "AWAITING_UPLOAD" ? "Yükleniyor…" : state ?? "İşleniyor"}
</span>
</div>
)}
<div className="absolute inset-0 bg-black/60 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
<button
onClick={() => handleDelete(s.id)}
disabled={deletingId === s.id}
className="p-2 rounded-lg bg-red-500/20 hover:bg-red-500/40 text-red-400 hover:text-red-300 transition-colors"
>
{deletingId === s.id
? <Loader2 size={14} className="animate-spin" />
: <Trash2 size={14} />}
</button>
</div>
{state && state !== "COMPLETE" && (
<div className="absolute bottom-1 left-1 right-1">
<span className="block text-center text-[9px] bg-black/70 text-amber-400 rounded px-1 py-0.5">
{state === "UPLOAD_COMPLETE" ? "İşleniyor…" : state}
</span>
</div>
)}
</div>
);
})}
</div>
)}
{screenshots.length < 10 && (
<div
onDragOver={onDragOver}
onDragLeave={onDragLeave}
onDrop={onDrop}
onClick={() => !uploading && fileInputRef.current?.click()}
className={`border-2 border-dashed rounded-xl p-8 flex flex-col items-center gap-3 cursor-pointer transition-all ${
dragging ? "border-sky-500 bg-sky-500/10" : "border-white/10 hover:border-white/20 hover:bg-white/[0.02]"
} ${uploading ? "pointer-events-none opacity-60" : ""}`}
>
{uploading ? <Loader2 size={24} className="animate-spin text-sky-400" /> : <Upload size={24} className="text-slate-500" />}
<div className="text-center">
<p className="text-sm text-slate-400 font-medium">
{uploading ? "Yükleniyor…" : "PNG / JPEG sürükle veya tıkla"}
</p>
<p className="text-[11px] text-slate-600 mt-0.5">iPhone 6.5 · Önerilen: 1242 × 2688 px</p>
</div>
</div>
)}
</>
)}
</div>
</section>
);
}

View File

@@ -0,0 +1,480 @@
import { getAppById } from "../../actions";
import {
getAppDetails,
getAppStoreVersions,
getLatestBuilds,
getBetaGroups,
getCustomerReviews,
} from "../../../asc/actions";
import {
ArrowLeft,
Star,
Package,
Users,
TestTube2,
Globe,
ShoppingBag,
AlertCircle,
CheckCircle2,
Clock,
XCircle,
ChevronRight,
ExternalLink,
Apple,
} from "lucide-react";
import Link from "next/link";
import { notFound } from "next/navigation";
import LocalizationEditor from "./LocalizationEditor";
import ReviewCard from "./ReviewCard";
import ScreenshotManager from "./ScreenshotManager";
// ─── Helpers ────────────────────────────────────────────────────────────────
function stateColor(state: string) {
const map: Record<string, string> = {
READY_FOR_SALE: "text-emerald-400 bg-emerald-400/10 border-emerald-400/20",
IN_REVIEW: "text-yellow-400 bg-yellow-400/10 border-yellow-400/20",
WAITING_FOR_REVIEW: "text-blue-400 bg-blue-400/10 border-blue-400/20",
PENDING_DEVELOPER_RELEASE: "text-violet-400 bg-violet-400/10 border-violet-400/20",
PREPARE_FOR_SUBMISSION: "text-slate-400 bg-slate-400/10 border-slate-400/20",
REJECTED: "text-red-400 bg-red-400/10 border-red-400/20",
DEVELOPER_REJECTED: "text-orange-400 bg-orange-400/10 border-orange-400/20",
METADATA_REJECTED: "text-orange-400 bg-orange-400/10 border-orange-400/20",
};
return map[state] ?? "text-slate-400 bg-slate-400/10 border-slate-400/20";
}
function stateIcon(state: string) {
if (state === "READY_FOR_SALE") return <CheckCircle2 size={12} />;
if (state === "REJECTED" || state === "DEVELOPER_REJECTED") return <XCircle size={12} />;
if (state?.includes("REVIEW")) return <Clock size={12} />;
return <AlertCircle size={12} />;
}
function stateLabel(state: string) {
const map: Record<string, string> = {
READY_FOR_SALE: "Satışta",
IN_REVIEW: "İncelemede",
WAITING_FOR_REVIEW: "İnceleme Bekliyor",
PENDING_DEVELOPER_RELEASE: "Geliştirici Yayını Bekliyor",
PREPARE_FOR_SUBMISSION: "Gönderime Hazırlanıyor",
REJECTED: "Reddedildi",
DEVELOPER_REJECTED: "Geliştirici Reddetti",
METADATA_REJECTED: "Metadata Reddedildi",
};
return map[state] ?? state;
}
function processingBadge(state: string) {
if (state === "VALID") return <span className="text-emerald-400 text-[10px] font-bold"> Geçerli</span>;
if (state === "PROCESSING") return <span className="text-yellow-400 text-[10px] font-bold animate-pulse"> İşleniyor</span>;
if (state === "FAILED") return <span className="text-red-400 text-[10px] font-bold"> Başarısız</span>;
return <span className="text-slate-400 text-[10px]">{state}</span>;
}
function StarRating({ rating }: { rating: number }) {
return (
<div className="flex items-center gap-0.5">
{[1, 2, 3, 4, 5].map((s) => (
<Star
key={s}
size={12}
className={s <= rating ? "fill-yellow-400 text-yellow-400" : "text-slate-700"}
/>
))}
</div>
);
}
// ─── Page ────────────────────────────────────────────────────────────────────
export default async function AppStorePage({
params,
searchParams,
}: {
params: Promise<{ id: string }>;
searchParams: Promise<{ versionId?: string }>;
}) {
const { id } = await params;
const { versionId } = await searchParams;
const appId = parseInt(id);
if (isNaN(appId)) notFound();
const app = await getAppById(appId);
if (!app) notFound();
const hasAppleId = !!app.appleId;
// Paralel fetch
const [detailsRes, versionsRes, buildsRes, betaRes, reviewsRes] =
await Promise.all([
hasAppleId ? getAppDetails(app.appleId!) : Promise.resolve({ data: null, error: null }),
hasAppleId ? getAppStoreVersions(app.appleId!) : Promise.resolve({ data: null, error: null }),
hasAppleId ? getLatestBuilds(app.appleId!) : Promise.resolve({ data: null, error: null }),
hasAppleId ? getBetaGroups(app.appleId!) : Promise.resolve({ data: null, error: null }),
hasAppleId ? getCustomerReviews(app.appleId!) : Promise.resolve({ data: null, error: null }),
]);
const versions: any[] = versionsRes.data?.data ?? [];
const liveVersion = versions[0];
// Use the versionId from the query string if provided, otherwise fall back to the first (live) version
const selectedVersion = versionId
? (versions.find((v: any) => v.id === versionId) ?? liveVersion)
: liveVersion;
const builds: any[] = buildsRes.data?.data ?? [];
const betaGroups: any[] = betaRes.data?.data ?? [];
const reviews: any[] = reviewsRes.data?.data ?? [];
const appInfo = detailsRes.data?.data;
// Avg rating
const avgRating =
reviews.length > 0
? reviews.reduce((s: number, r: any) => s + (r.attributes?.rating ?? 0), 0) / reviews.length
: null;
return (
<div className="min-h-screen bg-[#0a0a0f] text-white">
{/* Header */}
<div className="border-b border-white/5 bg-[#0a0a0f]/80 backdrop-blur sticky top-0 z-20">
<div className="max-w-7xl mx-auto px-6 py-4 flex items-center gap-4">
<Link
href={`/apps`}
className="p-2 rounded-lg hover:bg-white/5 transition-colors text-slate-400 hover:text-white"
>
<ArrowLeft size={18} />
</Link>
<div className="flex items-center gap-3 flex-1">
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-violet-500 to-indigo-600 flex items-center justify-center shadow-lg">
<Apple size={18} className="text-white" />
</div>
<div>
<h1 className="font-bold text-base leading-none">{app.name}</h1>
<p className="text-[11px] text-slate-400 mt-0.5">{app.bundleId}</p>
</div>
</div>
{/* Tabs */}
<div className="flex items-center gap-1 bg-white/5 rounded-xl p-1">
{[
{ href: `/apps/${appId}/config`, label: "Remote Config" },
{ href: `/apps/${appId}/store`, label: "App Store", active: true },
].map((tab) => (
<Link
key={tab.href}
href={tab.href}
className={`px-3 py-1.5 rounded-lg text-xs font-semibold transition-all ${
tab.active
? "bg-violet-600 text-white shadow"
: "text-slate-400 hover:text-white"
}`}
>
{tab.label}
</Link>
))}
</div>
</div>
</div>
<div className="max-w-7xl mx-auto px-6 py-8 space-y-8">
{/* No Apple ID warning */}
{!hasAppleId && (
<div className="rounded-2xl border border-orange-500/20 bg-orange-500/5 p-6 flex items-start gap-4">
<AlertCircle size={20} className="text-orange-400 shrink-0 mt-0.5" />
<div>
<p className="font-semibold text-orange-300">Apple ID Tanımlı Değil</p>
<p className="text-sm text-slate-400 mt-1">
App Store Connect verilerini çekebilmek için bu uygulamaya bir Apple ID ekleyin.
</p>
<Link
href={`/apps/${appId}/edit`}
className="mt-3 inline-flex items-center gap-2 text-xs font-bold text-orange-400 hover:text-orange-300 transition-colors"
>
Düzenle <ChevronRight size={12} />
</Link>
</div>
</div>
)}
{/* ── KPIs ─────────────────────────────────────────────────────── */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
{/* Live Version */}
<div className="rounded-2xl border border-white/5 bg-white/[0.03] p-5">
<div className="flex items-center gap-2 text-slate-400 text-xs font-semibold mb-3">
<Package size={13} /> Canlı Sürüm
</div>
{liveVersion ? (
<>
<p className="text-2xl font-bold">
{liveVersion.attributes?.versionString ?? "—"}
</p>
<span
className={`mt-2 inline-flex items-center gap-1 text-[10px] font-bold px-2 py-0.5 rounded-full border ${stateColor(
liveVersion.attributes?.appStoreState
)}`}
>
{stateIcon(liveVersion.attributes?.appStoreState)}
{stateLabel(liveVersion.attributes?.appStoreState)}
</span>
</>
) : (
<p className="text-slate-500 text-sm"></p>
)}
</div>
{/* Avg Rating */}
<div className="rounded-2xl border border-white/5 bg-white/[0.03] p-5">
<div className="flex items-center gap-2 text-slate-400 text-xs font-semibold mb-3">
<Star size={13} /> Ortalama Puan
</div>
{avgRating !== null ? (
<>
<p className="text-2xl font-bold">{avgRating.toFixed(1)}</p>
<StarRating rating={Math.round(avgRating)} />
<p className="text-[10px] text-slate-500 mt-1">{reviews.length} yorum</p>
</>
) : (
<p className="text-slate-500 text-sm"></p>
)}
</div>
{/* Builds */}
<div className="rounded-2xl border border-white/5 bg-white/[0.03] p-5">
<div className="flex items-center gap-2 text-slate-400 text-xs font-semibold mb-3">
<Package size={13} /> Son Build
</div>
{builds[0] ? (
<>
<p className="text-2xl font-bold">
{builds[0].attributes?.version ?? "—"}
</p>
<div className="mt-1">
{processingBadge(builds[0].attributes?.processingState)}
</div>
<p className="text-[10px] text-slate-500 mt-1">
{builds[0].attributes?.uploadedDate
? new Date(builds[0].attributes.uploadedDate).toLocaleDateString("tr-TR")
: ""}
</p>
</>
) : (
<p className="text-slate-500 text-sm"></p>
)}
</div>
{/* Beta Testers */}
<div className="rounded-2xl border border-white/5 bg-white/[0.03] p-5">
<div className="flex items-center gap-2 text-slate-400 text-xs font-semibold mb-3">
<TestTube2 size={13} /> TestFlight Grup
</div>
<p className="text-2xl font-bold">{betaGroups.length}</p>
<p className="text-[10px] text-slate-500 mt-1">aktif grup</p>
</div>
</div>
{/* ── Main Grid ────────────────────────────────────────────────── */}
<div className="grid grid-cols-1 xl:grid-cols-3 gap-6">
{/* LEFT: Versions + Localizations */}
<div className="xl:col-span-2 space-y-6">
{/* Sürümler */}
<section className="rounded-2xl border border-white/5 bg-white/[0.03] overflow-hidden">
<div className="flex items-center justify-between px-6 py-4 border-b border-white/5">
<h2 className="font-bold text-sm flex items-center gap-2">
<Globe size={15} className="text-violet-400" /> App Store Sürümleri
</h2>
</div>
{versions.length === 0 ? (
<div className="p-6 text-center text-slate-500 text-sm">Sürüm bulunamadı.</div>
) : (
<div className="divide-y divide-white/5">
{versions.map((v: any) => {
const isSelected = selectedVersion?.id === v.id;
return (
<div
key={v.id}
className={`px-6 py-4 flex items-center justify-between transition-colors ${
isSelected
? "bg-violet-500/10 border-l-2 border-violet-500"
: "hover:bg-white/[0.02]"
}`}
>
<div>
<p className="font-semibold text-sm">
v{v.attributes?.versionString}
{isSelected && (
<span className="ml-2 text-[9px] font-bold text-violet-400 uppercase tracking-wider">
Seçili
</span>
)}
</p>
<p className="text-[10px] text-slate-500 mt-0.5">
{v.attributes?.createdDate
? new Date(v.attributes.createdDate).toLocaleDateString("tr-TR", {
year: "numeric", month: "long", day: "numeric",
})
: ""}
{v.attributes?.copyright && ` · ${v.attributes.copyright}`}
</p>
</div>
<div className="flex items-center gap-3">
<span className={`inline-flex items-center gap-1 text-[10px] font-bold px-2.5 py-1 rounded-full border ${stateColor(v.attributes?.appStoreState)}`}>
{stateIcon(v.attributes?.appStoreState)}
{stateLabel(v.attributes?.appStoreState)}
</span>
{/* Localization Link */}
<Link
href={`/apps/${appId}/store?versionId=${v.id}`}
className={`text-[10px] font-semibold flex items-center gap-1 ${
isSelected
? "text-violet-300 cursor-default pointer-events-none"
: "text-violet-400 hover:text-violet-300"
}`}
>
Metadata <ChevronRight size={10} />
</Link>
</div>
</div>
);
})}
</div>
)}
</section>
{/* Localization Editor — shows selected version (or live version) */}
{selectedVersion && (
<LocalizationEditor
versionId={selectedVersion.id}
versionString={selectedVersion.attributes?.versionString}
/>
)}
{/* Screenshot Manager (iPhone 6.5") */}
{selectedVersion && (
<ScreenshotManager
versionId={selectedVersion.id}
versionString={selectedVersion.attributes?.versionString}
/>
)}
{/* Builds */}
<section className="rounded-2xl border border-white/5 bg-white/[0.03] overflow-hidden">
<div className="px-6 py-4 border-b border-white/5">
<h2 className="font-bold text-sm flex items-center gap-2">
<Package size={15} className="text-indigo-400" /> Son Buildler
</h2>
</div>
{builds.length === 0 ? (
<div className="p-6 text-center text-slate-500 text-sm">Build bulunamadı.</div>
) : (
<div className="divide-y divide-white/5">
{builds.map((b: any) => (
<div key={b.id} className="px-6 py-4 flex items-center justify-between hover:bg-white/[0.02]">
<div>
<p className="font-semibold text-sm">Build {b.attributes?.version}</p>
<p className="text-[10px] text-slate-500 mt-0.5">
iOS {b.attributes?.minOsVersion}+
{b.attributes?.uploadedDate
? ` · ${new Date(b.attributes.uploadedDate).toLocaleDateString("tr-TR")}`
: ""}
</p>
</div>
<div>{processingBadge(b.attributes?.processingState)}</div>
</div>
))}
</div>
)}
</section>
</div>
{/* RIGHT: Reviews + TestFlight */}
<div className="space-y-6">
{/* Yorumlar */}
<section className="rounded-2xl border border-white/5 bg-white/[0.03] overflow-hidden">
<div className="flex items-center justify-between px-6 py-4 border-b border-white/5">
<h2 className="font-bold text-sm flex items-center gap-2">
<Star size={15} className="text-yellow-400" /> Kullanıcı Yorumları
</h2>
{avgRating !== null && (
<span className="text-xs text-slate-400 font-medium">{avgRating.toFixed(1)} </span>
)}
</div>
{reviews.length === 0 ? (
<div className="p-6 text-center text-slate-500 text-sm">
{hasAppleId ? "Yorum bulunamadı." : "Apple ID gerekli."}
</div>
) : (
<div className="divide-y divide-white/5 max-h-[480px] overflow-y-auto">
{reviews.map((r: any) => (
<ReviewCard key={r.id} review={r} />
))}
</div>
)}
</section>
{/* TestFlight Grupları */}
<section className="rounded-2xl border border-white/5 bg-white/[0.03] overflow-hidden">
<div className="px-6 py-4 border-b border-white/5">
<h2 className="font-bold text-sm flex items-center gap-2">
<TestTube2 size={15} className="text-sky-400" /> TestFlight Grupları
</h2>
</div>
{betaGroups.length === 0 ? (
<div className="p-6 text-center text-slate-500 text-sm">
{hasAppleId ? "Grup bulunamadı." : "Apple ID gerekli."}
</div>
) : (
<div className="divide-y divide-white/5">
{betaGroups.map((g: any) => (
<div key={g.id} className="px-6 py-4 hover:bg-white/[0.02]">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-semibold">{g.attributes?.name}</p>
<p className="text-[10px] text-slate-500 mt-0.5">
{g.attributes?.isInternalGroup ? "İç Grup" : "Dış Grup"}
</p>
</div>
{g.attributes?.publicLinkEnabled && (
<a
href={g.attributes.publicLink}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-[10px] text-sky-400 hover:text-sky-300 font-semibold"
>
Link <ExternalLink size={9} />
</a>
)}
</div>
{g.attributes?.publicLinkLimitEnabled && (
<p className="text-[10px] text-slate-500 mt-1">
Limit: {g.attributes.publicLinkLimit} kişi
</p>
)}
</div>
))}
</div>
)}
</section>
{/* App Store Link */}
{appInfo && (
<a
href={`https://apps.apple.com/app/id${app.appleId}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-between w-full px-5 py-4 rounded-2xl border border-violet-500/20 bg-violet-500/5 hover:bg-violet-500/10 transition-colors group"
>
<div className="flex items-center gap-3">
<ShoppingBag size={16} className="text-violet-400" />
<span className="text-sm font-semibold text-violet-300">App Store'da Görüntüle</span>
</div>
<ExternalLink size={14} className="text-violet-400 group-hover:translate-x-0.5 transition-transform" />
</a>
)}
</div>
</div>
</div>
</div>
);
}

72
app/apps/actions.ts Normal file
View File

@@ -0,0 +1,72 @@
"use server";
import { db } from "@/db";
import { apps } from "@/db/schema";
import { eq, desc } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
export async function getApps() {
return await db.query.apps.findMany({
orderBy: [desc(apps.createdAt)],
});
}
export async function getAppById(id: number) {
if (isNaN(id)) return null;
return await db.query.apps.findFirst({
where: eq(apps.id, id),
});
}
export async function createApp(formData: FormData) {
const name = (formData.get("name") as string)?.trim();
const bundleId = (formData.get("bundleId") as string)?.trim();
const platform = formData.get("platform") as string;
const appleId = (formData.get("appleId") as string)?.trim() || null;
if (!name || !bundleId) throw new Error("İsim ve Bundle ID zorunludur.");
await db.insert(apps).values({
name,
bundleId,
platform: platform as any,
appleId: appleId,
});
revalidatePath("/apps");
revalidatePath("/");
redirect("/apps");
}
export async function updateApp(id: number, formData: FormData) {
if (isNaN(id)) throw new Error("Geçersiz Uygulama ID");
const name = (formData.get("name") as string)?.trim();
const bundleId = (formData.get("bundleId") as string)?.trim();
const platform = formData.get("platform") as string;
const appleId = (formData.get("appleId") as string)?.trim() || null;
const status = formData.get("status") as string;
if (!name || !bundleId) throw new Error("İsim ve Bundle ID zorunludur.");
await db.update(apps).set({
name,
bundleId,
platform: platform as any,
appleId: appleId,
status: status as any,
updatedAt: new Date(),
}).where(eq(apps.id, id));
revalidatePath("/apps");
revalidatePath("/");
redirect("/apps");
}
export async function deleteApp(id: number) {
if (isNaN(id)) return;
await db.delete(apps).where(eq(apps.id, id));
revalidatePath("/apps");
revalidatePath("/");
}

264
app/apps/page.tsx Normal file
View File

@@ -0,0 +1,264 @@
import { getApps, createApp, deleteApp } from "./actions";
import {
Smartphone,
Plus,
ArrowLeft,
Globe,
Apple,
Play,
Settings,
ShoppingBag,
SlidersHorizontal,
ExternalLink,
PackageSearch,
} from "lucide-react";
import Link from "next/link";
import { revalidatePath } from "next/cache";
import DeleteButton from "./DeleteButton";
export default async function AppsPage() {
const allApps = await getApps();
async function handleDelete(formData: FormData) {
"use server";
const id = Number(formData.get("id"));
await deleteApp(id);
revalidatePath("/apps");
}
return (
<div className="flex h-screen bg-zinc-50 dark:bg-black font-sans text-zinc-900 dark:text-zinc-100">
{/* Sidebar */}
<aside className="w-64 bg-white dark:bg-zinc-900 border-r border-zinc-200 dark:border-zinc-800 flex flex-col">
<div className="p-6 border-b border-zinc-200 dark:border-zinc-800 flex items-center gap-3">
<Link
href="/"
className="w-8 h-8 bg-black dark:bg-white rounded-lg flex items-center justify-center"
>
<span className="text-white dark:text-black font-bold text-lg">A</span>
</Link>
<span className="font-bold text-lg tracking-tight">AppAdmin</span>
</div>
<nav className="flex-1 p-4 space-y-1">
<Link
href="/"
className="flex items-center gap-3 px-3 py-2 text-zinc-500 hover:bg-zinc-100 dark:hover:bg-zinc-800 rounded-lg text-sm font-medium transition-colors"
>
<Globe size={18} /> Dashboard
</Link>
<Link
href="/apps"
className="flex items-center gap-3 px-3 py-2 bg-zinc-100 dark:bg-zinc-800 rounded-lg text-sm font-medium"
>
<Smartphone size={18} /> Uygulamalar
</Link>
<div className="px-3 py-2 text-zinc-400 text-[10px] uppercase font-bold tracking-wider mt-4">
Sistem
</div>
<Link
href="/config"
className="flex items-center gap-3 px-3 py-2 text-zinc-500 hover:bg-zinc-100 dark:hover:bg-zinc-800 rounded-lg text-sm font-medium transition-colors"
>
<SlidersHorizontal size={18} /> Remote Config
</Link>
</nav>
</aside>
<main className="flex-1 overflow-y-auto p-8">
<div className="max-w-5xl mx-auto space-y-8">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold">Uygulama Yönetimi</h1>
<p className="text-zinc-500 mt-1">
Sisteme kayıtlı tüm mobil uygulamalarınızı buradan yönetin.
</p>
</div>
<Link
href="/"
className="flex items-center gap-2 text-sm text-zinc-500 hover:text-black dark:hover:text-white transition-colors"
>
<ArrowLeft size={16} /> Geri Dön
</Link>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* Yeni Uygulama Formu */}
<div className="lg:col-span-1">
<div className="bg-white dark:bg-zinc-900 p-6 rounded-2xl border border-zinc-200 dark:border-zinc-800 shadow-sm sticky top-8">
<h2 className="text-lg font-bold mb-4 flex items-center gap-2">
<Plus size={20} className="text-blue-500" /> Yeni Uygulama Ekle
</h2>
<form action={createApp} className="space-y-4">
<div>
<label className="block text-xs font-bold uppercase text-zinc-500 mb-1 ml-1">
Uygulama Adı
</label>
<input
name="name"
required
className="w-full px-4 py-2.5 rounded-xl border border-zinc-200 dark:border-zinc-800 bg-zinc-50 dark:bg-black text-sm focus:ring-2 focus:ring-black dark:focus:ring-white transition-all"
placeholder="Örn: X-Tracker Pro"
/>
</div>
<div>
<label className="block text-xs font-bold uppercase text-zinc-500 mb-1 ml-1">
Bundle ID
</label>
<input
name="bundleId"
required
className="w-full px-4 py-2.5 rounded-xl border border-zinc-200 dark:border-zinc-800 bg-zinc-50 dark:bg-black text-sm font-mono focus:ring-2 focus:ring-black dark:focus:ring-white transition-all"
placeholder="com.company.app"
/>
</div>
<div>
<label className="block text-xs font-bold uppercase text-zinc-500 mb-1 ml-1">
Platform
</label>
<select
name="platform"
className="w-full px-4 py-2.5 rounded-xl border border-zinc-200 dark:border-zinc-800 bg-zinc-50 dark:bg-black text-sm focus:ring-2 focus:ring-black dark:focus:ring-white transition-all"
>
<option value="ios">iOS (App Store)</option>
<option value="android">Android (Play Store)</option>
<option value="dual">Dual (Cross Platform)</option>
</select>
</div>
<div>
<label className="block text-xs font-bold uppercase text-zinc-500 mb-1 ml-1">
Apple App ID (Opsiyonel)
</label>
<input
name="appleId"
className="w-full px-4 py-2.5 rounded-xl border border-zinc-200 dark:border-zinc-800 bg-zinc-50 dark:bg-black text-sm focus:ring-2 focus:ring-black dark:focus:ring-white transition-all"
placeholder="123456789"
/>
</div>
<button
type="submit"
className="w-full py-3 bg-black dark:bg-white text-white dark:text-black rounded-xl font-bold text-sm hover:opacity-90 transition-opacity mt-2"
>
Uygulamayı Kaydet
</button>
</form>
</div>
</div>
{/* Uygulama Listesi */}
<div className="lg:col-span-2 space-y-4">
{allApps.length === 0 ? (
<div className="bg-white dark:bg-zinc-900 border-2 border-dashed border-zinc-200 dark:border-zinc-800 rounded-3xl p-12 text-center">
<div className="w-16 h-16 bg-zinc-100 dark:bg-zinc-800 rounded-full flex items-center justify-center mx-auto mb-4">
<PackageSearch className="text-zinc-400" size={32} />
</div>
<h3 className="text-lg font-bold">Henüz uygulama yok</h3>
<p className="text-zinc-500 text-sm max-w-xs mx-auto mt-2">
Sol taraftaki formu kullanarak ilk uygulamanızı sisteme kaydedin.
</p>
</div>
) : (
<div className="grid grid-cols-1 gap-4">
{allApps.map((app) => (
<div
key={app.id}
className="bg-white dark:bg-zinc-900 p-6 rounded-2xl border border-zinc-200 dark:border-zinc-800 shadow-sm hover:border-zinc-300 dark:hover:border-zinc-700 transition-all"
>
{/* App info row */}
<div className="flex items-center gap-5 mb-5">
<div
className={`w-14 h-14 rounded-2xl flex items-center justify-center shrink-0 ${
app.platform === "ios"
? "bg-zinc-100 dark:bg-white/5"
: "bg-green-50 dark:bg-green-900/10"
}`}
>
{app.platform === "ios" ? (
<Apple className="text-zinc-900 dark:text-white" size={28} />
) : (
<Play className="text-green-600" size={28} />
)}
</div>
<div className="flex-1 min-w-0">
<h3 className="text-lg font-bold leading-none mb-1 truncate">
{app.name}
</h3>
<div className="flex items-center gap-3 flex-wrap">
<span className="text-xs font-mono text-zinc-500 truncate">
{app.bundleId}
</span>
<span className="w-1 h-1 bg-zinc-300 rounded-full shrink-0" />
<span className="text-[10px] uppercase font-bold text-zinc-400 tracking-widest">
{app.platform}
</span>
{app.appleId && (
<>
<span className="w-1 h-1 bg-zinc-300 rounded-full shrink-0" />
<span className="text-[10px] font-mono text-zinc-400">
#{app.appleId}
</span>
</>
)}
</div>
</div>
</div>
{/* Action buttons row */}
<div className="flex items-center gap-2 flex-wrap pt-4 border-t border-zinc-100 dark:border-zinc-800">
{/* App Store */}
<Link
href={`/apps/${app.id}/store`}
className="flex items-center gap-1.5 px-3 py-2 bg-violet-50 dark:bg-violet-900/20 text-violet-700 dark:text-violet-400 border border-violet-200 dark:border-violet-800 rounded-xl text-xs font-bold hover:bg-violet-100 dark:hover:bg-violet-900/40 transition-colors"
>
<ShoppingBag size={13} /> App Store
</Link>
{/* Remote Config */}
<Link
href={`/apps/${app.id}/config`}
className="flex items-center gap-1.5 px-3 py-2 bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-400 border border-blue-200 dark:border-blue-800 rounded-xl text-xs font-bold hover:bg-blue-100 dark:hover:bg-blue-900/40 transition-colors"
>
<SlidersHorizontal size={13} /> Remote Config
</Link>
{/* Edit */}
<Link
href={`/apps/${app.id}/edit`}
className="flex items-center gap-1.5 px-3 py-2 bg-zinc-50 dark:bg-zinc-800 text-zinc-700 dark:text-zinc-300 border border-zinc-200 dark:border-zinc-700 rounded-xl text-xs font-bold hover:bg-zinc-100 dark:hover:bg-zinc-700 transition-colors"
>
<Settings size={13} /> Düzenle
</Link>
{/* Apple Store external link */}
{app.appleId && (
<a
href={`https://apps.apple.com/app/id${app.appleId}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1.5 px-3 py-2 bg-zinc-50 dark:bg-zinc-800 text-zinc-500 dark:text-zinc-400 border border-zinc-200 dark:border-zinc-700 rounded-xl text-xs font-bold hover:text-black dark:hover:text-white transition-colors"
>
<ExternalLink size={13} /> App Store'da Gör
</a>
)}
{/* Spacer */}
<div className="flex-1" />
{/* Delete */}
<DeleteButton
appId={app.id}
appName={app.name}
deleteAction={handleDelete}
/>
</div>
</div>
))}
</div>
)}
</div>
</div>
</div>
</main>
</div>
);
}

157
app/asc/actions.ts Normal file
View File

@@ -0,0 +1,157 @@
"use server";
import { appleApiRequest } from "./api";
import { revalidatePath } from "next/cache";
// ─── App Info ───────────────────────────────────────────────────────────────
export async function getAppDetails(appleId: string) {
if (!appleId) return { data: null, error: "Apple ID eksik" };
return await appleApiRequest(`/apps/${appleId}?fields[apps]=name,bundleId,sku,primaryLocale,contentRightsDeclaration,isOrEverWasMadeForKids`);
}
// ─── App Store Versions ─────────────────────────────────────────────────────
export async function getAppStoreVersions(appleId: string) {
if (!appleId) return { data: null, error: "Apple ID eksik" };
return await appleApiRequest(
`/apps/${appleId}/appStoreVersions?filter[platform]=IOS&limit=5&fields[appStoreVersions]=versionString,appStoreState,releaseType,createdDate,downloadable,copyright`
);
}
export async function getActiveVersion(appleId: string) {
if (!appleId) return { data: null, error: "Apple ID eksik" };
return await appleApiRequest(
`/apps/${appleId}/appStoreVersions?filter[platform]=IOS&filter[appStoreState]=READY_FOR_SALE,IN_REVIEW,WAITING_FOR_REVIEW,PENDING_DEVELOPER_RELEASE,PREPARE_FOR_SUBMISSION&limit=1&fields[appStoreVersions]=versionString,appStoreState,releaseType,createdDate,copyright`
);
}
// ─── Localizations (Metadata) ───────────────────────────────────────────────
export async function getVersionLocalizations(versionId: string) {
if (!versionId) return { data: null, error: "Version ID eksik" };
return await appleApiRequest(
`/appStoreVersions/${versionId}/appStoreVersionLocalizations?fields[appStoreVersionLocalizations]=locale,name,subtitle,description,keywords,whatsNew,promotionalText,marketingUrl,supportUrl`
);
}
export async function updateLocalization(
localizationId: string,
payload: {
name?: string;
subtitle?: string;
description?: string;
keywords?: string;
whatsNew?: string;
promotionalText?: string;
marketingUrl?: string;
supportUrl?: string;
}
) {
return await appleApiRequest(`/appStoreVersionLocalizations/${localizationId}`, {
method: "PATCH",
body: JSON.stringify({
data: {
type: "appStoreVersionLocalizations",
id: localizationId,
attributes: payload,
},
}),
});
}
// ─── Reviews & Ratings ──────────────────────────────────────────────────────
export async function getCustomerReviews(appleId: string) {
if (!appleId) return { data: null, error: "Apple ID eksik" };
return await appleApiRequest(
`/apps/${appleId}/customerReviews?sort=-createdDate&limit=10&fields[customerReviews]=rating,title,body,reviewerNickname,createdDate,territory`
);
}
export async function respondToReview(reviewId: string, responseBody: string) {
// Check existing response first
const existing = await appleApiRequest(`/customerReviews/${reviewId}/response`);
if (existing.data?.data) {
// PATCH
return await appleApiRequest(`/customerReviewResponses/${existing.data.data.id}`, {
method: "PATCH",
body: JSON.stringify({
data: {
type: "customerReviewResponses",
id: existing.data.data.id,
attributes: { responseBody },
},
}),
});
} else {
// POST
return await appleApiRequest(`/customerReviewResponses`, {
method: "POST",
body: JSON.stringify({
data: {
type: "customerReviewResponses",
attributes: { responseBody },
relationships: {
review: { data: { type: "customerReviews", id: reviewId } },
},
},
}),
});
}
}
// ─── Builds ─────────────────────────────────────────────────────────────────
export async function getLatestBuilds(appleId: string) {
if (!appleId) return { data: null, error: "Apple ID eksik" };
return await appleApiRequest(
`/builds?filter[app]=${appleId}&limit=10&sort=-uploadedDate&fields[builds]=version,uploadedDate,processingState,usesNonExemptEncryption,minOsVersion`
);
}
// ─── TestFlight ─────────────────────────────────────────────────────────────
export async function getBetaGroups(appleId: string) {
if (!appleId) return { data: null, error: "Apple ID eksik" };
return await appleApiRequest(
`/apps/${appleId}/betaGroups?fields[betaGroups]=name,isInternalGroup,publicLink,publicLinkEnabled,publicLinkLimit,publicLinkLimitEnabled,createdDate`
);
}
export async function getBetaTesters(groupId: string) {
if (!groupId) return { data: null, error: "Group ID eksik" };
return await appleApiRequest(
`/betaGroups/${groupId}/betaTesters?fields[betaTesters]=firstName,lastName,email,inviteType,state&limit=25`
);
}
// ─── Pricing ────────────────────────────────────────────────────────────────
export async function getAppPricing(appleId: string) {
if (!appleId) return { data: null, error: "Apple ID eksik" };
return await appleApiRequest(
`/apps/${appleId}/pricePoints?filter[territory]=USA&fields[appPricePoints]=customerPrice,proceeds,territory&limit=5`
);
}
// ─── App Store Review Submissions ───────────────────────────────────────────
export async function getReviewSubmissions(appleId: string) {
if (!appleId) return { data: null, error: "Apple ID eksik" };
return await appleApiRequest(
`/appStoreVersionSubmissions?filter[appStoreVersion.app]=${appleId}&limit=5`
);
}
// ─── Analytics Summary (via App Store Version) ──────────────────────────────
export async function getAppStatus(appleId: string) {
if (!appleId) return { data: null, error: "Apple ID eksik" };
return await getActiveVersion(appleId);
}
export async function getTestFlightGroups(appleId: string) {
return getBetaGroups(appleId);
}

47
app/asc/api.ts Normal file
View File

@@ -0,0 +1,47 @@
import * as jose from 'jose';
const ISSUER_ID = process.env.ASC_ISSUER_ID!;
const KEY_ID = process.env.ASC_KEY_ID!;
const PRIVATE_KEY = process.env.ASC_PRIVATE_KEY?.replace(/\\n/g, '\n')!;
/**
* App Store Connect API için JWT oluşturur (20 dakika geçerli)
*/
async function generateJwt() {
const privateKey = await jose.importPKCS8(PRIVATE_KEY, 'ES256');
return await new jose.SignJWT({})
.setProtectedHeader({ alg: 'ES256', kid: KEY_ID, typ: 'JWT' })
.setIssuer(ISSUER_ID)
.setAudience('appstoreconnect-v1')
.setIssuedAt()
.setExpirationTime('20m')
.sign(privateKey);
}
/**
* Apple Store Connect API'ye istek atar
*/
export async function appleApiRequest(path: string, options: RequestInit = {}) {
const token = await generateJwt();
const url = `https://api.appstoreconnect.apple.com/v1${path}`;
const response = await fetch(url, {
...options,
headers: {
...options.headers,
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
});
const data = await response.json();
if (!response.ok) {
console.error('Apple API Error:', data);
return { data: null, error: data.errors?.[0]?.detail || 'Unknown error' };
}
return { data, error: null };
}

19
app/asc/utils.ts Normal file
View File

@@ -0,0 +1,19 @@
import { exec } from "child_process";
import { promisify } from "util";
const execAsync = promisify(exec);
export async function runAscCommand(command: string) {
// Use environment variables for authentication
// The asc CLI usually looks for these or can be passed as flags
const authFlags = `--issuer ${process.env.ASC_ISSUER_ID} --key-id ${process.env.ASC_KEY_ID} --key "${process.env.ASC_PRIVATE_KEY?.replace(/\\n/g, '\n')}"`;
try {
const { stdout, stderr } = await execAsync(`asc ${command} ${authFlags}`);
if (stderr) console.error("ASC Error:", stderr);
return { data: stdout, error: stderr };
} catch (error: any) {
console.error("Exec Error:", error);
return { data: null, error: error.message };
}
}

48
app/config/actions.ts Normal file
View File

@@ -0,0 +1,48 @@
"use server";
import { db } from "@/db";
import { remoteConfig } from "@/db/schema";
import { eq, and } from "drizzle-orm";
/** Fetch all active config entries for a given app */
export async function getAppConfigs(appId: number) {
return await db.query.remoteConfig.findMany({
where: eq(remoteConfig.appId, appId),
orderBy: (rc, { asc }) => [asc(rc.configKey)],
});
}
/**
* Insert or update a config key for an app.
* If the key already exists for this appId, it is overwritten.
*/
export async function upsertConfig(
appId: number,
key: string,
value: unknown
) {
const existing = await db.query.remoteConfig.findFirst({
where: and(
eq(remoteConfig.appId, appId),
eq(remoteConfig.configKey, key)
),
});
if (existing) {
await db
.update(remoteConfig)
.set({ configValue: value, updatedAt: new Date() })
.where(eq(remoteConfig.id, existing.id));
} else {
await db.insert(remoteConfig).values({
appId,
configKey: key,
configValue: value,
});
}
}
/** Delete a config entry by its primary key */
export async function deleteConfig(id: number) {
await db.delete(remoteConfig).where(eq(remoteConfig.id, id));
}

View File

@@ -1,26 +1,219 @@
@import "tailwindcss";
:root {
--background: #ffffff;
--foreground: #171717;
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
@theme {
--font-sans: var(--font-inter), var(--font-geist-sans), ui-sans-serif, system-ui, sans-serif;
--font-mono: var(--font-geist-mono);
/* Custom color tokens */
--color-brand-50: #f0f4ff;
--color-brand-100: #e0e9ff;
--color-brand-500: #6366f1;
--color-brand-600: #4f46e5;
--color-brand-700: #4338ca;
--color-surface-0: #0a0a0f;
--color-surface-1: #111118;
--color-surface-2: #16161f;
--color-surface-3: #1e1e2a;
--color-surface-4: #252535;
--color-border-subtle: rgba(255, 255, 255, 0.06);
--color-border-default: rgba(255, 255, 255, 0.10);
--color-border-strong: rgba(255, 255, 255, 0.16);
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
*,
*::before,
*::after {
box-sizing: border-box;
}
html {
scroll-behavior: smooth;
}
body {
background: var(--background);
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
background-color: #0a0a0f;
color: #e4e4f0;
font-family: var(--font-inter), var(--font-geist-sans), ui-sans-serif, system-ui, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
line-height: 1.5;
}
/* Custom scrollbar */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.12);
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.2);
}
/* Glass card effect */
.glass {
background: rgba(22, 22, 31, 0.8);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid rgba(255, 255, 255, 0.08);
}
.glass-hover {
transition: background 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease;
}
.glass-hover:hover {
background: rgba(30, 30, 42, 0.9);
border-color: rgba(255, 255, 255, 0.14);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
}
/* Gradient text */
.gradient-text {
background: linear-gradient(135deg, #a78bfa 0%, #818cf8 50%, #38bdf8 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
/* Glow effects */
.glow-purple {
box-shadow: 0 0 20px rgba(139, 92, 246, 0.3), 0 0 60px rgba(139, 92, 246, 0.1);
}
.glow-blue {
box-shadow: 0 0 20px rgba(59, 130, 246, 0.3), 0 0 60px rgba(59, 130, 246, 0.1);
}
/* Subtle animated gradient background */
@keyframes gradient-shift {
0%,
100% {
background-position: 0% 50%;
}
50% {
background-position: 100% 50%;
}
}
.animated-gradient {
background: linear-gradient(-45deg, #0a0a0f, #0f0f1a, #0a0f1f, #0a0a0f);
background-size: 400% 400%;
animation: gradient-shift 15s ease infinite;
}
/* Pulse dot animation */
@keyframes pulse-dot {
0%,
100% {
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.6;
transform: scale(0.85);
}
}
.pulse-dot {
animation: pulse-dot 2s ease-in-out infinite;
}
/* Fade in animations */
@keyframes fade-in-up {
from {
opacity: 0;
transform: translateY(12px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-fade-in-up {
animation: fade-in-up 0.4s ease forwards;
}
/* Shine effect */
@keyframes shine {
from {
transform: translateX(-100%) skewX(-12deg);
}
to {
transform: translateX(200%) skewX(-12deg);
}
}
.shine-effect::after {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.04), transparent);
animation: shine 3s ease-in-out infinite;
}
/* Number counter animation */
@keyframes count-up {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.stat-value {
animation: count-up 0.5s ease forwards;
}
/* Sidebar active glow */
.nav-active {
background: linear-gradient(135deg, rgba(99, 102, 241, 0.2), rgba(139, 92, 246, 0.15));
border: 1px solid rgba(99, 102, 241, 0.3);
color: #a78bfa;
box-shadow: 0 0 20px rgba(99, 102, 241, 0.15), inset 0 1px 0 rgba(255, 255, 255, 0.06);
}
/* Focus ring */
*:focus-visible {
outline: 2px solid rgba(99, 102, 241, 0.6);
outline-offset: 2px;
}
/* Input styles */
input::placeholder {
color: rgba(255, 255, 255, 0.25);
}
/* Status badge animation */
@keyframes status-pulse {
0%,
100% {
box-shadow: 0 0 0 0 currentColor;
}
50% {
box-shadow: 0 0 0 4px transparent;
}
}

39
app/items/actions.ts Normal file
View File

@@ -0,0 +1,39 @@
"use server";
import { db } from "@/db";
import { items } from "@/db/schema";
import { eq, desc } from "drizzle-orm";
import { revalidatePath } from "next/cache";
export async function getItems() {
return await db.query.items.findMany({
orderBy: [desc(items.createdAt)],
});
}
export async function createItem(formData: FormData) {
const key = formData.get("key") as string;
const title = formData.get("title") as string;
const content = formData.get("content") as string;
const type = formData.get("type") as string;
await db.insert(items).values({
key,
title,
content,
type,
});
revalidatePath("/");
}
export async function deleteItem(id: number) {
await db.delete(items).where(eq(items.id, id));
revalidatePath("/");
}
export async function getItemByKey(key: string) {
return await db.query.items.findFirst({
where: eq(items.key, key),
});
}

View File

@@ -1,5 +1,5 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import { Geist, Geist_Mono, Inter } from "next/font/google";
import "./globals.css";
const geistSans = Geist({
@@ -12,9 +12,15 @@ const geistMono = Geist_Mono({
subsets: ["latin"],
});
const inter = Inter({
variable: "--font-inter",
subsets: ["latin"],
weight: ["300", "400", "500", "600", "700", "800", "900"],
});
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
title: "AppAdmin - Merkezi Uygulama Yönetimi",
description: "Mobil uygulamalarınız için merkezi yönetim paneli",
};
export default function RootLayout({
@@ -24,10 +30,13 @@ export default function RootLayout({
}>) {
return (
<html
lang="en"
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
lang="tr"
className={`${geistSans.variable} ${geistMono.variable} ${inter.variable} h-full antialiased`}
suppressHydrationWarning
>
<body className="min-h-full flex flex-col">{children}</body>
<body className="min-h-full flex flex-col text-slate-900 dark:text-slate-100">
{children}
</body>
</html>
);
}

47
app/login/actions.ts Normal file
View File

@@ -0,0 +1,47 @@
"use server";
import { db } from "@/db";
import { users } from "@/db/schema";
import { eq } from "drizzle-orm";
import bcrypt from "bcryptjs";
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
export async function login(formData: FormData) {
const email = formData.get("email") as string;
const password = formData.get("password") as string;
if (!email || !password) {
return { error: "Email and password are required" };
}
const user = await db.query.users.findFirst({
where: eq(users.email, email),
});
if (!user || user.role !== "admin") {
return { error: "Invalid credentials or not an admin" };
}
const passwordMatch = await bcrypt.compare(password, user.password);
if (!passwordMatch) {
return { error: "Invalid credentials" };
}
const cookieStore = await cookies();
cookieStore.set("admin_session", "true", {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
maxAge: 60 * 60 * 24, // 1 day
path: "/",
});
redirect("/");
}
export async function logout() {
const cookieStore = await cookies();
cookieStore.delete("admin_session");
redirect("/login");
}

222
app/login/page.tsx Normal file
View File

@@ -0,0 +1,222 @@
"use client";
import { useState } from "react";
import { login } from "./actions";
import { Lock, Mail, AlertCircle, ArrowRight, Loader2 } from "lucide-react";
export default function LoginPage() {
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
setError(null);
setLoading(true);
const formData = new FormData(event.currentTarget);
const result = await login(formData);
if (result?.error) {
setError(result.error);
setLoading(false);
}
}
return (
<div
className="flex min-h-screen items-center justify-center p-4 relative overflow-hidden animated-gradient"
style={{ fontFamily: "Inter, var(--font-geist-sans), system-ui, sans-serif" }}
>
{/* Ambient glow blobs */}
<div
className="absolute top-1/4 left-1/4 w-96 h-96 rounded-full blur-3xl pointer-events-none"
style={{ background: "rgba(99, 102, 241, 0.08)" }}
/>
<div
className="absolute bottom-1/4 right-1/4 w-80 h-80 rounded-full blur-3xl pointer-events-none"
style={{ background: "rgba(139, 92, 246, 0.06)" }}
/>
{/* Card */}
<div
className="w-full max-w-sm relative z-10 rounded-2xl p-8"
style={{
background: "rgba(16, 16, 26, 0.85)",
border: "1px solid rgba(255,255,255,0.09)",
backdropFilter: "blur(24px)",
boxShadow:
"0 24px 64px rgba(0,0,0,0.5), 0 0 0 1px rgba(255,255,255,0.04) inset",
}}
>
{/* Logo / Header */}
<div className="text-center mb-8">
<div className="flex justify-center mb-5">
<div
className="w-12 h-12 rounded-2xl flex items-center justify-center relative"
style={{
background: "linear-gradient(135deg, #6366f1, #8b5cf6)",
boxShadow:
"0 0 32px rgba(99, 102, 241, 0.45), 0 0 0 1px rgba(255,255,255,0.12) inset",
}}
>
<span className="text-white font-black text-xl">A</span>
</div>
</div>
<h1
className="text-xl font-black tracking-wide mb-1"
style={{
background: "linear-gradient(135deg, #e4e4f0, #a78bfa)",
WebkitBackgroundClip: "text",
WebkitTextFillColor: "transparent",
backgroundClip: "text",
}}
>
AppAdmin
</h1>
<p className="text-xs font-medium" style={{ color: "rgba(255,255,255,0.3)" }}>
Yönetim paneline erişmek için giriş yapın
</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
{/* Email */}
<div className="space-y-1.5">
<label
htmlFor="email"
className="block text-[11px] font-bold uppercase tracking-wider"
style={{ color: "rgba(255,255,255,0.35)" }}
>
E-posta
</label>
<div className="relative">
<Mail
size={14}
className="absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none"
style={{ color: "rgba(255,255,255,0.22)" }}
/>
<input
id="email"
name="email"
type="email"
autoComplete="email"
required
className="block w-full pl-9 pr-4 py-2.5 text-sm rounded-xl transition-all duration-200"
style={{
background: "rgba(255,255,255,0.05)",
border: "1px solid rgba(255,255,255,0.09)",
color: "rgba(255,255,255,0.85)",
outline: "none",
}}
placeholder="admin@admin.com"
onFocus={(e) => {
e.currentTarget.style.borderColor = "rgba(99, 102, 241, 0.5)";
e.currentTarget.style.background = "rgba(99, 102, 241, 0.06)";
}}
onBlur={(e) => {
e.currentTarget.style.borderColor = "rgba(255,255,255,0.09)";
e.currentTarget.style.background = "rgba(255,255,255,0.05)";
}}
/>
</div>
</div>
{/* Password */}
<div className="space-y-1.5">
<label
htmlFor="password"
className="block text-[11px] font-bold uppercase tracking-wider"
style={{ color: "rgba(255,255,255,0.35)" }}
>
Şifre
</label>
<div className="relative">
<Lock
size={14}
className="absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none"
style={{ color: "rgba(255,255,255,0.22)" }}
/>
<input
id="password"
name="password"
type="password"
autoComplete="current-password"
required
className="block w-full pl-9 pr-4 py-2.5 text-sm rounded-xl transition-all duration-200"
style={{
background: "rgba(255,255,255,0.05)",
border: "1px solid rgba(255,255,255,0.09)",
color: "rgba(255,255,255,0.85)",
outline: "none",
}}
placeholder="••••••••"
onFocus={(e) => {
e.currentTarget.style.borderColor = "rgba(99, 102, 241, 0.5)";
e.currentTarget.style.background = "rgba(99, 102, 241, 0.06)";
}}
onBlur={(e) => {
e.currentTarget.style.borderColor = "rgba(255,255,255,0.09)";
e.currentTarget.style.background = "rgba(255,255,255,0.05)";
}}
/>
</div>
</div>
{/* Error */}
{error && (
<div
className="flex items-center gap-2.5 p-3 rounded-xl text-xs font-medium"
style={{
background: "rgba(239, 68, 68, 0.08)",
border: "1px solid rgba(239, 68, 68, 0.2)",
color: "#f87171",
}}
>
<AlertCircle size={14} />
<span>{error}</span>
</div>
)}
{/* Submit */}
<button
type="submit"
disabled={loading}
className="relative w-full flex items-center justify-center gap-2 py-2.5 rounded-xl text-sm font-bold transition-all duration-200 mt-2 overflow-hidden group"
style={{
background: loading
? "rgba(99, 102, 241, 0.4)"
: "linear-gradient(135deg, #6366f1, #8b5cf6)",
color: "white",
boxShadow: loading
? "none"
: "0 0 24px rgba(99, 102, 241, 0.35)",
cursor: loading ? "not-allowed" : "pointer",
}}
>
{loading ? (
<>
<Loader2 size={15} className="animate-spin" />
<span>Giriş yapılıyor...</span>
</>
) : (
<>
<span>Giriş Yap</span>
<ArrowRight
size={15}
className="group-hover:translate-x-0.5 transition-transform duration-200"
/>
</>
)}
</button>
</form>
{/* Footer hint */}
<p
className="text-center text-[10px] mt-6"
style={{ color: "rgba(255,255,255,0.15)" }}
>
AppAdmin v1.0 · Yönetim Paneli
</p>
</div>
</div>
);
}

View File

@@ -1,65 +1,227 @@
import Image from "next/image";
import { logout } from "./login/actions";
import { getApps } from "./apps/actions";
import { getAppStatus } from "./asc/actions";
import {
LayoutDashboard,
Smartphone,
Settings,
Bell,
CloudUpload,
Plus,
ExternalLink,
Search,
CheckCircle2,
Clock,
XCircle,
RefreshCw,
LogOut,
SlidersHorizontal
} from "lucide-react";
import Link from "next/link";
const statusMap: Record<string, { label: string, color: string, bg: string }> = {
READY_FOR_SALE: { label: "Yayında", color: "text-emerald-700 dark:text-emerald-400", bg: "bg-emerald-100 dark:bg-emerald-900/30" },
PREPARE_FOR_SUBMISSION: { label: "Hazırlanıyor", color: "text-blue-700 dark:text-blue-400", bg: "bg-blue-100 dark:bg-blue-900/30" },
WAITING_FOR_REVIEW: { label: "İnceleme Bekliyor", color: "text-amber-700 dark:text-amber-400", bg: "bg-amber-100 dark:bg-amber-900/30" },
IN_REVIEW: { label: "İncelemede", color: "text-purple-700 dark:text-purple-400", bg: "bg-purple-100 dark:bg-purple-900/30" },
REJECTED: { label: "Reddedildi", color: "text-red-700 dark:text-red-400", bg: "bg-red-100 dark:bg-red-900/30" },
METADATA_REJECTED: { label: "Metadata Reddi", color: "text-red-700 dark:text-red-400", bg: "bg-red-100 dark:bg-red-900/30" },
PENDING_DEVELOPER_RELEASE: { label: "Onay Bekliyor", color: "text-cyan-700 dark:text-cyan-400", bg: "bg-cyan-100 dark:bg-cyan-900/30" },
PROCESSING_FOR_APP_STORE: { label: "İşleniyor", color: "text-zinc-600 dark:text-zinc-400", bg: "bg-zinc-100 dark:bg-zinc-800" },
};
export default async function Home() {
const allApps = await getApps();
const appsWithStatus = await Promise.all(allApps.map(async (app) => {
if (app.platform === 'ios' && app.appleId) {
try {
const { data } = await getAppStatus(app.appleId);
const latestVersion = data?.data?.[0];
const state = latestVersion?.attributes?.appStoreState;
const versionNumber = latestVersion?.attributes?.versionString;
return { ...app, apiStatus: state, version: versionNumber };
} catch (err) {
return { ...app, apiStatus: 'ERROR' };
}
}
return { ...app, apiStatus: 'UNKNOWN' };
}));
const inReviewCount = appsWithStatus.filter(a => a.apiStatus === 'IN_REVIEW' || a.apiStatus === 'WAITING_FOR_REVIEW').length;
const liveCount = appsWithStatus.filter(a => a.apiStatus === 'READY_FOR_SALE').length;
const rejectedCount = appsWithStatus.filter(a => a.apiStatus === 'REJECTED' || a.apiStatus === 'METADATA_REJECTED').length;
export default function Home() {
return (
<div className="flex flex-col flex-1 items-center justify-center bg-zinc-50 font-sans dark:bg-black">
<main className="flex flex-1 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 className="flex h-screen bg-slate-50 dark:bg-black font-sans text-slate-900 dark:text-slate-100">
{/* Sidebar */}
<aside className="w-64 bg-white dark:bg-zinc-950 border-r border-slate-200 dark:border-zinc-900 flex flex-col shrink-0 shadow-sm">
<div className="p-6 border-b border-slate-100 dark:border-zinc-900 flex items-center gap-3">
<div className="w-7 h-7 bg-slate-900 dark:bg-white rounded-md flex items-center justify-center">
<span className="text-white dark:text-black font-bold text-sm">A</span>
</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
<span className="font-bold text-lg tracking-tight uppercase tracking-wider">AppAdmin</span>
</div>
<nav className="flex-1 p-4 space-y-1">
<Link href="/" className="flex items-center gap-2.5 px-3 py-2 bg-slate-900 dark:bg-white text-white dark:text-black rounded-lg text-sm font-semibold transition-all">
<LayoutDashboard size={18} /> Dashboard
</Link>
<Link href="/apps" className="flex items-center gap-2.5 px-3 py-2 text-slate-600 dark:text-zinc-400 hover:bg-slate-50 dark:hover:bg-zinc-900 hover:text-slate-900 dark:hover:text-white rounded-lg text-sm font-medium">
<Smartphone size={18} /> Uygulamalar
</Link>
<Link href="/notifications" className="flex items-center gap-2.5 px-3 py-2 text-slate-600 dark:text-zinc-400 hover:bg-slate-50 dark:hover:bg-zinc-900 hover:text-slate-900 dark:hover:text-white rounded-lg text-sm font-medium">
<Bell size={18} /> Bildirimler
</Link>
</nav>
<div className="p-4 border-t border-slate-100 dark:border-zinc-900">
<form action={logout}>
<button className="flex items-center gap-2.5 px-3 py-2 w-full text-slate-500 dark:text-zinc-400 hover:text-red-600 dark:hover:text-red-400 transition-colors text-sm font-medium">
<LogOut size={18} /> Çıkış Yap
</button>
</form>
</div>
</aside>
{/* Main Content */}
<main className="flex-1 overflow-y-auto">
<header className="h-16 bg-white/80 dark:bg-zinc-950/80 backdrop-blur-md border-b border-slate-200 dark:border-zinc-900 flex items-center justify-between px-8 sticky top-0 z-10">
<div className="flex items-center gap-3">
<h2 className="text-md font-bold text-slate-900 dark:text-white uppercase tracking-wider">Genel Bakış</h2>
<span className="px-2 py-0.5 bg-emerald-50 dark:bg-emerald-900/30 text-emerald-600 dark:text-emerald-400 text-[10px] font-bold rounded-full border border-emerald-100 dark:border-emerald-800 uppercase tracking-widest">
Live
</span>
</div>
<div className="flex items-center gap-4">
<div className="relative group">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400 group-focus-within:text-slate-900 transition-colors" size={16} />
<input className="pl-9 pr-4 py-1.5 bg-slate-50 dark:bg-zinc-900 border border-slate-200 dark:border-zinc-800 rounded-lg text-sm w-64 text-slate-900 dark:text-white focus:outline-none focus:ring-1 focus:ring-slate-400 dark:focus:ring-zinc-700 transition-all" placeholder="Uygulama ara..." />
</div>
</div>
</header>
<div className="p-8 space-y-8">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<StatCard title="Uygulamalar" value={allApps.length.toString()} icon={<Smartphone size={20}/>} color="bg-blue-600" />
<StatCard title="İnceleme" value={inReviewCount.toString()} icon={<Clock size={20}/>} color="bg-amber-500" />
<StatCard title="Reddedilen" value={rejectedCount.toString()} icon={<XCircle size={20}/>} color="bg-red-500" />
<StatCard title="Yayında" value={liveCount.toString()} icon={<CheckCircle2 size={20}/>} color="bg-emerald-500" />
</div>
<div className="grid grid-cols-1 xl:grid-cols-3 gap-8">
<div className="xl:col-span-2 space-y-6">
<div className="flex items-center justify-between border-b border-slate-200 dark:border-zinc-900 pb-4">
<h3 className="text-lg font-bold text-slate-900 dark:text-white uppercase tracking-wider">Uygulamalar</h3>
<Link href="/apps" className="flex items-center gap-1.5 px-4 py-1.5 bg-slate-900 dark:bg-white text-white dark:text-black rounded-lg text-xs font-bold hover:opacity-90 transition-all">
<Plus size={14} /> Yeni Ekle
</Link>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{appsWithStatus.map((app) => {
const statusInfo = statusMap[app.apiStatus || ''] || { label: "Bilinmiyor", color: "text-slate-500 dark:text-zinc-400", bg: "bg-slate-100 dark:bg-zinc-900" };
return (
<div key={app.id} className="bg-white dark:bg-zinc-950 p-6 rounded-xl border border-slate-200 dark:border-zinc-900 hover:shadow-sm hover:border-slate-300 dark:hover:border-zinc-800 transition-all group">
<div className="flex justify-between items-start mb-6">
<div className="w-11 h-11 bg-slate-50 dark:bg-zinc-900 rounded-lg flex items-center justify-center border border-slate-100 dark:border-zinc-800 overflow-hidden">
{app.platform === 'ios' ? <Image src="/next.svg" alt="iOS" width={22} height={22} className="dark:invert p-0.5" /> : <Smartphone size={22} className="text-slate-400" />}
</div>
<div className={`px-2.5 py-0.5 rounded-md text-[10px] font-bold uppercase tracking-widest border border-slate-100 dark:border-zinc-800 ${statusInfo.bg} ${statusInfo.color}`}>
{statusInfo.label}
</div>
</div>
<div className="space-y-0.5">
<div className="flex items-baseline gap-2">
<h4 className="font-bold text-md text-slate-900 dark:text-white truncate max-w-[140px]">{app.name}</h4>
{(app as any).version && <span className="text-[10px] font-mono text-slate-400">v{(app as any).version}</span>}
</div>
<p className="text-[10px] text-slate-400 font-mono truncate">{app.bundleId}</p>
</div>
<div className="mt-6 flex items-center justify-between pt-4 border-t border-slate-50 dark:border-zinc-900/50">
<div className="flex gap-4">
{/* Remote Config Butonu (Yeni) */}
<Link href={`/apps/${app.id}/config`} className="flex items-center gap-1.5 px-2 py-1 bg-slate-50 dark:bg-zinc-900 border border-slate-200 dark:border-zinc-800 rounded text-[10px] font-bold uppercase tracking-wider text-slate-600 dark:text-slate-400 hover:text-blue-600 transition-all">
<SlidersHorizontal size={12} /> Config
</Link>
{/* Edit Butonu */}
<Link href={`/apps/${app.id}/edit`} className="flex items-center gap-1.5 px-2 py-1 bg-slate-50 dark:bg-zinc-900 border border-slate-200 dark:border-zinc-800 rounded text-[10px] font-bold uppercase tracking-wider text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white transition-all">
<Settings size={12} /> Edit
</Link>
</div>
<a href={`https://apps.apple.com/app/id${app.appleId}`} target="_blank" className="text-slate-400 hover:text-blue-600 transition-colors">
<ExternalLink size={18} />
</a>
</div>
</div>
);
})}
</div>
</div>
<div className="xl:col-span-1 space-y-8">
<div className="bg-white dark:bg-zinc-950 p-6 rounded-xl border border-slate-200 dark:border-zinc-900">
<h3 className="text-sm font-bold mb-5 text-slate-400 uppercase tracking-widest">Sistem Durumu</h3>
<div className="space-y-4">
<div className="flex items-center justify-between">
<span className="text-xs text-slate-600 dark:text-zinc-400 font-semibold uppercase tracking-wider">Apple Connect API</span>
<span className="flex items-center gap-1.5 text-emerald-600 text-[10px] font-bold uppercase tracking-widest">
<div className="w-1.5 h-1.5 bg-emerald-500 rounded-full" /> Bağlı
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-xs text-slate-600 dark:text-zinc-400 font-semibold uppercase tracking-wider">PostgreSQL DB</span>
<span className="flex items-center gap-1.5 text-emerald-600 text-[10px] font-bold uppercase tracking-widest">
<div className="w-1.5 h-1.5 bg-emerald-500 rounded-full" /> Aktif
</span>
</div>
</div>
</div>
<div className="bg-white dark:bg-zinc-950 p-6 rounded-xl border border-slate-200 dark:border-zinc-900">
<h3 className="text-sm font-bold mb-5 flex items-center justify-between text-slate-400 uppercase tracking-widest">
Hızlı İşlemler
<RefreshCw size={14} className="hover:rotate-180 transition-all duration-500 cursor-pointer" />
</h3>
<div className="space-y-2">
<QuickActionButton label="Duyuru Yayınla" icon={<Bell size={16}/>} />
<QuickActionButton label="Yenile" icon={<CloudUpload size={16}/>} />
</div>
</div>
</div>
</div>
</div>
</main>
</div>
);
}
function StatCard({ title, value, icon, color }: { title: string, value: string, icon: any, color: string }) {
return (
<div className="bg-white dark:bg-zinc-950 p-6 rounded-xl border border-slate-200 dark:border-zinc-900">
<div className="flex items-center justify-between mb-3">
<span className="text-[10px] text-slate-400 font-bold uppercase tracking-[0.1em]">{title}</span>
<div className={`p-1.5 rounded-md text-white ${color} shadow-sm`}>
{icon}
</div>
</div>
<div className="text-3xl font-bold text-slate-900 dark:text-white tabular-nums">{value}</div>
</div>
)
}
function QuickActionButton({ label, icon }: { label: string, icon: any }) {
return (
<button className="w-full flex items-center justify-between p-3 rounded-lg border border-slate-50 dark:border-zinc-900 hover:bg-slate-50 dark:hover:bg-zinc-900 hover:border-slate-100 dark:hover:border-zinc-800 transition-all text-xs font-bold text-slate-700 dark:text-zinc-300">
<div className="flex items-center gap-3">
{icon}
{label}
</div>
<Plus size={14} className="text-slate-300" />
</button>
)
}

12
db/index.ts Normal file
View File

@@ -0,0 +1,12 @@
import { drizzle } from "drizzle-orm/node-postgres";
import { Pool } from "pg";
import * as schema from "./schema";
import * as dotenv from "dotenv";
dotenv.config({ path: ".env.local" });
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
});
export const db = drizzle(pool, { schema });

47
db/schema/index.ts Normal file
View File

@@ -0,0 +1,47 @@
import { pgTable, serial, text, timestamp, varchar, jsonb, integer, boolean } from "drizzle-orm/pg-core";
export const users = pgTable("users", {
id: serial("id").primaryKey(),
name: text("name").notNull(),
email: text("email").notNull().unique(),
password: text("password").notNull(),
role: varchar("role", { length: 20 }).default("user").notNull(),
createdAt: timestamp("created_at").defaultNow().notNull(),
});
// Uygulamalar Tablosu
export const apps = pgTable("apps", {
id: serial("id").primaryKey(),
name: varchar("name", { length: 100 }).notNull(),
bundleId: varchar("bundle_id", { length: 100 }).notNull().unique(),
appleId: varchar("apple_id", { length: 100 }), // App Store Connect için
platform: varchar("platform", { length: 20 }).default("ios").notNull(), // ios, android, dual
status: varchar("status", { length: 50 }).default("active").notNull(),
iconUrl: text("icon_url"),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().notNull(),
});
// Remote Config & App Settings
export const remoteConfig = pgTable("remote_config", {
id: serial("id").primaryKey(),
appId: integer("app_id").references(() => apps.id, { onDelete: 'cascade' }).notNull(),
configKey: varchar("config_key", { length: 100 }).notNull(),
configValue: jsonb("config_value").notNull(),
description: text("description"),
isActive: boolean("is_active").default(true).notNull(),
updatedAt: timestamp("updated_at").defaultNow().notNull(),
});
// Mevcut items tablosunu "Global İçerik" olarak saklayabiliriz veya genişletebiliriz.
export const items = pgTable("items", {
id: serial("id").primaryKey(),
appId: integer("app_id").references(() => apps.id, { onDelete: 'cascade' }), // Hangi uygulamaya ait?
key: varchar("key", { length: 100 }).notNull(),
title: text("title").notNull(),
content: text("content"),
data: jsonb("data"),
type: varchar("type", { length: 50 }).default("generic").notNull(),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().notNull(),
});

25
db/seed.ts Normal file
View File

@@ -0,0 +1,25 @@
import { db } from "./index";
import { users } from "./schema";
import bcrypt from "bcryptjs";
import * as dotenv from "dotenv";
dotenv.config({ path: ".env.local" });
async function seed() {
const hashedPassword = await bcrypt.hash("admin123", 10);
await db.insert(users).values({
name: "Admin",
email: "admin@admin.com",
password: hashedPassword,
role: "admin",
}).onConflictDoNothing();
console.log("Admin user created.");
process.exit(0);
}
seed().catch(err => {
console.error(err);
process.exit(1);
});

21
db/test-conn.ts Normal file
View File

@@ -0,0 +1,21 @@
import { Client } from 'pg';
import * as dotenv from 'dotenv';
dotenv.config({ path: '.env.local' });
async function test() {
const client = new Client({
connectionString: process.env.DATABASE_URL,
});
try {
await client.connect();
console.log("Connected successfully");
const res = await client.query('SELECT NOW()');
console.log(res.rows[0]);
await client.end();
} catch (err) {
console.error("Connection error", err);
}
}
test();

15
drizzle.config.ts Normal file
View File

@@ -0,0 +1,15 @@
import { defineConfig } from "drizzle-kit";
import * as dotenv from "dotenv";
dotenv.config({
path: ".env.local",
});
export default defineConfig({
schema: "./db/schema",
out: "./db/migrations",
dialect: "postgresql",
dbCredentials: {
url: process.env.DATABASE_URL!,
},
});

32
middleware.ts Normal file
View File

@@ -0,0 +1,32 @@
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
const adminSession = request.cookies.get('admin_session');
// Allow requests to /login and static assets
if (
pathname.startsWith('/login') ||
pathname.startsWith('/_next') ||
pathname.startsWith('/api') ||
pathname === '/favicon.ico'
) {
// If already logged in and trying to access login page, redirect to home
if (pathname.startsWith('/login') && adminSession) {
return NextResponse.redirect(new URL('/', request.url));
}
return NextResponse.next();
}
// Redirect to /login if no session exists
if (!adminSession) {
return NextResponse.redirect(new URL('/login', request.url));
}
return NextResponse.next();
}
export const config = {
matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],
};

1833
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -9,15 +9,24 @@
"lint": "eslint"
},
"dependencies": {
"bcryptjs": "^3.0.3",
"drizzle-orm": "^0.45.1",
"jose": "^6.2.2",
"lucide-react": "^0.577.0",
"next": "16.2.1",
"pg": "^8.20.0",
"react": "19.2.4",
"react-dom": "19.2.4"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/bcryptjs": "^2.4.6",
"@types/node": "^20",
"@types/pg": "^8.20.0",
"@types/react": "^19",
"@types/react-dom": "^19",
"dotenv": "^17.3.1",
"drizzle-kit": "^0.31.10",
"eslint": "^9",
"eslint-config-next": "16.2.1",
"tailwindcss": "^4",