generated from fahricansecer/boilerplate-fe
main
Some checks failed
UI Deploy (Next-Auth Support) 🎨 / build-and-deploy (push) Has been cancelled
Some checks failed
UI Deploy (Next-Auth Support) 🎨 / build-and-deploy (push) Has been cancelled
This commit is contained in:
@@ -1,14 +1,10 @@
|
||||
'use client';
|
||||
|
||||
import Footer from '@/components/layout/footer/footer';
|
||||
import { Box, Flex } from '@chakra-ui/react';
|
||||
|
||||
function AuthLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<Flex minH='100vh' direction='column'>
|
||||
<Box as='main'>{children}</Box>
|
||||
<Footer />
|
||||
</Flex>
|
||||
<div style={{ minHeight: '100vh', display: 'flex', flexDirection: 'column' }}>
|
||||
<main style={{ flex: 1 }}>{children}</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ import Link from "next/link";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useCreateProject } from "@/hooks/use-api";
|
||||
import { useToast } from "@/components/ui/toast";
|
||||
import { projectsApi } from "@/lib/api/api-service";
|
||||
|
||||
const steps = ["Konu & Dil", "Stil & Süre", "AI Senaryo"];
|
||||
|
||||
@@ -69,17 +70,22 @@ export default function NewProjectPage() {
|
||||
|
||||
const handleGenerate = async () => {
|
||||
try {
|
||||
// Backend DTO alanları: prompt (zorunlu), videoStyle, title, language, aspectRatio, targetDuration
|
||||
const result = await createProject.mutateAsync({
|
||||
title: topic.slice(0, 80),
|
||||
topic,
|
||||
prompt: topic, // ← topic → prompt (backend alanı)
|
||||
language,
|
||||
style,
|
||||
videoStyle: style, // ← style → videoStyle (backend alanı)
|
||||
targetDuration: duration,
|
||||
aspectRatio,
|
||||
});
|
||||
toast.success("Proje başarıyla oluşturuldu! AI senaryo üretiliyor...");
|
||||
const projectId = result?.id;
|
||||
if (projectId) {
|
||||
// Proje oluşturulduktan sonra otomatik senaryo üretimini tetikle
|
||||
projectsApi.generateScript(projectId).catch((err) => {
|
||||
console.error("Senaryo üretimi başlatılamadı:", err);
|
||||
});
|
||||
router.push(`/dashboard/projects/${projectId}`);
|
||||
} else {
|
||||
router.push("/dashboard/projects");
|
||||
|
||||
@@ -88,8 +88,13 @@ export default function XToVideoPage() {
|
||||
targetDuration: duration,
|
||||
});
|
||||
toast.success("Tweet → Video projesi oluşturuldu!");
|
||||
const projectId = result?.id ?? result?.data?.id;
|
||||
const projectId = result?.id;
|
||||
if (projectId) {
|
||||
// Otomatik senaryo üretimini tetikle
|
||||
const { projectsApi } = await import("@/lib/api/api-service");
|
||||
projectsApi.generateScript(projectId).catch((err) => {
|
||||
console.error("Tweet→Video senaryo üretimi başlatılamadı:", err);
|
||||
});
|
||||
router.push(`/dashboard/projects/${projectId}`);
|
||||
} else {
|
||||
router.push("/dashboard/projects");
|
||||
@@ -178,35 +183,37 @@ export default function XToVideoPage() {
|
||||
{/* Author */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-9 h-9 rounded-full bg-gradient-to-br from-violet-500/20 to-cyan-500/20 flex items-center justify-center text-sm font-bold text-violet-300">
|
||||
{(previewData.author?.name ?? previewData.authorName ?? "X")?.[0]}
|
||||
{(previewData.tweet?.author?.name ?? "X")?.[0]}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-bold text-[var(--color-text-primary)]">
|
||||
{previewData.author?.name ?? previewData.authorName ?? "Kullanıcı"}
|
||||
{previewData.tweet?.author?.name ?? "Kullanıcı"}
|
||||
</p>
|
||||
<p className="text-[11px] text-[var(--color-text-ghost)]">
|
||||
@{previewData.author?.handle ?? previewData.authorHandle ?? "handle"}
|
||||
@{previewData.tweet?.author?.username ?? "handle"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<p className="text-sm text-[var(--color-text-secondary)] whitespace-pre-line">
|
||||
{previewData.text ?? previewData.content ?? ""}
|
||||
{previewData.tweet?.text ?? ""}
|
||||
</p>
|
||||
|
||||
{/* Images */}
|
||||
{(previewData.images?.length > 0 || previewData.mediaUrls?.length > 0) && (
|
||||
{(previewData.tweet?.media?.length > 0) && (
|
||||
<div className="flex gap-2 overflow-x-auto">
|
||||
{(previewData.images ?? previewData.mediaUrls ?? []).map(
|
||||
(url: string, i: number) => (
|
||||
{(previewData.tweet.media ?? [])
|
||||
.filter((m: any) => m.type === 'photo')
|
||||
.map(
|
||||
(m: any, i: number) => (
|
||||
<div
|
||||
key={i}
|
||||
className="w-20 h-20 rounded-lg bg-[var(--color-bg-elevated)] overflow-hidden shrink-0"
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={url}
|
||||
src={m.url}
|
||||
alt={`Media ${i + 1}`}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
@@ -220,30 +227,38 @@ export default function XToVideoPage() {
|
||||
<div className="flex items-center gap-4 text-[11px] text-[var(--color-text-ghost)]">
|
||||
<span className="flex items-center gap-1">
|
||||
<Heart size={12} />
|
||||
{previewData.likes ?? previewData.stats?.likes ?? 0}
|
||||
{previewData.tweet?.metrics?.likes ?? 0}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Repeat2 size={12} />
|
||||
{previewData.retweets ?? previewData.stats?.retweets ?? 0}
|
||||
{previewData.tweet?.metrics?.retweets ?? 0}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Eye size={12} />
|
||||
{previewData.views ?? previewData.stats?.views ?? 0}
|
||||
{previewData.tweet?.metrics?.views ?? 0}
|
||||
</span>
|
||||
{previewData.threadLength > 1 && (
|
||||
{previewData.tweet?.isThread && (
|
||||
<span className="flex items-center gap-1 text-violet-400">
|
||||
<MessageSquare size={12} />
|
||||
{previewData.threadLength} tweet thread
|
||||
{previewData.tweet?.threadTweets?.length ?? 0} tweet thread
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Suggested info */}
|
||||
{previewData.suggestedTitle && (
|
||||
<div className="flex items-center gap-1.5 text-[11px] text-cyan-400">
|
||||
<Sparkles size={12} />
|
||||
Önerilen başlık: {previewData.suggestedTitle} · Tahmini süre: {previewData.estimatedDuration}sn · Viral skoru: {previewData.viralScore}/100
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Images tag */}
|
||||
{(previewData.images?.length > 0 || previewData.mediaUrls?.length > 0) && (
|
||||
{(previewData.tweet?.media?.filter((m: any) => m.type === 'photo')?.length > 0) && (
|
||||
<div className="flex items-center gap-1.5 text-[11px] text-cyan-400">
|
||||
<ImageIcon size={12} />
|
||||
{(previewData.images ?? previewData.mediaUrls ?? []).length} görsel referans olarak kullanılacak + AI görsel üretilecek
|
||||
{previewData.tweet.media.filter((m: any) => m.type === 'photo').length} görsel referans olarak kullanılacak + AI görsel üretilecek
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import baseUrl from "@/config/base-url";
|
||||
import { authService } from "@/lib/api/example/auth/service";
|
||||
import { authApi } from "@/lib/api/api-service";
|
||||
import NextAuth from "next-auth";
|
||||
import Credentials from "next-auth/providers/credentials";
|
||||
|
||||
@@ -38,22 +37,19 @@ const handler = NextAuth({
|
||||
}
|
||||
|
||||
// Normal mod: backend'e istek at
|
||||
const res = await authService.login({
|
||||
const res = await authApi.login({
|
||||
email: credentials.email,
|
||||
password: credentials.password,
|
||||
});
|
||||
|
||||
console.log("res", res);
|
||||
|
||||
const response = res;
|
||||
|
||||
// Backend returns ApiResponse<TokenResponseDto>
|
||||
// Structure: { data: { accessToken, refreshToken, expiresIn, user }, message, statusCode }
|
||||
if (!res.success || !response?.data?.accessToken) {
|
||||
throw new Error(response?.message || "Giriş başarısız");
|
||||
// Axios interceptor otomatik unwrap yapıyor:
|
||||
// Backend { success, data: { accessToken, refreshToken, user } } sarıyor
|
||||
// Interceptor sonrası res = { accessToken, refreshToken, user }
|
||||
if (!res?.accessToken) {
|
||||
throw new Error("Giriş başarısız");
|
||||
}
|
||||
|
||||
const { accessToken, refreshToken, user } = response.data;
|
||||
const { accessToken, refreshToken, user } = res;
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
|
||||
@@ -2,11 +2,12 @@
|
||||
|
||||
import { usePathname } from "next/navigation";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { Home, FolderOpen, LayoutGrid, Settings, Sparkles, AtSign, ShieldCheck } from "lucide-react";
|
||||
import { Home, FolderOpen, LayoutGrid, Settings, Sparkles, AtSign, ShieldCheck, LogOut } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useCreditBalance, useCurrentUser } from "@/hooks/use-api";
|
||||
import { NotificationsDropdown } from "./notifications-dropdown";
|
||||
import { signOut } from "next-auth/react";
|
||||
|
||||
const navItems = [
|
||||
{ href: "/dashboard", icon: Home, label: "Ana Sayfa" },
|
||||
@@ -66,13 +67,13 @@ export function MobileNav() {
|
||||
function CreditCard() {
|
||||
const { data, isLoading } = useCreditBalance();
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const remaining = (data as any)?.data?.remaining ?? (data as any)?.remaining ?? 0;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const total = (data as any)?.data?.total ?? (data as any)?.total ?? 50;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const planName = (data as any)?.data?.plan ?? (data as any)?.plan ?? "Free";
|
||||
const pct = total > 0 ? Math.round((remaining / total) * 100) : 0;
|
||||
// Axios interceptor unwrap sonrası data doğrudan backend yanıtı
|
||||
const creditData = data as any;
|
||||
const isAdmin = creditData?.isAdmin === true;
|
||||
const remaining = creditData?.remaining ?? creditData?.balance ?? 0;
|
||||
const total = creditData?.total ?? creditData?.monthlyLimit ?? 50;
|
||||
const planName = creditData?.plan ?? "Free";
|
||||
const pct = isAdmin ? 100 : (total > 0 ? Math.round((remaining / total) * 100) : 0);
|
||||
|
||||
return (
|
||||
<div className="mx-3 mb-4 p-4 rounded-xl bg-gradient-to-br from-violet-500/8 to-cyan-400/5 border border-[var(--color-border-faint)]">
|
||||
@@ -81,7 +82,7 @@ function CreditCard() {
|
||||
<span className="badge badge-violet">{planName}</span>
|
||||
</div>
|
||||
<div className="text-2xl font-bold font-[family-name:var(--font-display)] text-[var(--color-text-primary)]">
|
||||
{isLoading ? "..." : remaining}
|
||||
{isLoading ? "..." : isAdmin ? "∞" : remaining}
|
||||
</div>
|
||||
<div className="progress-bar mt-2">
|
||||
<div
|
||||
@@ -90,7 +91,7 @@ function CreditCard() {
|
||||
/>
|
||||
</div>
|
||||
<p className="text-[10px] text-[var(--color-text-ghost)] mt-1.5">
|
||||
{total} kredilik planınızın {remaining}'{remaining === 1 ? "i" : "si"} kaldı
|
||||
{isAdmin ? "Sınırsız admin erişimi" : `${total} kredilik planınızın ${remaining}'${remaining === 1 ? "i" : "si"} kaldı`}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
@@ -106,6 +107,10 @@ export function DesktopSidebar() {
|
||||
const roles: string[] = (user?.roles ?? []).map((r: any) => r?.role?.name ?? r?.name ?? "");
|
||||
const isAdmin = roles.includes("admin") || roles.includes("superadmin");
|
||||
|
||||
const handleLogout = () => {
|
||||
signOut({ redirect: true, callbackUrl: "/signin" });
|
||||
};
|
||||
|
||||
return (
|
||||
<aside className="hidden md:flex md:w-64 lg:w-72 flex-col h-screen sticky top-0 border-r border-[var(--color-border-faint)] bg-[var(--color-bg-deep)]">
|
||||
{/* Logo */}
|
||||
@@ -176,6 +181,17 @@ export function DesktopSidebar() {
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Çıkış Butonu */}
|
||||
<div className="px-3 pb-4">
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="w-full flex items-center gap-3 px-4 py-2.5 rounded-xl text-sm font-medium text-[var(--color-text-muted)] hover:text-red-400 hover:bg-red-500/8 transition-all duration-200"
|
||||
>
|
||||
<LogOut size={18} strokeWidth={1.6} />
|
||||
<span>Çıkış Yap</span>
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -260,6 +260,13 @@ export interface NotificationListResponse {
|
||||
|
||||
// ── API Functions ────────────────────────────────────────────────────
|
||||
|
||||
export const authApi = {
|
||||
login: (data: any) =>
|
||||
apiClient.post('/auth/login', data).then((r) => r.data),
|
||||
register: (data: any) =>
|
||||
apiClient.post('/auth/register', data).then((r) => r.data),
|
||||
};
|
||||
|
||||
export const projectsApi = {
|
||||
list: (params?: { page?: number; limit?: number; status?: string }) =>
|
||||
apiClient.get<PaginatedResponse<Project>>('/projects', { params }).then((r) => r.data),
|
||||
|
||||
@@ -53,7 +53,19 @@ export function createApiClient(baseURL: string): AxiosInstance {
|
||||
});
|
||||
|
||||
client.interceptors.response.use(
|
||||
(response) => response,
|
||||
(response) => {
|
||||
// Backend ResponseInterceptor tüm yanıtları { success, status, message, data } ile sarıyor.
|
||||
// Frontend'in her yerde .data.data yazmasına gerek kalmadan otomatik unwrap yapıyoruz.
|
||||
if (
|
||||
response.data &&
|
||||
typeof response.data === 'object' &&
|
||||
'success' in response.data &&
|
||||
'data' in response.data
|
||||
) {
|
||||
response.data = response.data.data;
|
||||
}
|
||||
return response;
|
||||
},
|
||||
async (error) => {
|
||||
if (error.response?.status === 401) {
|
||||
// Zaten giriş sayfasında değilsek veya auth ile ilgili bir istek değilse çıkış yap
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
/**
|
||||
* Auth Service — NextAuth CredentialsProvider için server-side auth çağrıları.
|
||||
* `apiRequest` yerine doğrudan fetch kullanır (server-side, session gerektirmez).
|
||||
*/
|
||||
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000/api';
|
||||
|
||||
interface LoginDto {
|
||||
email: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
interface AuthUser {
|
||||
id: string;
|
||||
email: string;
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
roles?: string[];
|
||||
}
|
||||
|
||||
interface AuthResponse {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
expiresIn?: number;
|
||||
user: AuthUser;
|
||||
}
|
||||
|
||||
interface ApiResponse<T> {
|
||||
success: boolean;
|
||||
data: T;
|
||||
message?: string;
|
||||
statusCode?: number;
|
||||
}
|
||||
|
||||
async function authFetch<T>(url: string, body: unknown): Promise<ApiResponse<T>> {
|
||||
try {
|
||||
const res = await fetch(`${API_URL}${url}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
const json = await res.json();
|
||||
|
||||
if (!res.ok) {
|
||||
return {
|
||||
success: false,
|
||||
data: null as unknown as T,
|
||||
message: json?.message || 'İstek başarısız',
|
||||
statusCode: res.status,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: json?.data ?? json,
|
||||
message: json?.message,
|
||||
statusCode: res.status,
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : 'Bağlantı hatası';
|
||||
return {
|
||||
success: false,
|
||||
data: null as unknown as T,
|
||||
message,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const login = (data: LoginDto) => {
|
||||
return authFetch<AuthResponse>('/auth/login', data);
|
||||
};
|
||||
|
||||
const register = (data: { email: string; password: string; firstName?: string; lastName?: string }) => {
|
||||
return authFetch<AuthResponse>('/auth/register', data);
|
||||
};
|
||||
|
||||
const refreshToken = (data: { refreshToken: string }) => {
|
||||
return authFetch<AuthResponse>('/auth/refresh', data);
|
||||
};
|
||||
|
||||
const logout = () => {
|
||||
return authFetch<null>('/auth/logout', {});
|
||||
};
|
||||
|
||||
export const authService = {
|
||||
login,
|
||||
register,
|
||||
refreshToken,
|
||||
logout,
|
||||
};
|
||||
Reference in New Issue
Block a user