first commit
This commit is contained in:
67
app/api/asc/localizations/route.ts
Normal file
67
app/api/asc/localizations/route.ts
Normal 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 });
|
||||
}
|
||||
19
app/api/asc/review-response/route.ts
Normal file
19
app/api/asc/review-response/route.ts
Normal 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 });
|
||||
}
|
||||
24
app/api/asc/screenshots/commit/route.ts
Normal file
24
app/api/asc/screenshots/commit/route.ts
Normal 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);
|
||||
}
|
||||
97
app/api/asc/screenshots/route.ts
Normal file
97
app/api/asc/screenshots/route.ts
Normal 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 });
|
||||
}
|
||||
76
app/api/config/[slug]/route.ts
Normal file
76
app/api/config/[slug]/route.ts
Normal 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" },
|
||||
});
|
||||
}
|
||||
21
app/api/items/[key]/route.ts
Normal file
21
app/api/items/[key]/route.ts
Normal 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
28
app/apps/DeleteButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
246
app/apps/[id]/config/page.tsx
Normal file
246
app/apps/[id]/config/page.tsx
Normal 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
100
app/apps/[id]/edit/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
295
app/apps/[id]/store/LocalizationEditor.tsx
Normal file
295
app/apps/[id]/store/LocalizationEditor.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
143
app/apps/[id]/store/ReviewCard.tsx
Normal file
143
app/apps/[id]/store/ReviewCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
266
app/apps/[id]/store/ScreenshotManager.tsx
Normal file
266
app/apps/[id]/store/ScreenshotManager.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
480
app/apps/[id]/store/page.tsx
Normal file
480
app/apps/[id]/store/page.tsx
Normal 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
72
app/apps/actions.ts
Normal 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
264
app/apps/page.tsx
Normal 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
157
app/asc/actions.ts
Normal 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
47
app/asc/api.ts
Normal 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
19
app/asc/utils.ts
Normal 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
48
app/config/actions.ts
Normal 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));
|
||||
}
|
||||
227
app/globals.css
227
app/globals.css
@@ -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
39
app/items/actions.ts
Normal 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),
|
||||
});
|
||||
}
|
||||
@@ -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
47
app/login/actions.ts
Normal 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
222
app/login/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
274
app/page.tsx
274
app/page.tsx
@@ -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>
|
||||
<span className="font-bold text-lg tracking-tight uppercase tracking-wider">AppAdmin</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
|
||||
</a>
|
||||
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user