mirror of
https://github.com/PeterMaquiran/tvone.git
synced 2026-04-18 15:27:52 +00:00
Compare commits
10 Commits
73e0834d18
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| f163f22987 | |||
| 5e5a43094f | |||
| 661784a73d | |||
| 7854e3dd44 | |||
| 2ab775514e | |||
| c8383955d5 | |||
| 0a645744f0 | |||
| 4e7794476b | |||
| 6555a171ee | |||
| 8454abea36 |
@@ -2,9 +2,9 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import { ThumbsUp, MessageCircle, Send, Share2, LinkIcon, Link2, ChevronRight } from 'lucide-react';
|
import { ThumbsUp, MessageCircle, Send, Share2, LinkIcon, Link2, ChevronRight } from 'lucide-react';
|
||||||
import { TvoneAdBanner, TvoneFooter } from '../components/tvone-content';
|
import { TvoneAdBanner, TvoneFooter } from '../../components/tvone-content';
|
||||||
import { TvonePromoStrip } from '../components/tvone-promo-strip';
|
import { TvonePromoStrip } from '../../components/tvone-promo-strip';
|
||||||
import { TvoneSiteNav } from '../components/tvone-site-nav';
|
import { TvoneSiteNav } from '../../components/tvone-site-nav';
|
||||||
import { SiWhatsapp, SiFacebook, SiX } from 'react-icons/si'; // Ícones oficiais de marca
|
import { SiWhatsapp, SiFacebook, SiX } from 'react-icons/si'; // Ícones oficiais de marca
|
||||||
import { FiCopy } from 'react-icons/fi'; // Ícone de cópia mais limpo
|
import { FiCopy } from 'react-icons/fi'; // Ícone de cópia mais limpo
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
@@ -1,15 +1,47 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import React, { useState, useRef } from 'react';
|
import React, { useState, useRef, useEffect } from 'react';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import {
|
import {
|
||||||
LayoutDashboard, Newspaper, Users, BarChart3,
|
LayoutDashboard, Newspaper, Users, BarChart3,
|
||||||
Settings, HelpCircle, Image as ImageIcon,
|
Settings, HelpCircle, Image as ImageIcon,
|
||||||
Type, Calendar, Clock, Tag, User, Save, Eye, Send
|
Type, Calendar, Clock, Tag, User, Save, Eye, Send
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
import Keycloak from "keycloak-js";
|
||||||
|
|
||||||
// Importe o componente que criámos (ajuste o caminho se necessário)
|
// Importe o componente que criámos (ajuste o caminho se necessário)
|
||||||
import MultiAspectEditor from '../components/MultiAspectEditor';
|
import MultiAspectEditor from '../../components/MultiAspectEditor';
|
||||||
import { Editor } from '@tinymce/tinymce-react';
|
import dynamic from "next/dynamic";
|
||||||
|
const Editor = dynamic(
|
||||||
|
() => import("@tinymce/tinymce-react").then((mod) => mod.Editor),
|
||||||
|
{ ssr: false }
|
||||||
|
);
|
||||||
|
|
||||||
|
const keycloak = new Keycloak({
|
||||||
|
url: "https://keycloak.petermaquiran.xyz",
|
||||||
|
realm: "tvone", // ✅ IMPORTANT
|
||||||
|
clientId: "tvone-web", // must match Keycloak client
|
||||||
|
});
|
||||||
|
|
||||||
|
interface GoogleAuthResponse {
|
||||||
|
access_token: string;
|
||||||
|
token_type: string;
|
||||||
|
expires_in: number;
|
||||||
|
scope: string;
|
||||||
|
authuser?: string;
|
||||||
|
prompt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface KeycloakTokenResponse {
|
||||||
|
access_token: string;
|
||||||
|
expires_in: number;
|
||||||
|
refresh_expires_in: number;
|
||||||
|
refresh_token: string;
|
||||||
|
token_type: string;
|
||||||
|
id_token?: string;
|
||||||
|
"not-before-policy": number;
|
||||||
|
session_state: string;
|
||||||
|
scope: string;
|
||||||
|
}
|
||||||
|
|
||||||
const CreateNewsPage = () => {
|
const CreateNewsPage = () => {
|
||||||
|
|
||||||
@@ -57,6 +89,11 @@ const CreateNewsPage = () => {
|
|||||||
const [isEditorOpen, setIsEditorOpen] = useState(false);
|
const [isEditorOpen, setIsEditorOpen] = useState(false);
|
||||||
const [finalCrops, setFinalCrops] = useState<any>(null); // Guarda os Base64 finais
|
const [finalCrops, setFinalCrops] = useState<any>(null); // Guarda os Base64 finais
|
||||||
const [content, setContent] = useState('');
|
const [content, setContent] = useState('');
|
||||||
|
const [user, setUser] = useState<{
|
||||||
|
email?: string;
|
||||||
|
name?: string;
|
||||||
|
picture?: string;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
@@ -76,6 +113,30 @@ const CreateNewsPage = () => {
|
|||||||
fileInputRef.current?.click();
|
fileInputRef.current?.click();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Avoid hydration mismatch by waiting for mount
|
||||||
|
useEffect(() => {
|
||||||
|
keycloak.init({
|
||||||
|
onLoad: "check-sso",
|
||||||
|
pkceMethod: "S256",
|
||||||
|
}).then(async (authenticated) => {
|
||||||
|
if (authenticated) {
|
||||||
|
const token = keycloak.token!;
|
||||||
|
localStorage.setItem("token", token);
|
||||||
|
|
||||||
|
const res = await fetch("http://localhost:3001/profile/", {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const profile = await res.json();
|
||||||
|
|
||||||
|
setUser(profile);
|
||||||
|
console.log("Profile:", profile);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen bg-slate-100/50 text-slate-900 font-sans">
|
<div className="flex h-screen bg-slate-100/50 text-slate-900 font-sans">
|
||||||
|
|
||||||
@@ -135,11 +196,14 @@ const CreateNewsPage = () => {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<div className="text-right">
|
<div className="text-right">
|
||||||
<p className="text-xs font-bold">James Wilson</p>
|
<p className="text-xs font-bold">{user?.name ?? "Loading..."}</p>
|
||||||
<p className="text-[10px] text-slate-500">Editor</p>
|
<p className="text-[10px] text-slate-500">Editor</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-8 h-8 bg-slate-300 rounded-full border border-white shadow-sm overflow-hidden">
|
<div className="w-8 h-8 bg-slate-300 rounded-full border border-white shadow-sm overflow-hidden">
|
||||||
<img src="https://ui-avatars.com/api/?name=James+Wilson" alt="User" />
|
<img
|
||||||
|
src={user?.picture ?? "https://ui-avatars.com/api/?name=User"}
|
||||||
|
alt="User"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@@ -184,15 +248,15 @@ const CreateNewsPage = () => {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Resumo (Lead) */}
|
{/* Resumo (Lead) */}
|
||||||
<div className="bg-white rounded-2xl p-6 border border-slate-200 shadow-sm">
|
<div className="bg-white rounded-2xl p-6 border border-slate-200 shadow-sm">
|
||||||
<label className="block text-xs font-bold uppercase text-slate-400 mb-2 leading-8">Resumo (Lead)</label>
|
<label className="block text-xs font-bold uppercase text-slate-400 mb-2 leading-8">Resumo (Lead)</label>
|
||||||
<textarea
|
<textarea
|
||||||
rows={3}
|
rows={3}
|
||||||
placeholder="Escreva um resumo curto para visualização..."
|
placeholder="Escreva um resumo curto para visualização..."
|
||||||
className="w-full border-none focus:ring-0 resize-none text-slate-600 text-sm italic p-0"
|
className="w-full border-none focus:ring-0 resize-none text-slate-600 text-sm italic p-0"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tags */}
|
{/* Tags */}
|
||||||
<div className="bg-white rounded-2xl p-6 border border-slate-200 shadow-sm">
|
<div className="bg-white rounded-2xl p-6 border border-slate-200 shadow-sm">
|
||||||
@@ -4,6 +4,35 @@ import React, { useState, useEffect } from "react";
|
|||||||
import { useGoogleLogin } from "@react-oauth/google";
|
import { useGoogleLogin } from "@react-oauth/google";
|
||||||
import { useTheme } from 'next-themes';
|
import { useTheme } from 'next-themes';
|
||||||
import { Sun, Moon } from 'lucide-react'; // Optional: install lucide-react for clean icons
|
import { Sun, Moon } from 'lucide-react'; // Optional: install lucide-react for clean icons
|
||||||
|
import Keycloak from "keycloak-js";
|
||||||
|
|
||||||
|
|
||||||
|
const keycloak = new Keycloak({
|
||||||
|
url: "https://keycloak.petermaquiran.xyz",
|
||||||
|
realm: "tvone", // ✅ IMPORTANT
|
||||||
|
clientId: "tvone-web", // must match Keycloak client
|
||||||
|
});
|
||||||
|
|
||||||
|
interface GoogleAuthResponse {
|
||||||
|
access_token: string;
|
||||||
|
token_type: string;
|
||||||
|
expires_in: number;
|
||||||
|
scope: string;
|
||||||
|
authuser?: string;
|
||||||
|
prompt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface KeycloakTokenResponse {
|
||||||
|
access_token: string;
|
||||||
|
expires_in: number;
|
||||||
|
refresh_expires_in: number;
|
||||||
|
refresh_token: string;
|
||||||
|
token_type: string;
|
||||||
|
id_token?: string;
|
||||||
|
"not-before-policy": number;
|
||||||
|
session_state: string;
|
||||||
|
scope: string;
|
||||||
|
}
|
||||||
|
|
||||||
export default function AppleStyleAuth() {
|
export default function AppleStyleAuth() {
|
||||||
const [email, setEmail] = useState("");
|
const [email, setEmail] = useState("");
|
||||||
@@ -13,14 +42,98 @@ export default function AppleStyleAuth() {
|
|||||||
|
|
||||||
// Avoid hydration mismatch by waiting for mount
|
// Avoid hydration mismatch by waiting for mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
keycloak.init({
|
||||||
|
onLoad: "check-sso", // or "login-required"
|
||||||
|
pkceMethod: "S256",
|
||||||
|
}).then((authenticated) => {
|
||||||
|
if (authenticated) {
|
||||||
|
localStorage.setItem("token", keycloak.token!);
|
||||||
|
console.log("Logged in", keycloak.token);
|
||||||
|
localStorage.setItem("token", keycloak.token as string);
|
||||||
|
|
||||||
|
fetch("http://localhost:3001/profile/", {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${localStorage.getItem("token")}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
setMounted(true);
|
setMounted(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const handleExchange = async (googleResponse: GoogleAuthResponse): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const details: Record<string, string> = {
|
||||||
|
grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange',
|
||||||
|
client_id: 'tvone-web', // Replace with your actual Keycloak Client ID
|
||||||
|
subject_token: googleResponse.access_token,
|
||||||
|
subject_token_type: 'urn:ietf:params:oauth:token-type:access_token',
|
||||||
|
subject_issuer: 'google', // Ensure this matches your Keycloak IdP Alias
|
||||||
|
};
|
||||||
|
|
||||||
|
const formBody = new URLSearchParams(details).toString();
|
||||||
|
|
||||||
|
const response = await fetch(
|
||||||
|
'https://keycloak.petermaquiran.xyz/realms/tvone/protocol/openid-connect/token',
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
body: formBody,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Keycloak exchange failed: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data: KeycloakTokenResponse = await response.json();
|
||||||
|
|
||||||
|
// Store the Keycloak token to send to your NestJS API
|
||||||
|
localStorage.setItem("token", data.access_token);
|
||||||
|
console.log("Authenticated with Keycloak:", data.access_token);
|
||||||
|
|
||||||
|
// Redirect user or update Global Auth State here
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Authentication Flow Error:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const googleLogin = useGoogleLogin({
|
const googleLogin = useGoogleLogin({
|
||||||
onSuccess: (res) => console.log("Google Success", res),
|
onSuccess: (res) => {
|
||||||
|
handleExchange(res)
|
||||||
|
console.log("Google Success", res)
|
||||||
|
},
|
||||||
onError: () => console.log("Google Failed"),
|
onError: () => console.log("Google Failed"),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const handleManualLogin = async (): Promise<void> => {
|
||||||
|
const details: Record<string, string> = {
|
||||||
|
grant_type: 'password',
|
||||||
|
client_id: 'tvone-web-client',
|
||||||
|
username: email,
|
||||||
|
password: password,
|
||||||
|
scope: 'openid',
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
'https://keycloak.petermaquiran.xyz/realms/<realm>/protocol/openid-connect/token',
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
body: new URLSearchParams(details).toString(),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const data: KeycloakTokenResponse = await response.json();
|
||||||
|
if (data.access_token) {
|
||||||
|
localStorage.setItem("token", data.access_token);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Login failed", err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (!mounted) return null;
|
if (!mounted) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -83,10 +196,14 @@ export default function AppleStyleAuth() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 4. BOTÃO GOOGLE */}
|
{/* 4. BOTÃO GOOGLE */}
|
||||||
<button
|
<button
|
||||||
onClick={() => googleLogin()}
|
onClick={() =>
|
||||||
className="group flex w-full items-center justify-center gap-3 rounded-2xl border border-neutral-200 bg-white px-6 py-3.5 transition-all hover:bg-neutral-50 active:scale-[0.98] dark:border-neutral-800 dark:bg-transparent dark:hover:bg-neutral-900"
|
keycloak.login({
|
||||||
>
|
redirectUri: `${window.location.origin}/create-news`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="group flex w-full items-center justify-center gap-3 rounded-2xl border border-neutral-200 bg-white px-6 py-3.5 transition-all hover:bg-neutral-50 active:scale-[0.98] dark:border-neutral-800 dark:bg-transparent dark:hover:bg-neutral-900"
|
||||||
|
>
|
||||||
<svg className="h-5 w-5" viewBox="0 0 24 24">
|
<svg className="h-5 w-5" viewBox="0 0 24 24">
|
||||||
<path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" fill="#4285F4" />
|
<path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" fill="#4285F4" />
|
||||||
<path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" fill="#34A853" />
|
<path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" fill="#34A853" />
|
||||||
@@ -0,0 +1,264 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useEffect, useState } from "react";
|
||||||
|
import {
|
||||||
|
FolderTree,
|
||||||
|
Edit3,
|
||||||
|
Trash2,
|
||||||
|
Plus,
|
||||||
|
ChevronRight,
|
||||||
|
ChevronDown,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { getTree, getFlat, updateCategory, createCategory, deleteCategory } from "@/lib/categories.api";
|
||||||
|
|
||||||
|
/* ================= TYPES ================= */
|
||||||
|
interface Category {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
parentId?: string | null;
|
||||||
|
children?: Category[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ================= API ================= */
|
||||||
|
|
||||||
|
/* ================= UTIL ================= */
|
||||||
|
function slugify(text: string) {
|
||||||
|
return text
|
||||||
|
.toLowerCase()
|
||||||
|
.trim()
|
||||||
|
.replace(/\s+/g, "-")
|
||||||
|
.replace(/[^a-z0-9-]/g, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ================= PAGE ================= */
|
||||||
|
export default function CategoriesPage() {
|
||||||
|
const [tree, setTree] = useState<Category[]>([]);
|
||||||
|
const [flat, setFlat] = useState<Category[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
|
|
||||||
|
const [form, setForm] = useState({
|
||||||
|
id: null as string | null,
|
||||||
|
name: "",
|
||||||
|
slug: "",
|
||||||
|
parentId: null as string | null,
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ================= LOAD ================= */
|
||||||
|
async function load() {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const [t, f] = await Promise.all([getTree(), getFlat()]);
|
||||||
|
setTree(t);
|
||||||
|
setFlat(f);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/* ================= CRUD ================= */
|
||||||
|
async function save() {
|
||||||
|
const payload = {
|
||||||
|
name: form.name,
|
||||||
|
slug: form.slug || slugify(form.name),
|
||||||
|
parentId: form.parentId,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (form.id) {
|
||||||
|
await updateCategory(form.id, payload);
|
||||||
|
} else {
|
||||||
|
await createCategory(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
closeModal();
|
||||||
|
load();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove(id: string) {
|
||||||
|
if (!confirm("Delete this category?")) return;
|
||||||
|
await deleteCategory(id);
|
||||||
|
load();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ================= MODAL ================= */
|
||||||
|
function openCreate(parentId?: string) {
|
||||||
|
setForm({
|
||||||
|
id: null,
|
||||||
|
name: "",
|
||||||
|
slug: "",
|
||||||
|
parentId: parentId || null,
|
||||||
|
});
|
||||||
|
setModalOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(cat: Category) {
|
||||||
|
setForm({
|
||||||
|
id: cat.id,
|
||||||
|
name: cat.name,
|
||||||
|
slug: cat.slug,
|
||||||
|
parentId: cat.parentId || null,
|
||||||
|
});
|
||||||
|
setModalOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeModal() {
|
||||||
|
setModalOpen(false);
|
||||||
|
setForm({ id: null, name: "", slug: "", parentId: null });
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ================= TREE ================= */
|
||||||
|
function TreeNode({
|
||||||
|
node,
|
||||||
|
level = 0,
|
||||||
|
}: {
|
||||||
|
node: Category;
|
||||||
|
level?: number;
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(true);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ marginLeft: level * 14 }} className="border-l pl-3">
|
||||||
|
|
||||||
|
{/* NODE */}
|
||||||
|
<div className="flex items-center justify-between py-2 group">
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
|
||||||
|
<button onClick={() => setOpen(!open)}>
|
||||||
|
{open ? (
|
||||||
|
<ChevronDown size={14} />
|
||||||
|
) : (
|
||||||
|
<ChevronRight size={14} />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<FolderTree size={14} className="text-blue-500" />
|
||||||
|
|
||||||
|
<span
|
||||||
|
onClick={() => openEdit(node)}
|
||||||
|
className="text-sm font-medium cursor-pointer hover:text-blue-600"
|
||||||
|
>
|
||||||
|
{node.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ACTIONS */}
|
||||||
|
<div className="flex gap-2 opacity-0 group-hover:opacity-100 transition">
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => openCreate(node.id)}
|
||||||
|
className="text-green-600"
|
||||||
|
>
|
||||||
|
<Plus size={14} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button onClick={() => openEdit(node)}>
|
||||||
|
<Edit3 size={14} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button onClick={() => remove(node.id)}>
|
||||||
|
<Trash2 size={14} className="text-red-500" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* CHILDREN */}
|
||||||
|
{open &&
|
||||||
|
node.children?.map((child) => (
|
||||||
|
<TreeNode
|
||||||
|
key={child.id}
|
||||||
|
node={child}
|
||||||
|
level={level + 1}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ================= UI ================= */
|
||||||
|
return (
|
||||||
|
<div className="p-8 bg-slate-50 min-h-screen">
|
||||||
|
|
||||||
|
{/* HEADER */}
|
||||||
|
<div className="flex justify-between mb-6">
|
||||||
|
<h1 className="text-xl font-semibold">
|
||||||
|
Category Manager
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => openCreate()}
|
||||||
|
className="bg-blue-600 text-white px-4 py-2 rounded"
|
||||||
|
>
|
||||||
|
+ New Category
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* TREE */}
|
||||||
|
<div className="bg-white border rounded-xl p-4">
|
||||||
|
{loading ? (
|
||||||
|
<p>Loading...</p>
|
||||||
|
) : (
|
||||||
|
tree.map((node) => (
|
||||||
|
<TreeNode key={node.id} node={node} />
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* MODAL */}
|
||||||
|
{modalOpen && (
|
||||||
|
<div className="fixed inset-0 bg-black/40 flex items-center justify-center">
|
||||||
|
|
||||||
|
<div className="bg-white w-[420px] p-5 rounded-xl">
|
||||||
|
|
||||||
|
<h2 className="font-semibold mb-4">
|
||||||
|
{form.id ? "Edit Category" : "Create Category"}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<input
|
||||||
|
className="w-full border p-2 rounded mb-2"
|
||||||
|
placeholder="Name"
|
||||||
|
value={form.name}
|
||||||
|
onChange={(e) =>
|
||||||
|
setForm({
|
||||||
|
...form,
|
||||||
|
name: e.target.value,
|
||||||
|
slug: slugify(e.target.value),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<input
|
||||||
|
className="w-full border p-2 rounded mb-3"
|
||||||
|
placeholder="Slug"
|
||||||
|
value={form.slug}
|
||||||
|
onChange={(e) =>
|
||||||
|
setForm({ ...form, slug: e.target.value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
|
||||||
|
<button onClick={closeModal}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={save}
|
||||||
|
className="bg-blue-600 text-white px-3 py-1 rounded"
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,343 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useEffect, useState } from "react";
|
||||||
|
import Image from "next/image";
|
||||||
|
import {
|
||||||
|
LayoutDashboard,
|
||||||
|
Newspaper,
|
||||||
|
Users,
|
||||||
|
BarChart3,
|
||||||
|
Settings,
|
||||||
|
HelpCircle,
|
||||||
|
Tag,
|
||||||
|
FolderTree,
|
||||||
|
Edit3,
|
||||||
|
Trash2,
|
||||||
|
Plus,
|
||||||
|
ChevronRight,
|
||||||
|
ChevronDown,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { createCategory, deleteCategory, getFlat, getTree, updateCategory } from "@/lib/categories.api";
|
||||||
|
import { slugify } from "@/lib/slug";
|
||||||
|
|
||||||
|
/* ================= TYPES ================= */
|
||||||
|
interface Category {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
parentId?: string | null;
|
||||||
|
children?: Category[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ================= PAGE ================= */
|
||||||
|
export default function CategoriesPage() {
|
||||||
|
const [tree, setTree] = useState<Category[]>([]);
|
||||||
|
const [flat, setFlat] = useState<Category[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
|
|
||||||
|
const [form, setForm] = useState({
|
||||||
|
id: null as string | null,
|
||||||
|
name: "",
|
||||||
|
slug: "",
|
||||||
|
parentId: null as string | null,
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ================= LOAD ================= */
|
||||||
|
async function load() {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const [t, f] = await Promise.all([getTree(), getFlat()]);
|
||||||
|
setTree(t);
|
||||||
|
setFlat(f);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/* ================= CRUD ================= */
|
||||||
|
async function save() {
|
||||||
|
const payload = {
|
||||||
|
name: form.name,
|
||||||
|
slug: form.slug || slugify(form.name),
|
||||||
|
parentId: form.parentId,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (form.id) {
|
||||||
|
await updateCategory(form.id, payload);
|
||||||
|
} else {
|
||||||
|
await createCategory(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
closeModal();
|
||||||
|
load();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove(id: string) {
|
||||||
|
if (!confirm("Delete this category?")) return;
|
||||||
|
await deleteCategory(id);
|
||||||
|
load();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ================= MODAL ================= */
|
||||||
|
function openCreate(parentId?: string) {
|
||||||
|
setForm({
|
||||||
|
id: null,
|
||||||
|
name: "",
|
||||||
|
slug: "",
|
||||||
|
parentId: parentId || null,
|
||||||
|
});
|
||||||
|
setModalOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(cat: Category) {
|
||||||
|
setForm({
|
||||||
|
id: cat.id,
|
||||||
|
name: cat.name,
|
||||||
|
slug: cat.slug,
|
||||||
|
parentId: cat.parentId || null,
|
||||||
|
});
|
||||||
|
setModalOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeModal() {
|
||||||
|
setModalOpen(false);
|
||||||
|
setForm({ id: null, name: "", slug: "", parentId: null });
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ================= TREE ================= */
|
||||||
|
function TreeNode({
|
||||||
|
node,
|
||||||
|
level = 0,
|
||||||
|
}: {
|
||||||
|
node: Category;
|
||||||
|
level?: number;
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(true);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ marginLeft: level * 14 }} className="border-l pl-3">
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between py-2 group">
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
|
||||||
|
<button onClick={() => setOpen(!open)}>
|
||||||
|
{open ? (
|
||||||
|
<ChevronDown size={14} />
|
||||||
|
) : (
|
||||||
|
<ChevronRight size={14} />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<FolderTree size={14} className="text-blue-500" />
|
||||||
|
|
||||||
|
<span
|
||||||
|
onClick={() => openEdit(node)}
|
||||||
|
className="text-sm font-medium cursor-pointer hover:text-blue-600"
|
||||||
|
>
|
||||||
|
{node.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2 opacity-0 group-hover:opacity-100 transition">
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => openCreate(node.id)}
|
||||||
|
className="text-green-600"
|
||||||
|
>
|
||||||
|
<Plus size={14} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button onClick={() => openEdit(node)}>
|
||||||
|
<Edit3 size={14} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button onClick={() => remove(node.id)}>
|
||||||
|
<Trash2 size={14} className="text-red-500" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{open &&
|
||||||
|
node.children?.map((child) => (
|
||||||
|
<TreeNode
|
||||||
|
key={child.id}
|
||||||
|
node={child}
|
||||||
|
level={level + 1}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ================= UI ================= */
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen bg-slate-50 text-slate-900">
|
||||||
|
|
||||||
|
{/* ================= SIDEBAR ================= */}
|
||||||
|
{/* Sidebar Lateral */}
|
||||||
|
<aside className="w-64 backdrop-blur-xl bg-white/80 border-r border-slate-200 p-6 flex flex-col">
|
||||||
|
<div className="flex items-center gap-2 mb-10 px-2">
|
||||||
|
<div className="w-10 h-10 bg-white rounded-lg flex items-center justify-center font-bold italic">
|
||||||
|
<Image src="/logo.png" alt="TVone" width={50} height={50} />
|
||||||
|
</div>
|
||||||
|
<span className="font-bold text-sm tracking-tight leading-tight uppercase">
|
||||||
|
TVone<br/><span className="text-blue-600 text-[10px]">de Notícias</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav className="flex-1 space-y-1">
|
||||||
|
<NavItem icon={<LayoutDashboard size={18}/>} label="Painel" />
|
||||||
|
<NavItem icon={<Newspaper size={18}/>} label="Meus Artigos" />
|
||||||
|
<NavItem icon={<Users size={18}/>} label="Equipa" />
|
||||||
|
<NavItem icon={<BarChart3 size={18}/>} label="Análises" />
|
||||||
|
<NavItem icon={<Newspaper size={18}/>} label="Adicionar Notícia" active />
|
||||||
|
<NavItem icon={<Settings size={18}/>} label="Definições" />
|
||||||
|
<NavItem icon={<HelpCircle size={18}/>} label="Ajuda" />
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* User Activity Feed */}
|
||||||
|
<div className="mt-auto pt-6 border-t border-slate-200">
|
||||||
|
<h4 className="text-[10px] font-bold uppercase text-slate-400 mb-4 px-2 tracking-widest">Atividade Recente</h4>
|
||||||
|
<div className="space-y-3 px-2">
|
||||||
|
<ActivityItem user="James Wilson" action="atualizou 'Mercado'" />
|
||||||
|
<ActivityItem user="Sarah Johnson" action="criou 'Startups'" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* ================= MAIN ================= */}
|
||||||
|
<div className="flex-1 flex flex-col">
|
||||||
|
|
||||||
|
{/* HEADER */}
|
||||||
|
<header className="h-14 bg-white border-b flex items-center justify-between px-6">
|
||||||
|
|
||||||
|
<input
|
||||||
|
placeholder="Search categories..."
|
||||||
|
className="bg-slate-100 px-4 py-1 rounded-full text-sm w-72 outline-none"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
|
||||||
|
<span className="text-sm text-slate-600">
|
||||||
|
Admin
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<img
|
||||||
|
src="https://ui-avatars.com/api/?name=Admin"
|
||||||
|
className="w-8 h-8 rounded-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* CONTENT */}
|
||||||
|
<main className="p-6 overflow-y-auto">
|
||||||
|
|
||||||
|
{/* TOP BAR */}
|
||||||
|
<div className="flex justify-between mb-6">
|
||||||
|
|
||||||
|
<h1 className="text-xl font-semibold">
|
||||||
|
Categories
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => openCreate()}
|
||||||
|
className="bg-blue-600 text-white px-4 py-2 rounded"
|
||||||
|
>
|
||||||
|
+ New Category
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* TREE */}
|
||||||
|
<div className="bg-white border rounded-xl p-4">
|
||||||
|
{loading ? (
|
||||||
|
<p>Loading...</p>
|
||||||
|
) : (
|
||||||
|
tree.map((node) => (
|
||||||
|
<TreeNode key={node.id} node={node} />
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ================= MODAL ================= */}
|
||||||
|
{modalOpen && (
|
||||||
|
<div className="fixed inset-0 bg-black/40 flex items-center justify-center">
|
||||||
|
|
||||||
|
<div className="bg-white w-[420px] p-5 rounded-xl">
|
||||||
|
|
||||||
|
<h2 className="font-semibold mb-4">
|
||||||
|
{form.id ? "Edit Category" : "Create Category"}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<input
|
||||||
|
className="w-full border p-2 rounded mb-2"
|
||||||
|
placeholder="Name"
|
||||||
|
value={form.name}
|
||||||
|
onChange={(e) =>
|
||||||
|
setForm({
|
||||||
|
...form,
|
||||||
|
name: e.target.value,
|
||||||
|
slug: slugify(e.target.value),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<input
|
||||||
|
className="w-full border p-2 rounded mb-3"
|
||||||
|
placeholder="Slug"
|
||||||
|
value={form.slug}
|
||||||
|
onChange={(e) =>
|
||||||
|
setForm({ ...form, slug: e.target.value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
|
||||||
|
<button onClick={closeModal}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={save}
|
||||||
|
className="bg-blue-600 text-white px-3 py-1 rounded"
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Componentes Auxiliares para Limpeza de Código
|
||||||
|
const NavItem = ({ icon, label, active = false }: { icon: any, label: string, active?: boolean }) => (
|
||||||
|
<div className={`flex items-center gap-3 px-3 py-2 rounded-xl text-sm font-medium transition-all cursor-pointer ${
|
||||||
|
active ? 'bg-blue-600 text-white shadow-md shadow-blue-200' : 'text-slate-500 hover:bg-slate-100'
|
||||||
|
}`}>
|
||||||
|
{icon}
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const ActivityItem = ({ user, action }: { user: string, action: string }) => (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<div className="w-6 h-6 bg-slate-200 rounded-full shrink-0" />
|
||||||
|
<p className="text-[10px] leading-tight text-slate-600">
|
||||||
|
<span className="font-bold block text-slate-900">{user}</span> {action}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
@@ -1,14 +1,14 @@
|
|||||||
import { NextResponse } from "next/server";
|
// import { NextResponse } from "next/server";
|
||||||
import { readSliderPhotos } from "@/lib/slider-photos";
|
// import { readSliderPhotos } from "@/lib/slider-photos";
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
// export const runtime = "nodejs";
|
||||||
export const dynamic = "force-dynamic";
|
// export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
export async function GET() {
|
// export async function GET() {
|
||||||
try {
|
// try {
|
||||||
const photos = readSliderPhotos();
|
// const photos = readSliderPhotos();
|
||||||
return NextResponse.json(photos);
|
// return NextResponse.json(photos);
|
||||||
} catch {
|
// } catch {
|
||||||
return NextResponse.json([]);
|
// return NextResponse.json([]);
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
// export function CategoryModal({
|
||||||
|
// open,
|
||||||
|
// onClose,
|
||||||
|
// form,
|
||||||
|
// setForm,
|
||||||
|
// onSave,
|
||||||
|
// }: any) {
|
||||||
|
// if (!open) return null;
|
||||||
|
|
||||||
|
// return (
|
||||||
|
// <div className="fixed inset-0 bg-black/30 flex items-center justify-center">
|
||||||
|
|
||||||
|
// <div className="bg-white p-5 rounded-xl w-[400px]">
|
||||||
|
|
||||||
|
// <h2 className="font-semibold mb-3">
|
||||||
|
// {form.id ? "Edit Category" : "New Category"}
|
||||||
|
// </h2>
|
||||||
|
|
||||||
|
// <input
|
||||||
|
// className="w-full border p-2 rounded mb-2"
|
||||||
|
// placeholder="Name"
|
||||||
|
// value={form.name}
|
||||||
|
// onChange={(e) =>
|
||||||
|
// setForm({ ...form, name: e.target.value })
|
||||||
|
// }
|
||||||
|
// />
|
||||||
|
|
||||||
|
// <input
|
||||||
|
// className="w-full border p-2 rounded mb-3"
|
||||||
|
// placeholder="Slug (auto)"
|
||||||
|
// value={form.slug}
|
||||||
|
// onChange={(e) =>
|
||||||
|
// setForm({ ...form, slug: e.target.value })
|
||||||
|
// }
|
||||||
|
// />
|
||||||
|
|
||||||
|
// <div className="flex justify-end gap-2">
|
||||||
|
// <button onClick={onClose}>Cancel</button>
|
||||||
|
|
||||||
|
// <button
|
||||||
|
// onClick={onSave}
|
||||||
|
// className="bg-blue-600 text-white px-3 py-1 rounded"
|
||||||
|
// >
|
||||||
|
// Save
|
||||||
|
// </button>
|
||||||
|
// </div>
|
||||||
|
// </div>
|
||||||
|
// </div>
|
||||||
|
// );
|
||||||
|
// }
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
// "use client";
|
||||||
|
|
||||||
|
// import React, { useState } from "react";
|
||||||
|
// import {
|
||||||
|
// FolderTree,
|
||||||
|
// Edit3,
|
||||||
|
// Trash2,
|
||||||
|
// Plus,
|
||||||
|
// ChevronRight,
|
||||||
|
// ChevronDown,
|
||||||
|
// } from "lucide-react";
|
||||||
|
// import { Category } from "@/lib/categories.api";
|
||||||
|
|
||||||
|
// export function CategoryTree({
|
||||||
|
// nodes,
|
||||||
|
// onEdit,
|
||||||
|
// onDelete,
|
||||||
|
// onAddChild,
|
||||||
|
// }: {
|
||||||
|
// nodes: Category[];
|
||||||
|
// onEdit: (c: Category) => void;
|
||||||
|
// onDelete: (id: string) => void;
|
||||||
|
// onAddChild: (parentId: string) => void;
|
||||||
|
// }) {
|
||||||
|
// return (
|
||||||
|
// <div>
|
||||||
|
// {nodes.map((node) => (
|
||||||
|
// <TreeNode
|
||||||
|
// key={node.id}
|
||||||
|
// node={node}
|
||||||
|
// onEdit={onEdit}
|
||||||
|
// onDelete={onDelete}
|
||||||
|
// onAddChild={onAddChild}
|
||||||
|
// />
|
||||||
|
// ))}
|
||||||
|
// </div>
|
||||||
|
// );
|
||||||
|
// }
|
||||||
|
|
||||||
|
// function TreeNode({
|
||||||
|
// node,
|
||||||
|
// onEdit,
|
||||||
|
// onDelete,
|
||||||
|
// onAddChild,
|
||||||
|
// }: {
|
||||||
|
// node: Category;
|
||||||
|
// onEdit: (c: Category) => void;
|
||||||
|
// onDelete: (id: string) => void;
|
||||||
|
// onAddChild: (parentId: string) => void;
|
||||||
|
// }) {
|
||||||
|
// const [open, setOpen] = useState(true);
|
||||||
|
|
||||||
|
// return (
|
||||||
|
// <div className="ml-2 border-l pl-3">
|
||||||
|
|
||||||
|
// {/* NODE ROW */}
|
||||||
|
// <div className="flex items-center justify-between py-2 group">
|
||||||
|
|
||||||
|
// <div className="flex items-center gap-2">
|
||||||
|
|
||||||
|
// <button onClick={() => setOpen(!open)}>
|
||||||
|
// {open ? (
|
||||||
|
// <ChevronDown size={14} />
|
||||||
|
// ) : (
|
||||||
|
// <ChevronRight size={14} />
|
||||||
|
// )}
|
||||||
|
// </button>
|
||||||
|
|
||||||
|
// <FolderTree size={14} className="text-blue-500" />
|
||||||
|
|
||||||
|
// {/* INLINE EDIT TRIGGER */}
|
||||||
|
// <span
|
||||||
|
// onClick={() => onEdit(node)}
|
||||||
|
// className="text-sm font-medium cursor-pointer hover:text-blue-600"
|
||||||
|
// >
|
||||||
|
// {node.name}
|
||||||
|
// </span>
|
||||||
|
// </div>
|
||||||
|
|
||||||
|
// {/* ACTIONS (SHOW ON HOVER) */}
|
||||||
|
// <div className="flex gap-2 opacity-0 group-hover:opacity-100 transition">
|
||||||
|
|
||||||
|
// <button
|
||||||
|
// onClick={() => onAddChild(node.id)}
|
||||||
|
// className="text-green-600"
|
||||||
|
// >
|
||||||
|
// <Plus size={14} />
|
||||||
|
// </button>
|
||||||
|
|
||||||
|
// <button onClick={() => onEdit(node)}>
|
||||||
|
// <Edit3 size={14} />
|
||||||
|
// </button>
|
||||||
|
|
||||||
|
// <button onClick={() => onDelete(node.id)}>
|
||||||
|
// <Trash2 size={14} className="text-red-500" />
|
||||||
|
// </button>
|
||||||
|
// </div>
|
||||||
|
// </div>
|
||||||
|
|
||||||
|
// {/* CHILDREN */}
|
||||||
|
// {open && node.children?.length ? (
|
||||||
|
// <div>
|
||||||
|
// {node.children.map((child) => (
|
||||||
|
// <TreeNode
|
||||||
|
// key={child.id}
|
||||||
|
// node={child}
|
||||||
|
// onEdit={onEdit}
|
||||||
|
// onDelete={onDelete}
|
||||||
|
// onAddChild={onAddChild}
|
||||||
|
// />
|
||||||
|
// ))}
|
||||||
|
// </div>
|
||||||
|
// ) : null}
|
||||||
|
// </div>
|
||||||
|
// );
|
||||||
|
// }
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import {
|
||||||
|
Category,
|
||||||
|
getCategoriesTree,
|
||||||
|
getCategoriesFlat,
|
||||||
|
createCategory,
|
||||||
|
updateCategory,
|
||||||
|
deleteCategory,
|
||||||
|
} from "@/lib/categories.api";
|
||||||
|
import { slugify } from "@/lib/slug";
|
||||||
|
|
||||||
|
export function useCategories() {
|
||||||
|
const [tree, setTree] = useState<Category[]>([]);
|
||||||
|
const [flat, setFlat] = useState<Category[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const [form, setForm] = useState({
|
||||||
|
id: null as string | null,
|
||||||
|
name: "",
|
||||||
|
slug: "",
|
||||||
|
parentId: null as string | null,
|
||||||
|
});
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const [t, f] = await Promise.all([
|
||||||
|
getCategoriesTree(),
|
||||||
|
getCategoriesFlat(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
setTree(t);
|
||||||
|
setFlat(f);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
const payload = {
|
||||||
|
name: form.name,
|
||||||
|
slug: form.slug || slugify(form.name),
|
||||||
|
parentId: form.parentId,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (form.id) {
|
||||||
|
await updateCategory(form.id, payload);
|
||||||
|
} else {
|
||||||
|
await createCategory(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
resetForm();
|
||||||
|
load();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove(id: string) {
|
||||||
|
await deleteCategory(id);
|
||||||
|
load();
|
||||||
|
}
|
||||||
|
|
||||||
|
function edit(cat: Category) {
|
||||||
|
setForm({
|
||||||
|
id: cat.id,
|
||||||
|
name: cat.name,
|
||||||
|
slug: cat.slug,
|
||||||
|
parentId: cat.parentId || null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetForm() {
|
||||||
|
setForm({ id: null, name: "", slug: "", parentId: null });
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
tree,
|
||||||
|
flat,
|
||||||
|
form,
|
||||||
|
setForm,
|
||||||
|
save,
|
||||||
|
remove,
|
||||||
|
edit,
|
||||||
|
resetForm,
|
||||||
|
loading,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
const API = "http://localhost:3001/categories";
|
||||||
|
|
||||||
|
export interface Category {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
parentId?: string | null;
|
||||||
|
children?: Category[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCategoriesTree(): Promise<Category[]> {
|
||||||
|
const res = await fetch(`${API}/`);
|
||||||
|
const data = await res.json();
|
||||||
|
return Array.isArray(data) ? data : data?.data ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCategoriesFlat(): Promise<Category[]> {
|
||||||
|
const res = await fetch(API);
|
||||||
|
const data = await res.json();
|
||||||
|
return Array.isArray(data) ? data : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createCategory(payload: Partial<Category>) {
|
||||||
|
return fetch(API, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateCategory(id: string, payload: Partial<Category>) {
|
||||||
|
return fetch(`${API}/${id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteCategory(id: string) {
|
||||||
|
return fetch(`${API}/${id}`, { method: "DELETE" });
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export async function getTree(): Promise<Category[]> {
|
||||||
|
const res = await fetch(`${API}/`);
|
||||||
|
const data = await res.json();
|
||||||
|
return Array.isArray(data) ? data : data?.data ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getFlat(): Promise<Category[]> {
|
||||||
|
const res = await fetch(API);
|
||||||
|
const data = await res.json();
|
||||||
|
return Array.isArray(data) ? data : [];
|
||||||
|
}
|
||||||
+55
-55
@@ -1,63 +1,63 @@
|
|||||||
import fs from "fs";
|
// import fs from "fs";
|
||||||
import path from "path";
|
// import path from "path";
|
||||||
|
|
||||||
export type SliderPhoto = { src: string; alt: string };
|
// export type SliderPhoto = { src: string; alt: string };
|
||||||
|
|
||||||
function slugToAlt(filename: string): string {
|
// function slugToAlt(filename: string): string {
|
||||||
const base = filename.replace(/\.[^.]+$/, "");
|
// const base = filename.replace(/\.[^.]+$/, "");
|
||||||
const words = base.replace(/[-_]+/g, " ").trim();
|
// const words = base.replace(/[-_]+/g, " ").trim();
|
||||||
return words || "Slide";
|
// return words || "Slide";
|
||||||
}
|
// }
|
||||||
|
|
||||||
export function parseManifest(data: unknown): SliderPhoto[] {
|
// export function parseManifest(data: unknown): SliderPhoto[] {
|
||||||
if (!Array.isArray(data)) return [];
|
// if (!Array.isArray(data)) return [];
|
||||||
const out: SliderPhoto[] = [];
|
// const out: SliderPhoto[] = [];
|
||||||
for (const item of data) {
|
// for (const item of data) {
|
||||||
if (typeof item !== "object" || item === null || !("src" in item)) continue;
|
// if (typeof item !== "object" || item === null || !("src" in item)) continue;
|
||||||
const src = (item as { src: unknown }).src;
|
// const src = (item as { src: unknown }).src;
|
||||||
if (typeof src !== "string" || !src.startsWith("/")) continue;
|
// if (typeof src !== "string" || !src.startsWith("/")) continue;
|
||||||
const altRaw = (item as { alt?: unknown }).alt;
|
// const altRaw = (item as { alt?: unknown }).alt;
|
||||||
const alt = typeof altRaw === "string" && altRaw.trim() ? altRaw.trim() : slugToAlt(src.split("/").pop() ?? "");
|
// const alt = typeof altRaw === "string" && altRaw.trim() ? altRaw.trim() : slugToAlt(src.split("/").pop() ?? "");
|
||||||
out.push({ src, alt });
|
// out.push({ src, alt });
|
||||||
}
|
// }
|
||||||
return out;
|
// return out;
|
||||||
}
|
// }
|
||||||
|
|
||||||
const IMAGE_EXT = /\.(jpe?g|png|webp|gif|avif)$/i;
|
// const IMAGE_EXT = /\.(jpe?g|png|webp|gif|avif)$/i;
|
||||||
|
|
||||||
function scanSliderDirectory(dir: string): SliderPhoto[] {
|
// function scanSliderDirectory(dir: string): SliderPhoto[] {
|
||||||
let names: string[] = [];
|
// let names: string[] = [];
|
||||||
try {
|
// try {
|
||||||
names = fs.readdirSync(dir);
|
// names = fs.readdirSync(dir);
|
||||||
} catch {
|
// } catch {
|
||||||
return [];
|
// return [];
|
||||||
}
|
// }
|
||||||
return names
|
// return names
|
||||||
.filter((f) => IMAGE_EXT.test(f))
|
// .filter((f) => IMAGE_EXT.test(f))
|
||||||
.sort((a, b) => a.localeCompare(b, undefined, { sensitivity: "base" }))
|
// .sort((a, b) => a.localeCompare(b, undefined, { sensitivity: "base" }))
|
||||||
.map((f) => ({
|
// .map((f) => ({
|
||||||
src: `/slider/${f}`,
|
// src: `/slider/${f}`,
|
||||||
alt: slugToAlt(f),
|
// alt: slugToAlt(f),
|
||||||
}));
|
// }));
|
||||||
}
|
// }
|
||||||
|
|
||||||
/**
|
// /**
|
||||||
* Reads `public/slider/manifest.json` when present (full control: order + alt).
|
// * Reads `public/slider/manifest.json` when present (full control: order + alt).
|
||||||
* Otherwise scans `public/slider` for image files (drop-in updates, no code edits).
|
// * Otherwise scans `public/slider` for image files (drop-in updates, no code edits).
|
||||||
*/
|
// */
|
||||||
export function readSliderPhotos(): SliderPhoto[] {
|
// export function readSliderPhotos(): SliderPhoto[] {
|
||||||
const dir = path.join(process.cwd(), "public", "slider");
|
// const dir = path.join(process.cwd(), "public", "slider");
|
||||||
if (!fs.existsSync(dir)) return [];
|
// if (!fs.existsSync(dir)) return [];
|
||||||
|
|
||||||
const manifestPath = path.join(dir, "manifest.json");
|
// const manifestPath = path.join(dir, "manifest.json");
|
||||||
if (fs.existsSync(manifestPath)) {
|
// if (fs.existsSync(manifestPath)) {
|
||||||
try {
|
// try {
|
||||||
const raw = JSON.parse(fs.readFileSync(manifestPath, "utf8")) as unknown;
|
// const raw = JSON.parse(fs.readFileSync(manifestPath, "utf8")) as unknown;
|
||||||
return parseManifest(raw);
|
// return parseManifest(raw);
|
||||||
} catch {
|
// } catch {
|
||||||
return [];
|
// return [];
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
|
|
||||||
return scanSliderDirectory(dir);
|
// return scanSliderDirectory(dir);
|
||||||
}
|
// }
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
export function slugify(text: string) {
|
||||||
|
return text
|
||||||
|
.toLowerCase()
|
||||||
|
.trim()
|
||||||
|
.replace(/\s+/g, "-")
|
||||||
|
.replace(/[^a-z0-9-]/g, "");
|
||||||
|
}
|
||||||
@@ -9,9 +9,13 @@
|
|||||||
"lint": "eslint"
|
"lint": "eslint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@react-oauth/google": "^0.13.5",
|
"@react-oauth/google": "^0.13.5",
|
||||||
"@tinymce/tinymce-react": "^6.3.0",
|
"@tinymce/tinymce-react": "^6.3.0",
|
||||||
"framer-motion": "^12.38.0",
|
"framer-motion": "^12.38.0",
|
||||||
|
"keycloak-js": "^26.2.3",
|
||||||
"lucide-react": "^1.8.0",
|
"lucide-react": "^1.8.0",
|
||||||
"next": "16.2.1",
|
"next": "16.2.1",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
|
|||||||
Generated
+64
@@ -8,6 +8,15 @@ importers:
|
|||||||
|
|
||||||
.:
|
.:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
'@dnd-kit/core':
|
||||||
|
specifier: ^6.3.1
|
||||||
|
version: 6.3.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||||
|
'@dnd-kit/sortable':
|
||||||
|
specifier: ^10.0.0
|
||||||
|
version: 10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)
|
||||||
|
'@dnd-kit/utilities':
|
||||||
|
specifier: ^3.2.2
|
||||||
|
version: 3.2.2(react@19.2.4)
|
||||||
'@react-oauth/google':
|
'@react-oauth/google':
|
||||||
specifier: ^0.13.5
|
specifier: ^0.13.5
|
||||||
version: 0.13.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
version: 0.13.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||||
@@ -17,6 +26,9 @@ importers:
|
|||||||
framer-motion:
|
framer-motion:
|
||||||
specifier: ^12.38.0
|
specifier: ^12.38.0
|
||||||
version: 12.38.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
version: 12.38.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||||
|
keycloak-js:
|
||||||
|
specifier: ^26.2.3
|
||||||
|
version: 26.2.3
|
||||||
lucide-react:
|
lucide-react:
|
||||||
specifier: ^1.8.0
|
specifier: ^1.8.0
|
||||||
version: 1.8.0(react@19.2.4)
|
version: 1.8.0(react@19.2.4)
|
||||||
@@ -140,6 +152,28 @@ packages:
|
|||||||
resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==}
|
resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==}
|
||||||
engines: {node: '>=6.9.0'}
|
engines: {node: '>=6.9.0'}
|
||||||
|
|
||||||
|
'@dnd-kit/accessibility@3.1.1':
|
||||||
|
resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==}
|
||||||
|
peerDependencies:
|
||||||
|
react: '>=16.8.0'
|
||||||
|
|
||||||
|
'@dnd-kit/core@6.3.1':
|
||||||
|
resolution: {integrity: sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==}
|
||||||
|
peerDependencies:
|
||||||
|
react: '>=16.8.0'
|
||||||
|
react-dom: '>=16.8.0'
|
||||||
|
|
||||||
|
'@dnd-kit/sortable@10.0.0':
|
||||||
|
resolution: {integrity: sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==}
|
||||||
|
peerDependencies:
|
||||||
|
'@dnd-kit/core': ^6.3.0
|
||||||
|
react: '>=16.8.0'
|
||||||
|
|
||||||
|
'@dnd-kit/utilities@3.2.2':
|
||||||
|
resolution: {integrity: sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==}
|
||||||
|
peerDependencies:
|
||||||
|
react: '>=16.8.0'
|
||||||
|
|
||||||
'@emnapi/core@1.9.1':
|
'@emnapi/core@1.9.1':
|
||||||
resolution: {integrity: sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA==}
|
resolution: {integrity: sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA==}
|
||||||
|
|
||||||
@@ -1448,6 +1482,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==}
|
resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==}
|
||||||
engines: {node: '>=4.0'}
|
engines: {node: '>=4.0'}
|
||||||
|
|
||||||
|
keycloak-js@26.2.3:
|
||||||
|
resolution: {integrity: sha512-widjzw/9T6bHRgEp6H/Se3NCCarU7u5CwFKBcwtu7xfA1IfdZb+7Q7/KGusAnBo34Vtls8Oz9vzSqkQvQ7+b4Q==}
|
||||||
|
|
||||||
keyv@4.5.4:
|
keyv@4.5.4:
|
||||||
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
|
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
|
||||||
|
|
||||||
@@ -2148,6 +2185,31 @@ snapshots:
|
|||||||
'@babel/helper-string-parser': 7.27.1
|
'@babel/helper-string-parser': 7.27.1
|
||||||
'@babel/helper-validator-identifier': 7.28.5
|
'@babel/helper-validator-identifier': 7.28.5
|
||||||
|
|
||||||
|
'@dnd-kit/accessibility@3.1.1(react@19.2.4)':
|
||||||
|
dependencies:
|
||||||
|
react: 19.2.4
|
||||||
|
tslib: 2.8.1
|
||||||
|
|
||||||
|
'@dnd-kit/core@6.3.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
|
||||||
|
dependencies:
|
||||||
|
'@dnd-kit/accessibility': 3.1.1(react@19.2.4)
|
||||||
|
'@dnd-kit/utilities': 3.2.2(react@19.2.4)
|
||||||
|
react: 19.2.4
|
||||||
|
react-dom: 19.2.4(react@19.2.4)
|
||||||
|
tslib: 2.8.1
|
||||||
|
|
||||||
|
'@dnd-kit/sortable@10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)':
|
||||||
|
dependencies:
|
||||||
|
'@dnd-kit/core': 6.3.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||||
|
'@dnd-kit/utilities': 3.2.2(react@19.2.4)
|
||||||
|
react: 19.2.4
|
||||||
|
tslib: 2.8.1
|
||||||
|
|
||||||
|
'@dnd-kit/utilities@3.2.2(react@19.2.4)':
|
||||||
|
dependencies:
|
||||||
|
react: 19.2.4
|
||||||
|
tslib: 2.8.1
|
||||||
|
|
||||||
'@emnapi/core@1.9.1':
|
'@emnapi/core@1.9.1':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@emnapi/wasi-threads': 1.2.0
|
'@emnapi/wasi-threads': 1.2.0
|
||||||
@@ -3514,6 +3576,8 @@ snapshots:
|
|||||||
object.assign: 4.1.7
|
object.assign: 4.1.7
|
||||||
object.values: 1.2.1
|
object.values: 1.2.1
|
||||||
|
|
||||||
|
keycloak-js@26.2.3: {}
|
||||||
|
|
||||||
keyv@4.5.4:
|
keyv@4.5.4:
|
||||||
dependencies:
|
dependencies:
|
||||||
json-buffer: 3.0.1
|
json-buffer: 3.0.1
|
||||||
|
|||||||
Reference in New Issue
Block a user