Compare commits

...

105 Commits

Author SHA1 Message Date
peter f163f22987 cooment code
continuous-integration/drone/push Build is failing
2026-04-18 13:22:22 +01:00
peter 5e5a43094f reorganize page 2026-04-18 13:21:13 +01:00
peter 661784a73d add page 2026-04-18 11:28:23 +01:00
peter 7854e3dd44 show profile picture and name
continuous-integration/drone/push Build is passing
2026-04-17 16:15:00 +01:00
peter 2ab775514e make the link dynamic 2026-04-17 16:03:17 +01:00
peter c8383955d5 get token inside the app
continuous-integration/drone/push Build is passing
2026-04-17 15:57:13 +01:00
peter 0a645744f0 fetch profile 2026-04-17 15:08:40 +01:00
peter 4e7794476b login with gmail with keyclock 2026-04-17 10:48:36 +01:00
peter 6555a171ee add google 2026-04-17 10:08:18 +01:00
peter 8454abea36 add interface 2026-04-16 21:13:17 +01:00
peter 73e0834d18 remove text
continuous-integration/drone/push Build is passing
continuous-integration/drone Build is passing
2026-04-15 15:39:51 +01:00
peter 006305ca3f add tiny
continuous-integration/drone/push Build is passing
2026-04-15 15:14:34 +01:00
peter 1aceeafd72 improve crete news scren
continuous-integration/drone/push Build is passing
2026-04-15 15:00:10 +01:00
peter 09f74d2439 add 2026-04-15 14:52:57 +01:00
peter e9cbf91e91 .editorconfig
continuous-integration/drone/push Build is passing
2026-04-15 14:37:20 +01:00
peter eec32932e7 change logo 2026-04-15 14:36:46 +01:00
peter 9fb75d8db6 create noticias
continuous-integration/drone/push Build is passing
2026-04-15 14:12:03 +01:00
peter 95a80a72c7 login page
continuous-integration/drone/push Build is passing
2026-04-15 13:39:15 +01:00
peter 88759d56cf chage color
continuous-integration/drone/push Build is passing
2026-04-15 13:22:38 +01:00
peter 6e7fd74a31 make color march 2026-04-15 13:21:59 +01:00
peter 30f2152524 change color
continuous-integration/drone/push Build is passing
2026-04-15 13:14:14 +01:00
peter b30b8503f9 add theme toggle button
continuous-integration/drone/push Build is passing
2026-04-15 13:10:33 +01:00
peter f8d5e45673 add theme button 2026-04-15 12:58:16 +01:00
peter b84d6dd162 add login page 2026-04-15 12:50:44 +01:00
peter 1beda1f8f6 add date 2026-04-15 10:48:07 +01:00
peter c46857514b add category
continuous-integration/drone/push Build is passing
2026-04-15 09:58:11 +01:00
peter f110435720 images
continuous-integration/drone/push Build is passing
2026-04-12 12:36:14 +01:00
peter 554e469930 add like ubtton
continuous-integration/drone/push Build is passing
2026-04-12 12:27:57 +01:00
peter e3f7764a03 add message 2026-04-12 11:51:37 +01:00
peter db7fcb8a88 fix 2026-04-12 11:40:57 +01:00
peter 61e5ceefcd fix images
continuous-integration/drone/push Build is passing
2026-04-12 11:32:47 +01:00
peter cfb50a10fa change size
continuous-integration/drone/push Build is passing
2026-04-12 10:26:48 +01:00
peter 9e7976cd27 add ads
continuous-integration/drone/push Build is passing
2026-04-12 10:10:54 +01:00
peter 9dd1553656 safe
continuous-integration/drone/push Build is passing
2026-04-12 00:05:57 +01:00
peter dcc9fccbde finish content page
continuous-integration/drone/push Build is passing
2026-04-11 23:52:05 +01:00
peter 104207d909 improve
continuous-integration/drone/push Build is passing
2026-04-11 03:15:00 +01:00
peter f8f2c3cbbf labels
continuous-integration/drone/push Build is passing
2026-04-11 02:44:05 +01:00
peter 50649bb134 fix 2026-04-11 02:39:13 +01:00
peter 825cda0765 border 2026-04-11 00:25:37 +01:00
peter 891a5d8299 fix border
continuous-integration/drone/push Build is passing
2026-04-11 00:21:57 +01:00
peter e3b3917021 border 2026-04-11 00:10:59 +01:00
peter c81100fab8 change border 2026-04-11 00:09:18 +01:00
peter 5fc3e6535f text
continuous-integration/drone/push Build is passing
2026-04-10 23:59:18 +01:00
peter 1c8342ae00 fix image
continuous-integration/drone/push Build is passing
2026-04-10 23:46:06 +01:00
peter 4bcdde8bae make it cool
continuous-integration/drone/push Build is passing
2026-04-10 22:22:43 +01:00
peter 8ce72c86df change font
continuous-integration/drone/push Build is passing
2026-04-10 21:58:05 +01:00
peter 6308889ee8 unoptimized images
continuous-integration/drone/push Build is passing
2026-04-10 21:44:21 +01:00
peter d20fff0f19 change production docker
continuous-integration/drone/push Build is passing
2026-04-10 21:21:17 +01:00
peter 50ef8326dd add custom play button
continuous-integration/drone/push Build is passing
2026-04-10 21:12:03 +01:00
peter 5e818f1823 add video
continuous-integration/drone/push Build is passing
2026-04-10 20:46:42 +01:00
peter ab3bf9e2bd make it loog good 2026-04-10 20:31:56 +01:00
peter 4c799f5cc5 add destaque 2026-04-10 19:53:53 +01:00
peter 17d0655ac5 update docker
continuous-integration/drone/push Build is passing
2026-04-10 15:39:03 +01:00
peter 59237b2724 add content page
continuous-integration/drone/push Build is passing
2026-04-10 15:11:03 +01:00
peter 65a29f343c add scroll
continuous-integration/drone/push Build is passing
2026-04-09 14:12:44 +01:00
peter 3aab9adaea add date 2026-04-09 13:37:49 +01:00
peter 1baabc88b9 add date 2026-04-09 13:37:43 +01:00
peter ebff50e25b add spacing 2026-04-09 13:20:17 +01:00
peter cbddf00e20 add spacing 2026-04-09 13:20:10 +01:00
peter f4f5350054 add new section 2026-04-09 13:19:55 +01:00
peter e6636db3a8 max-w-[1200px] 2026-04-09 13:19:42 +01:00
peter 8a274e0623 change card 2026-04-09 12:02:14 +01:00
peter 07756128d3 add new section 2026-04-09 12:02:05 +01:00
peter a4e74f9985 change footer 2026-04-09 12:01:51 +01:00
peter b2ba56d09d fix sllide
continuous-integration/drone/push Build is passing
2026-04-06 16:28:12 +01:00
peter ff97450a39 make slidee carousel
continuous-integration/drone/push Build is passing
2026-04-06 16:16:15 +01:00
peter 6a239557d2 fix
continuous-integration/drone/push Build is passing
2026-04-06 15:40:50 +01:00
peter f6bea75cc4 add label to destaque 2026-04-06 14:43:30 +01:00
peter 822eddd2ee add button to see more
continuous-integration/drone/push Build is passing
2026-04-06 13:57:31 +01:00
peter 89ecdd1b64 fix message 2026-04-06 09:43:32 +01:00
peter d926d1db3a chant name
continuous-integration/drone/push Build is passing
2026-04-05 20:25:02 +01:00
peter 654fad8b39 remove lable
continuous-integration/drone/push Build is passing
2026-04-05 17:10:23 +01:00
peter 9637833e5e make destaque look good
continuous-integration/drone/push Build is passing
2026-04-05 16:01:39 +01:00
peter 9996f1051c add galary
continuous-integration/drone/push Build is passing
2026-04-05 15:48:33 +01:00
peter 6bce9ada4f add video player 2026-04-05 14:25:03 +01:00
peter 25a82c5472 add ads
continuous-integration/drone/push Build is passing
2026-04-05 13:17:05 +01:00
peter 0676161047 add video galary
continuous-integration/drone/push Build is passing
2026-04-05 13:14:15 +01:00
peter 737915697d add icon
continuous-integration/drone/push Build is passing
2026-04-05 12:40:47 +01:00
peter a1cceeff0e change logo
continuous-integration/drone/push Build is passing
2026-04-05 12:21:27 +01:00
peter 59f69e8273 fix image container
continuous-integration/drone/push Build is passing
2026-04-05 00:10:22 +01:00
peter be8709478d improve
continuous-integration/drone/push Build is passing
2026-04-04 22:56:28 +01:00
peter 21761aa90a logo
continuous-integration/drone/push Build is passing
2026-04-04 22:54:13 +01:00
peter 500978b873 update logo
continuous-integration/drone/push Build is passing
2026-04-04 22:52:07 +01:00
peter 74cadfa23a change header text and destaque
continuous-integration/drone/push Build is passing
2026-04-04 22:02:04 +01:00
peter 22337dffd1 make text small for mobile
continuous-integration/drone/push Build is passing
2026-03-31 19:09:19 +01:00
peter e30f6e8b07 rounded
continuous-integration/drone Build is passing
2026-03-31 18:39:42 +01:00
peter b306fdad2c remove rounded 2026-03-31 18:39:35 +01:00
peter 0bc0149aa5 remove roundness 2026-03-31 18:39:22 +01:00
peter daa8bbac58 remove roundness 2026-03-31 18:39:01 +01:00
peter 07ac26270a change image container 2026-03-31 16:55:38 +01:00
peter 2d7da9f546 change header color 2026-03-30 11:46:59 +01:00
peter 66868e97af make website blue
continuous-integration/drone/push Build is passing
2026-03-28 23:22:50 +01:00
peter d917d8c198 make it appear complete on mobile
continuous-integration/drone/push Build is passing
2026-03-28 23:10:16 +01:00
peter d1f06cb9d5 improve slider
continuous-integration/drone/push Build is passing
2026-03-28 23:01:17 +01:00
peter 48ff3359ab improve footer
continuous-integration/drone/push Build is passing
2026-03-28 14:09:27 +01:00
peter c03967c24e remove text
continuous-integration/drone/push Build is passing
2026-03-28 13:39:02 +01:00
peter 4377e08f33 add footer icons
continuous-integration/drone/push Build is passing
2026-03-28 13:25:56 +01:00
peter 937b458a17 make font bigger
continuous-integration/drone/push Build is passing
2026-03-27 23:50:26 +01:00
peter 7c01b766bb make tet bigger
continuous-integration/drone/push Build is passing
2026-03-27 23:29:24 +01:00
peter d81a1763d9 save
continuous-integration/drone/push Build is passing
2026-03-27 23:15:54 +01:00
peter a8205d7e86 cooler
continuous-integration/drone/push Build is passing
2026-03-27 23:10:26 +01:00
peter 4cb3ea8980 make header look wow 2026-03-27 23:04:13 +01:00
peter 65b43cfa70 make footer 2026-03-27 23:01:08 +01:00
peter 325ce303c1 add navbar 2026-03-27 22:56:54 +01:00
peter 5cf82dadf8 remove extra header 2026-03-27 22:17:16 +01:00
44 changed files with 5012 additions and 409 deletions
+5
View File
@@ -0,0 +1,5 @@
---
alwaysApply: true
---
destaque
+13
View File
@@ -0,0 +1,13 @@
# editorconfig.org
root = true
[*]
indent_style = space
indent_size = 2
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
[*.md]
trim_trailing_whitespace = false
+46 -33
View File
@@ -1,64 +1,77 @@
# syntax=docker.io/docker/dockerfile:1
############################
# Base image
############################
FROM node:22-alpine AS base
# Install dependencies only when needed
FROM base AS deps
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
RUN apk add --no-cache libc6-compat
WORKDIR /app
# Install dependencies based on the preferred package manager
############################
# Dependencies
############################
FROM base AS deps
RUN apk add --no-cache libc6-compat
# Copy package files
COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* .npmrc* ./
# Install dependencies
RUN \
if [ -f yarn.lock ]; then yarn --frozen-lockfile; \
elif [ -f package-lock.json ]; then npm ci; \
elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm i --frozen-lockfile; \
else echo "Lockfile not found." && exit 1; \
fi
if [ -f yarn.lock ]; then yarn --frozen-lockfile; \
elif [ -f package-lock.json ]; then npm ci; \
elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm i --frozen-lockfile; \
else echo "Lockfile not found." && exit 1; \
fi
# Rebuild the source code only when needed
############################
# Builder
############################
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# Next.js collects completely anonymous telemetry data about general usage.
# Learn more here: https://nextjs.org/telemetry
# Uncomment the following line in case you want to disable telemetry during the build.
# ENV NEXT_TELEMETRY_DISABLED=1
# Disable telemetry during build (optional)
ENV NEXT_TELEMETRY_DISABLED=1
# Build Next.js app
RUN \
if [ -f yarn.lock ]; then yarn run build; \
elif [ -f package-lock.json ]; then npm run build; \
elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm run build; \
else echo "Lockfile not found." && exit 1; \
fi
if [ -f yarn.lock ]; then yarn build; \
elif [ -f package-lock.json ]; then npm run build; \
elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm build; \
else echo "Lockfile not found." && exit 1; \
fi
# Production image, copy all the files and run next
FROM base AS runner
############################
# Runner (production)
############################
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
# Uncomment the following line in case you want to disable telemetry during runtime.
# ENV NEXT_TELEMETRY_DISABLED=1
ENV NEXT_TELEMETRY_DISABLED=1
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
RUN addgroup --system --gid 1001 nodejs \
&& adduser --system --uid 1001 nextjs
# Copy standalone build and static files
COPY --from=builder /app/public ./public
# Automatically leverage output traces to reduce image size
# https://nextjs.org/docs/advanced-features/output-file-tracing
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
# 1. Create the specific cache directory Next.js is looking for
# 2. Give ownership to the 'nextjs' user
RUN mkdir -p /app/.next/cache/images && chown -R nextjs:nodejs /app/.next
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME=0.0.0.0
# server.js is created by next build from the standalone output
# https://nextjs.org/docs/pages/api-reference/config/next-config-js/output
ENV HOSTNAME="0.0.0.0"
CMD ["node", "server.js"]
+515
View File
@@ -0,0 +1,515 @@
"use client";
import React, { useState } from 'react';
import Image from 'next/image';
import { ThumbsUp, MessageCircle, Send, Share2, LinkIcon, Link2, ChevronRight } from 'lucide-react';
import { TvoneAdBanner, TvoneFooter } from '../../components/tvone-content';
import { TvonePromoStrip } from '../../components/tvone-promo-strip';
import { TvoneSiteNav } from '../../components/tvone-site-nav';
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 Link from 'next/link';
import { Users } from 'lucide-react';
export default function NewsArticlePage() {
// Estado para controlar o play do vídeo customizado
const [isPlaying, setIsPlaying] = useState(false);
const recentes = [
{
cat: "EM FOCO",
readTime: "6",
catBg: "bg-pink-100 text-pink-700",
title: "Governo anuncia medidas para apoiar famílias e pequenas empresas.",
excerpt: "Pacote inclui linhas de crédito e simplificação de procedimentos.",
byline: "Por Redação",
date: "24 Mar 2025",
img: "https://images.unsplash.com/photo-1507679799987-c73779587ccf?w=200&q=80",
},
{
cat: "ECONOMIA",
readTime: "6",
catBg: "bg-amber-100 text-amber-800",
title: "Inflação desce pelo terceiro mês consecutivo, segundo dados preliminares.",
excerpt: "Analistas mantêm cautela face ao cenário internacional.",
byline: "Por Economia",
date: "24 Mar 2025",
img: "https://images.unsplash.com/photo-1611974789855-9c2a0a7236a3?w=200&q=80",
},
{
cat: "CULTURA",
readTime: "6",
catBg: "bg-violet-100 text-violet-800",
title: "Museu inaugura exposição com obras inéditas de artistas locais.",
excerpt: "Visitas guiadas e programa educativo arrancam no próximo fim de semana.",
byline: "Por Cultura",
date: "23 Mar 2025",
img: "https://images.unsplash.com/photo-1507679799987-c73779587ccf?w=200&q=80",
},
{
cat: "SAÚDE",
readTime: "6",
catBg: "bg-emerald-100 text-emerald-800",
title: "Campanha de vacinação alarga faixas etárias em todo o país.",
excerpt: "Autoridades de saúde reforçam importância da adesão às janelas recomendadas.",
byline: "Por Saúde",
date: "23 Mar 2025",
img: "https://images.unsplash.com/photo-1576091160399-112ba8d25d1d?w=200&q=80",
},
];
return (
<div className="min-h-screen bg-white font-sans text-[#1d1d1f] selection:bg-[#0066CC]/20">
<TvonePromoStrip />
<TvoneSiteNav />
<main className="mx-auto max-w-[1240px] px-6 py-12">
<div className="flex flex-col gap-16 lg:flex-row lg:items-start">
{/* --- COLUNA PRINCIPAL (ARTIGO) --- */}
<article className="flex-1 min-w-0">
<div className="mb-4">
<span className="text-[13px] font-semibold uppercase tracking-wider text-[#0066CC]">
Música
</span>
</div>
<h1 className="Text-3xl font-bold tracking-tight text-neutral-900 text-2xl md:text-4xl">
Adele: A Turnê Mundial que Redefiniu a Indústria Musical.
</h1>
<div className="mt-6 flex items-center gap-3">
<div className="h-10 w-10 overflow-hidden rounded-full bg-[#f5f5f7] border border-black/5 flex items-center justify-center font-bold text-[#0066CC]">
R
</div>
<div className="text-[14px]">
<p className="font-semibold text-[#1d1d1f]">Redação TV ONE</p>
<p className="text-[#6e6e73]">12 de Junho, 2016 4 min de leitura</p>
</div>
</div>
{/* Hero Image (Bordas Apple 32px) */}
<div className="relative mt-10 aspect-video w-full overflow-hidden rounded-2xl bg-neutral-100 shadow-sm border border-black/5">
<Image
src="https://images.unsplash.com/photo-1543900694-133f37abaaa5"
alt="Adele Live 2016"
fill
className="object-cover transition-transform duration-700 hover:scale-105"
priority
/>
</div>
{/* Corpo do Conteúdo */}
<div className="mt-12 space-y-8 text-[20px] leading-[1.6] text-[#333] lg:text-[18px] px-0 lg:px-6">
<p className="text-1xl md:text-2xl font-bold leading-snug text-black lg:tracking-tight">
A turnê de Adele em 2016 foi o ápice de sua carreira, uma celebração de puro talento e conexão emocional que parou o mundo.
</p>
<p>
A turnê mundial redefiniu um império de mais de $160 milhões, com passagens por arenas esgotadas em quase cem cidades.
Diferente de outros shows da época, Adele apostou no minimalismo: apenas ela e sua voz impecável.
</p>
<p>
A turnê mundial redefiniu um império de mais de $160 milhões, com passagens por arenas esgotadas em quase cem cidades.
Diferente de outros shows da época, Adele apostou no minimalismo: apenas ela e sua voz impecável.
</p>
<p>
A turnê mundial redefiniu um império de mais de $160 milhões, com passagens por arenas esgotadas em quase cem cidades.
Diferente de outros shows da época, Adele apostou no minimalismo: apenas ela e sua voz impecável.
</p>
<p>
A turnê mundial redefiniu um império de mais de $160 milhões, com passagens por arenas esgotadas em quase cem cidades.
Diferente de outros shows da época, Adele apostou no minimalismo: apenas ela e sua voz impecável.
</p>
<p>
A turnê mundial redefiniu um império de mais de $160 milhões, com passagens por arenas esgotadas em quase cem cidades.
Diferente de outros shows da época, Adele apostou no minimalismo: apenas ela e sua voz impecável.
</p>
<p>
A turnê mundial redefiniu um império de mais de $160 milhões, com passagens por arenas esgotadas em quase cem cidades.
Diferente de outros shows da época, Adele apostou no minimalismo: apenas ela e sua voz impecável.
</p>
<p>
A turnê mundial redefiniu um império de mais de $160 milhões, com passagens por arenas esgotadas em quase cem cidades.
Diferente de outros shows da época, Adele apostou no minimalismo: apenas ela e sua voz impecável.
</p>
<p>
A turnê mundial redefiniu um império de mais de $160 milhões, com passagens por arenas esgotadas em quase cem cidades.
Diferente de outros shows da época, Adele apostou no minimalismo: apenas ela e sua voz impecável.
</p>
<p>
A turnê mundial redefiniu um império de mais de $160 milhões, com passagens por arenas esgotadas em quase cem cidades.
Diferente de outros shows da época, Adele apostou no minimalismo: apenas ela e sua voz impecável.
</p>
{/* Galeria Grid */}
<div className="grid grid-cols-2 gap-6 mt-12">
<div className="relative aspect-square overflow-hidden rounded-2xl bg-neutral-100 border border-black/5">
<Image unoptimized src="https://images.unsplash.com/photo-1493225255756-d9584f8606e9" fill className="object-cover" alt="Concerto Adele 1" />
</div>
<div className="relative aspect-square overflow-hidden rounded-2xl bg-neutral-100 border border-black/5">
<Image unoptimized src="https://images.unsplash.com/photo-1470225620780-dba8ba36b745" fill className="object-cover" alt="Concerto Adele 2" />
</div>
</div>
</div>
{/* Autor e Meta Info */}
<div className="mt-4 flex items-center justify-between pt-8">
<div className="flex items-center gap-4">
<div className="relative h-12 w-12 overflow-hidden rounded-full bg-blue-50 border border-blue-100 flex items-center justify-center">
<span className="text-[#0066CC] font-bold text-lg">R</span>
</div>
<div>
<p className="text-[15px] font-bold text-black">Por <span className="text-[#0066CC]">Redação</span></p>
<p className="text-[13px] text-neutral-400 font-medium">12 de Junho, 2016 | 09:30 UTC</p>
</div>
</div>
{/* Share Buttons (Estilo Clean Apple) */}
{/* <div className="flex items-center gap-2.5">
<button className="flex h-10 w-10 items-center justify-center rounded-2xl bg-neutral-50 text-neutral-500 transition-all hover:bg-[#25D366] hover:text-white hover:scale-110 active:scale-95">
<FaWhatsapp size={20} />
</button>
<button className="flex h-10 w-10 items-center justify-center rounded-2xl bg-neutral-50 text-neutral-500 transition-all hover:bg-[#1877F2] hover:text-white hover:scale-110 active:scale-95">
<FaFacebookF size={18} />
</button>
<button className="flex h-10 w-10 items-center justify-center rounded-2xl bg-neutral-50 text-neutral-500 border border-neutral-200 transition-all hover:bg-black hover:text-white hover:scale-110 active:scale-95">
<FaInstagram size={20} />
</button>
</div> */}
</div>
{/* Seção de Comentários */}
<section className="mt-24 border-t border-neutral-100 pt-16">
<h3 className="text-2xl font-[900] mb-8 flex items-center gap-3 tracking-tight">
<MessageCircle size={28} className="text-[#0066CC]"/> Comentários
</h3>
<div className="rounded-2xl bg-neutral-50 p-8 border border-neutral-100 transition-all focus-within:border-[#0066CC]/30 focus-within:bg-white focus-within:shadow-xl focus-within:shadow-blue-500/5">
<textarea
className="w-full bg-transparent outline-none text-lg placeholder:text-neutral-400"
placeholder="Partilhe a sua opinião..."
rows={4}
/>
<div className="mt-4 flex justify-end">
<button className="flex items-center gap-2 rounded-2xl bg-[#0066CC] px-10 py-3 font-bold text-white transition-all hover:opacity-90 active:scale-95 shadow-lg shadow-blue-500/20">
Enviar <Send size={18}/>
</button>
</div>
</div>
</section>
</article>
{/* --- SIDEBAR REFINADA --- */}
{/* --- SIDEBAR REFINADA COM SOCIAL SHARE --- */}
<aside className="w-full lg:w-[340px] flex flex-col justify-center align-center items-center shrink-0">
<div className="sticky top-10 space-y-10 max-w-[400px]">
<div className="bg-white p-5 rounded-xl border border-neutral-100 shadow-[0_2px_15px_rgba(0,0,0,0.03)]">
{/* 1. Header: Adapted from your Share component */}
<div className="flex items-center gap-2 mb-4">
{/* Usando Users icon for Community */}
<Users size={15} className="text-neutral-400" />
<h3 className="text-[11px] font-bold text-neutral-400 uppercase tracking-widest">
Community
</h3>
</div>
{/* 2. Main Content Area: Simplified from the UI mockup */}
<div className="flex flex-col gap-5">
{/* Column for Brand and Button */}
<div className="flex flex-col gap-4">
{/* Brand Info */}
{/* Brand Info - A highly stylized placeholder that looks 'premium' */}
{/* Brand Info - FIXED to use your actual logo image */}
<div className="flex items-center gap-3">
{/* 1. Use Next/Image for your official icon */}
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-xl overflow-hidden shadow-inner border border-neutral-100">
<Image
src="/logo.png" // <-- UPDATE THIS PATH to your square logo
alt="TVone Icon Logo"
width={48} // Width in pixels (matches h-12/w-12 class)
height={48} // Height in pixels
className="object-contain" // Ensures the logo doesn't stretch
/>
</div>
{/* 2. Brand text matches previous styling */}
<div>
<p className="text-sm font-bold text-neutral-900 leading-tight">TVone</p>
<p className="text-[11px] font-medium text-neutral-500">500k seguidores</p>
</div>
</div>
{/* Main Action Button */}
<button className="flex h-11 w-full items-center justify-center gap-2.5 rounded-lg bg-[#1877F2] text-white font-semibold text-sm transition-all hover:bg-[#166fe5] active:scale-95 shadow-sm">
<SiFacebook size={16} />
Gosto da TVone
</button>
</div>
</div>
</div>
{/* 1. SEÇÃO DE SHARE (ESTILO APPLE NEWS) */}
{/* <div className="bg-white p-5 rounded-xl border border-neutral-100 shadow-[0_2px_15px_rgba(0,0,0,0.03)]">
<div className="flex items-center gap-2 mb-4">
<Share2 size={15} className="text-neutral-400" />
<h3 className="text-[11px] font-bold text-neutral-400 uppercase tracking-widest">
Compartilhar
</h3>
</div>
<div className="grid grid-cols-4 gap-3">
<button className="group flex flex-col items-center gap-2">
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-neutral-100 text-neutral-600 transition-all group-hover:bg-neutral-200 active:scale-95">
<SiWhatsapp size={20} />
</div>
<span className="text-[10px] font-medium text-neutral-500">WhatsApp</span>
</button>
<button className="group flex flex-col items-center gap-2">
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-neutral-100 text-neutral-600 transition-all group-hover:bg-neutral-200 active:scale-95">
<SiFacebook size={20} />
</div>
<span className="text-[10px] font-medium text-neutral-500">Facebook</span>
</button>
<button className="group flex flex-col items-center gap-2">
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-neutral-100 text-neutral-600 transition-all group-hover:bg-neutral-200 active:scale-95">
<SiX size={18} />
</div>
<span className="text-[10px] font-medium text-neutral-500">Twitter</span>
</button>
<button className="group flex flex-col items-center gap-2">
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-neutral-100 text-neutral-600 transition-all group-hover:bg-neutral-200 active:scale-95">
<Link2 size={20} />
</div>
<span className="text-[10px] font-medium text-neutral-500">Link</span>
</button>
</div>
</div> */}
{/* 2. ADVERTORIAL (CEO UNITEL) - REPLACING TV CARD */}
<div className="group cursor-pointer">
<div className="flex items-center justify-between mb-3 px-1">
<span className="text-[10px] font-bold text-neutral-400 uppercase">Publicidade</span>
</div>
<div className="relative aspect-[4/5] w-full overflow-hidden rounded-xl bg-neutral-100 shadow-sm transition-shadow">
<Image
src="/zap.jpeg" // Substitua pelo path real da imagem do print
alt="CEO da Unitel - O futuro do 5G em Angola"
fill
unoptimized
className="object-contain"
/>
{/* Overlay de gradiente para legibilidade do texto se estiver na imagem */}
{/* <div className="absolute inset-0 bg-gradient-to-t from-black/80 via-transparent to-transparent opacity-60" /> */}
{/* <div className="absolute bottom-6 left-6 right-6">
<span className="text-[10px] font-bold text-[#007AFF] uppercase tracking-widest">CEO DA UNITEL</span>
<h4 className="text-white text-xl font-bold mt-2 leading-tight">
"ro do 5G em Angola e a expansão da conectividade rural"
</h4>
</div> */}
</div>
{/* <div className="mt-4 px-1">
<div className="flex items-center text-[13px] font-bold text-[#007AFF]">
Ler entrevista completa <ChevronRight size={14} className="ml-1" />
</div>
</div> */}
</div>
<div className="flex items-center justify-between mb-3 px-1">
<span className="text-[10px] font-bold text-neutral-400 uppercase">Publicidade</span>
</div>
{/* 3. PREMIUM SUBSCRIPTION - APPLE STYLE */}
<div className="bg-black p-7 rounded-xl relative overflow-hidden text-white shadow-xl">
<div className="relative z-10">
<h4 className="text-[20px] font-bold leading-tight tracking-tight mb-2">
Assine o Premium.
</h4>
<p className="text-[13px] text-[#8E8E93] font-medium mb-6">
Acesso ilimitado às notícias e análises exclusivas.
</p>
<button className="flex w-full items-center justify-center gap-2 rounded-xl bg-white py-3 text-black font-bold text-[15px] transition-all hover:bg-neutral-100 active:scale-95">
<svg viewBox="0 0 384 512" className="h-4 w-4 fill-black">
<path d="M318.7 268.7c-.2-36.7 16.4-64.4 50-84.8-18.8-26.9-47.2-41.7-84.7-44.6-35.5-2.8-74.3 20.7-88.5 20.7-15 0-49.4-19.7-76.4-19.7C63.3 141.2 4 184.8 4 273.5q0 39.3 14.4 81.2c12.8 36.7 59 126.7 107.2 125.2 25.2-.6 43-17.9 75.8-17.9 31.8 0 48.3 17.9 76.4 17.9 48.6-.7 90.4-82.5 102.6-119.3-65.2-30.7-61.7-90-61.7-91.9zm-56.6-164.2c27.3-32.4 24.8-61.9 24-72.5-24.1 1.4-52 16.4-67.9 34.9-17.5 19.8-27.8 44.3-25.6 71.9 26.1 2 49.9-11.4 69.5-34.3z"/>
</svg>
<span>Pay</span>
</button>
</div>
{/* Sutil glow de fundo */}
<div className="absolute top-0 right-0 w-32 h-32 bg-blue-600/20 blur-[40px]" />
</div>
<div className="px-1">
<div className="flex items-center justify-between mb-4 border-b border-neutral-100 pb-2">
<h3 className="text-[13px] font-bold text-black uppercase tracking-tight">Mais Lidas</h3>
<span className="text-[11px] font-medium text-neutral-400">Últimas 24h</span>
</div>
<div className="space-y-4">
{[
{ id: "01", title: "Câmbio: Kwanza mantém trajetória de estabilidade frente ao Dólar", cat: "Economia" },
{ id: "02", title: "Novas infraestruturas no Porto de Luanda aumentam eficiência", cat: "Logística" },
{ id: "03", title: "Selecção Nacional prepara amistoso contra Marrocos", cat: "Desporto" }
].map((news) => (
<div key={news.id} className="group cursor-pointer flex align-start justify-start gap-4 text-black">
<span className="text-2xl font-black text-black group-hover:text-blue transition-colors leading-none">
{news.id}
</span>
<div className="space-y-1 flex flex-col">
<span className="text-[10px] font-bold text-blue-600 uppercase tracking-widest">{news.cat}</span>
<h4 className="text-[14px] font-bold text-neutral-900 leading-tight group-hover:text-blue-600 transition-colors">
{news.title}
</h4>
</div>
</div>
))}
</div>
<div className="grid gap-x-12 mt-12 gap-y-3 md:grid-cols-1">
<div className="flex items-center justify-between mb-2 border-b border-neutral-100 pb-2">
<h3 className="text-[13px] font-bold text-black uppercase tracking-tight">Noticias Principais</h3>
{/* <span className="text-[11px] font-medium text-neutral-400">Últimas 24h</span> */}
</div>
{recentes.map((item) => (
<article key={item.title} className="group cursor-pointer mb-1">
<Link href="#" className="flex flex-row items-start gap-6">
{/* 1. LADO DA IMAGEM (Limpo e Minimalista) */}
<div className="relative aspect-[4/3] w-25 shrink-0 overflow-hidden rounded-2xl bg-neutral-100 shadow-sm transition-all duration-500 group-hover:shadow-xl sm:w-25 md:w-25 lg:w-25">
<Image
src={item.img}
alt=""
fill
className="object-cover transition duration-700 group-hover:scale-110"
sizes="(max-width: 60px) 60px, 64px"
/>
</div>
{/* 2. LADO DO TEXTO (Categoria externa ao topo) */}
<div className="flex flex-1 flex-col ">
{/* CATEGORIA E DATA LADO A LADO */}
<div className="gap-3 mb-2 flex flex-col justify-start items-start">
<span className="text-[10px] font-bold uppercase tracking-wider text-[#0066CC]">
{item.cat}
</span>
{/* <span className="h-1 w-1 rounded-2xl bg-neutral-300" /> */}
{/* <span className="text-[10px] font-semibold text-neutral-400 uppercase tracking-tight">
{item.date}
</span>
<span className="h-1 w-1 rounded-2xl bg-neutral-300" />
<span className="text-[10px] font-bold uppercase text-neutral-400">
{item.readTime} min
</span> */}
</div>
<h3 className="line-clamp-3 text-[14px] font-bold leading-tight text-neutral-900 transition-colors group-hover:text-[#0066cc]">
{item.title}
</h3>
</div>
</Link>
</article>
))}
</div>
</div>
{/* 2. VIDEO - Minimalist Edge-to-Edge */}
<div className="group">
<div className="flex items-center justify-between mb-3 px-1">
<h3 className="text-[13px] font-bold text-black tracking-tight">TV One Angola</h3>
<div className="flex items-center gap-1.5">
<span className="h-1.5 w-1.5 rounded-full bg-[#FF3B30] animate-pulse" />
<span className="text-[11px] font-bold text-[#FF3B30] uppercase">Ao Vivo</span>
</div>
</div>
<div className="relative aspect-video w-full overflow-hidden rounded-xl bg-black shadow-sm group-hover:shadow-md transition-shadow">
{!isPlaying ? (
<div className="absolute inset-0 z-10 cursor-pointer" onClick={() => setIsPlaying(true)}>
<Image
src="https://i.ytimg.com/vi/bfEYtb2O3iI/maxresdefault.jpg"
alt="Workshop"
fill
className="object-cover transition-transform duration-500 group-hover:scale-105 opacity-90"
/>
<div className="absolute inset-0 flex items-center justify-center">
<div className="flex h-14 w-14 items-center justify-center rounded-full bg-white/20 backdrop-blur-md border border-white/30 text-white transition-all group-hover:bg-white group-hover:text-[#007AFF] group-hover:scale-110">
<svg className="h-6 w-6 ml-0.5" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z" />
</svg>
</div>
</div>
</div>
) : (
<iframe
className="absolute inset-0 h-full w-full"
src="https://www.youtube.com/embed/bfEYtb2O3iI?autoplay=1"
frameBorder="0"
allow="autoplay; encrypted-media"
allowFullScreen
/>
)}
</div>
<div className="mt-3.5 px-1">
<h4 className="text-[16px] font-semibold text-black leading-snug tracking-tight group-hover:text-[#007AFF] transition-colors">
Workshop Acelere a Sua Empresa impulsiona o empreendedorismo em Angola
</h4>
<p className="mt-2 text-[12px] text-[#8E8E93] font-medium tracking-tight">Publicado em 24 de Março, 2026</p>
</div>
</div>
</div>
</aside>
</div>
</main>
<TvoneAdBanner />
<TvoneFooter />
</div>
);
}
+364
View File
@@ -0,0 +1,364 @@
"use client";
import React, { useState, useRef, useEffect } from 'react';
import Image from 'next/image';
import {
LayoutDashboard, Newspaper, Users, BarChart3,
Settings, HelpCircle, Image as ImageIcon,
Type, Calendar, Clock, Tag, User, Save, Eye, Send
} from 'lucide-react';
import Keycloak from "keycloak-js";
// Importe o componente que criámos (ajuste o caminho se necessário)
import MultiAspectEditor from '../../components/MultiAspectEditor';
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 = () => {
// Configuração do Design do Editor para combinar com seu layout
const editorConfig = {
height: 500,
menubar: false,
plugins: [
'advlist', 'autolink', 'lists', 'link', 'image', 'charmap', 'preview',
'anchor', 'searchreplace', 'visualblocks', 'code', 'fullscreen',
'insertdatetime', 'media', 'table', 'code', 'help', 'wordcount'
],
// Adicionei 'blockquote' na toolbar
toolbar: 'undo redo | blocks | ' +
'bold italic forecolor | alignleft aligncenter ' +
'alignright alignjustify | bullist numlist | ' +
'blockquote link image | removeformat | help',
// Customização do aspeto da citação dentro do editor
content_style: `
body {
font-family: Inter, Helvetica, Arial, sans-serif;
font-size: 14px;
color: #334155;
line-height: 1.6;
}
blockquote {
border-left: 4px solid #2563eb; /* Azul da TVone */
padding-left: 1.5rem;
color: #475569;
font-style: italic;
margin: 1.5rem 0;
background: #f8fafc;
padding: 1rem 1.5rem;
border-radius: 0 8px 8px 0;
}
`,
skin: 'oxide',
promotion: false, // Remove o botão "Upgrade" do Tiny
branding: false, // Remove o "Powered by Tiny"
};
// 1. Estados para o Crop
const [tempImage, setTempImage] = useState<string | null>(null);
const [isEditorOpen, setIsEditorOpen] = useState(false);
const [finalCrops, setFinalCrops] = useState<any>(null); // Guarda os Base64 finais
const [content, setContent] = useState('');
const [user, setUser] = useState<{
email?: string;
name?: string;
picture?: string;
} | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
// 2. Lógica de Upload
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files.length > 0) {
const reader = new FileReader();
reader.readAsDataURL(e.target.files[0]);
reader.onload = () => {
setTempImage(reader.result as string);
setIsEditorOpen(true);
};
}
};
const triggerUpload = () => {
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 (
<div className="flex h-screen bg-slate-100/50 text-slate-900 font-sans">
{/* --- MODAL DO EDITOR (Renderiza fora do fluxo quando ativo) --- */}
{isEditorOpen && tempImage && (
<MultiAspectEditor
image={tempImage}
onClose={() => setIsEditorOpen(false)}
onExport={(data) => {
setFinalCrops(data); // Aqui tens o objeto com hero, news, square em Base64
setIsEditorOpen(false);
console.log("Imagens prontas para envio:", data);
}}
/>
)}
{/* 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 Content Area */}
<main className="flex-1 overflow-y-auto">
<header className="h-16 border-b border-slate-200 bg-white/50 backdrop-blur-md sticky top-0 z-10 px-8 flex items-center justify-between">
<div className="w-1/3">
<input
type="text"
placeholder="Pesquisar artigos..."
className="w-full bg-slate-100 border-none rounded-full px-4 py-1.5 text-sm focus:ring-2 focus:ring-blue-500/20 outline-none"
/>
</div>
<div className="flex items-center gap-4">
<div className="text-right">
<p className="text-xs font-bold">{user?.name ?? "Loading..."}</p>
<p className="text-[10px] text-slate-500">Editor</p>
</div>
<div className="w-8 h-8 bg-slate-300 rounded-full border border-white shadow-sm overflow-hidden">
<img
src={user?.picture ?? "https://ui-avatars.com/api/?name=User"}
alt="User"
/>
</div>
</div>
</header>
<div className="p-8 max-w-6xl mx-auto">
<div className="flex justify-between items-center mb-8">
<h1 className="text-2xl font-bold tracking-tight">Criar Nova Notícia</h1>
<div className="flex gap-3">
<button className="px-5 py-2 rounded-lg border border-slate-200 bg-white text-sm font-medium hover:bg-slate-50 transition-all flex items-center gap-2">
<Save size={16}/> Salvar Rascunho
</button>
<button className="px-6 py-2 rounded-lg bg-blue-600 text-white text-sm font-medium hover:bg-blue-700 transition-all shadow-lg shadow-blue-200 flex items-center gap-2">
<Send size={16}/> Publicar Artigo
</button>
</div>
</div>
<div className="grid grid-cols-3 gap-8">
{/* Coluna Principal */}
<div className="col-span-2 space-y-6">
<section className="bg-white rounded-2xl border border-slate-200 shadow-sm overflow-hidden">
{/* Título */}
<div className="p-6 pb-0">
<label className="block text-xs font-bold uppercase text-slate-400 mb-2">Título do Artigo</label>
<input
type="text"
placeholder="Insira o título principal da notícia..."
className="w-full text-xl font-bold border-none focus:ring-0 placeholder:text-slate-300 p-0"
/>
<hr className="my-6 border-slate-100" />
<label className="block text-xs font-bold uppercase text-slate-400 mb-2">Conteúdo Principal</label>
</div>
{/* TinyMCE Editor */}
<div className="border-t border-slate-50">
<Editor
apiKey='dmg1hghyf25x09mtg04hik0034yeadt1h6ai2ou68zhdvw11' // Obtenha em tiny.cloud ou use 'no-api-key' para teste
init={editorConfig}
value={content}
onEditorChange={(newContent) => setContent(newContent)}
/>
</div>
</section>
{/* Resumo (Lead) */}
<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>
<textarea
rows={3}
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"
/>
</div>
{/* Tags */}
<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">Tags</label>
<input type="text" placeholder="Insira as tags da notícia..." className="w-full border-none focus:ring-0 resize-none text-slate-600 text-sm italic p-0" />
</div>
</div>
{/* Coluna Lateral */}
<div className="space-y-6">
<section className="bg-white rounded-2xl p-6 border border-slate-200 shadow-sm space-y-6">
<div>
<label className="flex items-center gap-2 text-xs font-bold uppercase text-slate-400 mb-3">
<Tag size={14}/> Categoria
</label>
<select className="w-full bg-slate-50 border border-slate-100 rounded-lg px-3 py-2 text-sm outline-none">
<option>Negócios</option>
<option>Tecnologia</option>
<option>Desporto</option>
</select>
</div>
{/* --- INPUT DE UPLOAD ATUALIZADO --- */}
<div>
<label className="flex items-center gap-2 text-xs font-bold uppercase text-slate-400 mb-3">
<ImageIcon size={14}/> Imagem de Capa
</label>
<input
type="file"
hidden
ref={fileInputRef}
accept="image/*"
onChange={handleFileChange}
/>
<div
onClick={triggerUpload}
className={`border-2 border-dashed rounded-xl p-8 flex flex-col items-center justify-center transition-all cursor-pointer group
${finalCrops ? 'border-emerald-200 bg-emerald-50/30' : 'border-slate-100 bg-slate-50/50 hover:bg-slate-50'}`}
>
{finalCrops ? (
<div className="text-center">
<div className="w-12 h-12 bg-emerald-100 text-emerald-600 rounded-full flex items-center justify-center mx-auto mb-2">
<ImageIcon size={24}/>
</div>
<p className="text-[10px] font-bold text-emerald-700 uppercase">3 Formatos Gerados</p>
<p className="text-[9px] text-emerald-500 mt-1">Clique para alterar</p>
</div>
) : (
<>
<div className="w-10 h-10 bg-white rounded-full shadow-sm flex items-center justify-center text-blue-500 mb-2 group-hover:scale-110 transition-transform">
<ImageIcon size={20}/>
</div>
<p className="text-[10px] font-medium text-slate-500 text-center">Clique para carregar e enquadrar</p>
<p className="text-[9px] text-slate-400">Suporta JPG, PNG</p>
</>
)}
</div>
</div>
<div>
<label className="flex items-center gap-2 text-xs font-bold uppercase text-slate-400 mb-3">
<Calendar size={14}/> Agendamento
</label>
<div className="grid grid-cols-2 gap-2">
<input type="date" className="bg-slate-50 border border-slate-100 rounded-lg px-2 py-2 text-[11px] outline-none" />
<input type="time" className="bg-slate-50 border border-slate-100 rounded-lg px-2 py-2 text-[11px] outline-none" />
</div>
</div>
</section>
<button className="w-full py-4 rounded-2xl border border-slate-200 bg-white text-sm font-bold flex items-center justify-center gap-2 hover:bg-slate-50 transition-all text-slate-600">
<Eye size={18}/> Pré-visualizar Notícia
</button>
</div>
</div>
</div>
</main>
</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>
);
export default CreateNewsPage;
+338
View File
@@ -0,0 +1,338 @@
import React from 'react';
import {
LayoutDashboard, Newspaper, Users, BarChart3,
Settings, HelpCircle, TrendingUp, Eye, Clock,
AlertCircle, ChevronRight, Edit3, Trash2, ExternalLink
} from 'lucide-react';
import Image from 'next/image';
const DashboardMain = () => {
return (
<div className="flex h-screen bg-[#F1F5F9] text-slate-900 font-sans">
{/* Sidebar Lateral - Consistente com a página de criação */}
<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 Principal" active />
<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={<Settings size={18}/>} label="Definições" />
<NavItem icon={<HelpCircle size={18}/>} label="Ajuda" />
</nav>
<div className="mt-auto pt-6 border-t border-slate-200">
<div className="bg-blue-50 p-4 rounded-2xl">
<p className="text-[10px] font-bold text-blue-600 uppercase mb-1">Dica do Dia</p>
<p className="text-[11px] text-blue-800 leading-relaxed">
Artigos com mais de 3 imagens têm 40% mais retenção.
</p>
</div>
</div>
{/* Container do Fluxo de Atividade */}
<div className="mt-auto pt-6 border-t border-slate-200">
<h4 className="text-[10px] font-bold uppercase text-slate-400 mb-5 px-2 tracking-[0.15em]">
Fluxo de Atividade
</h4>
<div className="space-y-5 px-2">
<ActivityItem
name="James Wilson"
action="atualizou"
target="'Volatilidade do Mercado'"
time="Agora mesmo"
avatar="https://ui-avatars.com/api/?name=James+Wilson&background=E0F2FE&color=0369A1"
/>
<ActivityItem
name="Sarah Johnson"
action="criou"
target="'Financiamento de Startups'"
time="Há 12 min"
avatar="https://ui-avatars.com/api/?name=Sarah+Johnson&background=FEE2E2&color=B91C1C"
/>
<ActivityItem
name="Sarah Johnson"
action="criou"
target="'Tech Mercenary' em recentes"
time="Há 45 min"
avatar="https://ui-avatars.com/api/?name=Sarah+Johnson&background=FEE2E2&color=B91C1C"
/>
<ActivityItem
name="James Wilson"
action="atualizou"
target="'Volatilidade do Mercado'"
time="Há 1h"
avatar="https://ui-avatars.com/api/?name=James+Wilson&background=E0F2FE&color=0369A1"
/>
</div>
</div>
</aside>
{/* Main Content Area */}
<main className="flex-1 overflow-y-auto">
{/* Header Superior */}
{/* Header Superior - Secção do Utilizador Atualizada */}
<header className="h-16 border-b border-slate-200 bg-white/50 backdrop-blur-md sticky top-0 z-10 px-8 flex items-center justify-between">
<div className="w-1/3">
<input
type="text"
placeholder="Pesquisar artigos, autores..."
className="w-full bg-slate-100 border-none rounded-full px-4 py-1.5 text-sm focus:ring-2 focus:ring-blue-500/20 outline-none transition-all"
/>
</div>
<div className="flex items-center gap-5">
{/* Notificações (Opcional, mas completa o look) */}
<button className="relative p-2 text-slate-400 hover:text-blue-600 transition-colors">
<div className="absolute top-2 right-2.5 w-2 h-2 bg-red-500 border-2 border-white rounded-full"></div>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/></svg>
</button>
{/* Menu do Utilizador */}
<div className="flex items-center gap-3 pl-4 border-l border-slate-200 group cursor-pointer">
<div className="text-right hidden sm:block">
<p className="text-[11px] font-bold text-slate-900 leading-tight">James Wilson</p>
<p className="text-[10px] text-emerald-600 font-medium">Online</p>
</div>
<div className="relative">
{/* Container da Imagem com Efeito de Anel */}
<div className="w-9 h-9 rounded-full border-2 border-white shadow-sm overflow-hidden ring-1 ring-slate-200 group-hover:ring-blue-400 transition-all">
<img
src="https://ui-avatars.com/api/?name=James+Wilson&background=0D8ABC&color=fff"
alt="Avatar do utilizador"
className="w-full h-full object-cover"
/>
</div>
{/* Indicador de Status (Mobile) */}
<div className="absolute bottom-0 right-0 w-2.5 h-2.5 bg-emerald-500 border-2 border-white rounded-full"></div>
</div>
{/* Seta para indicar menu (Chevron) */}
<svg
xmlns="http://www.w3.org/2000/svg"
width="14" height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
className="text-slate-400 group-hover:text-slate-600 transition-transform group-hover:translate-y-0.5"
>
<path d="m6 9 6 6 6-6"/>
</svg>
</div>
</div>
</header>
<div className="p-8 space-y-8">
{/* Métricas Principais (Grid de 4 colunas) */}
<div className="grid grid-cols-4 gap-6">
<StatCard
label="Artigos Publicados"
value="14,352"
trend="+12% este mês"
icon={<Newspaper size={20} className="text-blue-600"/>}
/>
<StatCard
label="Visualizações Totais"
value="2.1M"
trend="+5.4% vs ontem"
icon={<TrendingUp size={20} className="text-emerald-600"/>}
/>
<StatCard
label="Tempo Médio"
value="4m 32s"
trend="-2s média"
icon={<Clock size={20} className="text-orange-600"/>}
/>
<StatCard
label="Alertas Ativos"
value="3"
trend="Urgente"
icon={<AlertCircle size={20} className="text-red-600"/>}
isAlert
/>
</div>
<div className="grid grid-cols-3 gap-8">
{/* Tabela de Artigos Recentes (Coluna Dupla) */}
<div className="col-span-2 bg-white rounded-2xl border border-slate-200 shadow-sm overflow-hidden">
<div className="p-6 border-b border-slate-100 flex justify-between items-center">
<h3 className="font-bold text-slate-800 text-sm uppercase tracking-wider">Artigos Recentes</h3>
<button className="text-blue-600 text-xs font-bold flex items-center gap-1 hover:underline">
Ver Todos <ChevronRight size={14}/>
</button>
</div>
<table className="w-full text-left border-collapse">
<thead className="bg-slate-50 text-[10px] uppercase font-bold text-slate-400">
<tr>
<th className="px-6 py-3">Título</th>
<th className="px-6 py-3">Estado</th>
<th className="px-6 py-3">Autor</th>
<th className="px-6 py-3">Visualizações</th>
<th className="px-6 py-3">Ações</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-50">
<TableRow
title="Volatilidade do Mercado: Impactos na Economia"
status="Publicado"
author="James Wilson"
views="1.2M"
/>
<TableRow
title="O Futuro da Inteligência Artificial em 2026"
status="Rascunho"
author="Sarah Johnson"
views="--"
/>
<TableRow
title="Novas Políticas de Sustentabilidade na UE"
status="Agendado"
author="Ricardo Silva"
views="0"
/>
</tbody>
</table>
</div>
{/* Performance por Categoria (Coluna Única) */}
<div className="bg-white rounded-2xl border border-slate-200 shadow-sm p-6">
<h3 className="font-bold text-slate-800 text-sm uppercase tracking-wider mb-6">Performance de Conteúdo</h3>
<div className="space-y-6">
<CategoryBar label="Tecnologia" percentage={85} color="bg-blue-500" />
<CategoryBar label="Negócios" percentage={62} color="bg-emerald-500" />
<CategoryBar label="Política" percentage={45} color="bg-purple-500" />
<CategoryBar label="Desporto" percentage={30} color="bg-orange-500" />
</div>
<div className="mt-8 p-4 bg-slate-50 rounded-xl">
<p className="text-[11px] text-slate-500 text-center">
O tráfego orgânico cresceu <strong>15%</strong> nos últimos 7 dias.
</p>
</div>
</div>
</div>
</div>
</main>
</div>
);
};
// --- Subcomponentes para Organização ---
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-lg shadow-blue-200' : 'text-slate-500 hover:bg-slate-100'
}`}>
{icon}
<span className="truncate">{label}</span>
</div>
);
const StatCard = ({ label, value, trend, icon, isAlert = false }: any) => (
<div className={`p-5 rounded-2xl border bg-white shadow-sm transition-transform hover:scale-[1.02] cursor-default ${isAlert ? 'border-red-100 bg-red-50/20' : 'border-slate-200'}`}>
<div className="flex justify-between items-start mb-4">
<div className={`p-2 rounded-lg ${isAlert ? 'bg-red-100' : 'bg-slate-50'}`}>
{icon}
</div>
<span className={`text-[10px] font-bold px-2 py-0.5 rounded-full ${isAlert ? 'bg-red-500 text-white' : 'bg-emerald-50 text-emerald-600'}`}>
{trend}
</span>
</div>
<p className="text-xs text-slate-500 font-medium">{label}</p>
<h3 className="text-2xl font-bold tracking-tight">{value}</h3>
</div>
);
const TableRow = ({ title, status, author, views }: any) => (
<tr className="hover:bg-slate-50/50 transition-colors group">
<td className="px-6 py-4">
<p className="text-sm font-semibold text-slate-800 truncate max-w-[200px]">{title}</p>
</td>
<td className="px-6 py-4">
<span className={`text-[10px] font-bold px-2 py-1 rounded-md ${
status === 'Publicado' ? 'bg-emerald-50 text-emerald-600' :
status === 'Rascunho' ? 'bg-slate-100 text-slate-500' : 'bg-blue-50 text-blue-600'
}`}>
{status}
</span>
</td>
<td className="px-6 py-4 text-xs text-slate-600">{author}</td>
<td className="px-6 py-4 text-xs font-mono font-medium">{views}</td>
<td className="px-6 py-4">
<div className="flex gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
<button className="p-1.5 hover:bg-white rounded-md border border-slate-200 text-slate-400 hover:text-blue-600 shadow-sm"><Edit3 size={14}/></button>
<button className="p-1.5 hover:bg-white rounded-md border border-slate-200 text-slate-400 hover:text-red-600 shadow-sm"><Trash2 size={14}/></button>
</div>
</td>
</tr>
);
const CategoryBar = ({ label, percentage, color }: any) => (
<div className="space-y-2">
<div className="flex justify-between text-[11px] font-bold uppercase tracking-wide">
<span>{label}</span>
<span className="text-slate-400">{percentage}%</span>
</div>
<div className="w-full h-1.5 bg-slate-100 rounded-full overflow-hidden">
<div className={`h-full ${color} rounded-full`} style={{ width: `${percentage}%` }} />
</div>
</div>
);
export default DashboardMain;
{/* Subcomponente ActivityItem (Coloque fora do componente principal) */}
const ActivityItem = ({ name, action, target, time, avatar }: {
name: string,
action: string,
target: string,
time: string,
avatar: string
}) => (
<div className="flex gap-3 items-start group">
{/* Avatar com Ring */}
<div className="relative shrink-0">
<img
src={avatar}
alt={name}
className="w-7 h-7 rounded-full border border-slate-100 shadow-sm"
/>
<div className="absolute -bottom-0.5 -right-0.5 w-2 h-2 bg-emerald-500 border-2 border-white rounded-full"></div>
</div>
{/* Texto da Atividade */}
<div className="flex flex-col gap-0.5">
<p className="text-[11px] leading-snug text-slate-600">
<span className="font-bold text-slate-900 hover:text-blue-600 cursor-pointer transition-colors">
{name}
</span>
{" "}{action}{" "}
<span className="font-medium text-slate-800 italic">{target}</span>
</p>
<span className="text-[9px] font-medium text-slate-400 uppercase tracking-tight">
{time}
</span>
</div>
</div>
);
+233
View File
@@ -0,0 +1,233 @@
"use client";
import Image from 'next/image';
import React, { useState, useEffect } from "react";
import { useGoogleLogin } from "@react-oauth/google";
import { useTheme } from 'next-themes';
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() {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const { theme, setTheme } = useTheme();
const [mounted, setMounted] = useState(false);
// Avoid hydration mismatch by waiting for mount
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);
}, []);
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({
onSuccess: (res) => {
handleExchange(res)
console.log("Google Success", res)
},
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;
return (
<div className="relative flex min-h-screen items-center justify-center bg-[#f5f5f7] px-4 py-10 transition-colors duration-500 dark:bg-black">
{/* THEME TOGGLE BUTTON */}
<button
onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
className="absolute right-6 top-6 flex h-10 w-10 items-center justify-center rounded-full bg-white/80 shadow-sm backdrop-blur-md transition-all hover:scale-110 active:scale-95 dark:bg-neutral-800/80"
aria-label="Toggle Theme"
>
{theme === 'dark' ? (
<Sun className="h-5 w-5 text-yellow-400" />
) : (
<Moon className="h-5 w-5 text-neutral-600" />
)}
</button>
<div className="w-full max-w-[400px] space-y-8 text-center">
{/* 1. LOGOTIPO */}
<div className="flex flex-col items-center">
<div className="flex h-16 w-16 items-center justify-center rounded-[1.2rem] bg-white text-white shadow-2xl transition-transform duration-700 hover:rotate-[360deg] dark:bg-white dark:text-black">
<Image src="/logo.png" alt="logo" width={100} height={100} />
</div>
<h1 className="mt-6 text-3xl font-bold tracking-tight text-neutral-900 dark:text-white">
Iniciar sessão
</h1>
<p className="mt-2 text-sm text-neutral-500">Usa a tua conta TVone.</p>
</div>
{/* 2. FORMULÁRIO */}
<div className="space-y-3">
<input
type="email"
placeholder="E-mail ou Telefone"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full rounded-2xl border border-neutral-200 bg-white/50 px-5 py-4 text-sm outline-none transition-all focus:border-blue-500 focus:bg-white focus:text-black focus:ring-4 focus:ring-blue-500/10 dark:border-neutral-800 dark:bg-neutral-900 dark:text-white"
/>
<input
type="password"
placeholder="Palavra-passe"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full rounded-2xl border border-neutral-200 bg-white/50 px-5 py-4 text-sm outline-none transition-all focus:border-blue-500 focus:bg-white focus:text-black focus:ring-4 focus:ring-blue-500/10 dark:border-neutral-800 dark:bg-neutral-900 dark:text-white"
/>
<button className="w-full rounded-2xl bg-blue-600 py-4 text-sm font-bold text-white transition-all hover:bg-blue-700 active:scale-[0.98]">
Continuar
</button>
</div>
{/* 3. DIVISOR */}
<div className="relative flex items-center py-4">
<div className="flex-grow border-t border-neutral-200 dark:border-neutral-800"></div>
<span className="mx-4 flex-shrink text-[11px] font-bold uppercase tracking-widest text-neutral-400">
ou
</span>
<div className="flex-grow border-t border-neutral-200 dark:border-neutral-800"></div>
</div>
{/* 4. BOTÃO GOOGLE */}
<button
onClick={() =>
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">
<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="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" fill="#FBBC05" />
<path d="M12 5.38c1.62 0 3.06.56 4.21 1.66l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" fill="#EA4335" />
</svg>
<span className="text-sm font-semibold tracking-tight text-neutral-700 dark:text-neutral-300">
Continuar com o Google
</span>
</button>
{/* 5. LINKS */}
<div className="pt-4 text-center">
<a href="#" className="text-xs font-medium text-blue-600 hover:underline">
Esqueceste-te da palavra-passe?
</a>
{/* <p className="mt-8 text-xs text-neutral-400">
Não tens conta?{" "}
<a href="#" className="font-bold text-neutral-900 dark:text-white">
Cria uma agora.
</a>
</p> */}
</div>
</div>
</div>
);
}
@@ -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>
);
}
+343
View File
@@ -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>
);
+14
View File
@@ -0,0 +1,14 @@
// import { NextResponse } from "next/server";
// import { readSliderPhotos } from "@/lib/slider-photos";
// export const runtime = "nodejs";
// export const dynamic = "force-dynamic";
// export async function GET() {
// try {
// const photos = readSliderPhotos();
// return NextResponse.json(photos);
// } catch {
// return NextResponse.json([]);
// }
// }
+534
View File
@@ -0,0 +1,534 @@
"use client";
import React, { useState } from 'react';
import Image from 'next/image';
import { ThumbsUp, MessageCircle, Send, Share2, LinkIcon, Link2, ChevronRight } from 'lucide-react';
import { TvoneAdBanner, TvoneFooter } from '../components/tvone-content';
import { TvonePromoStrip } from '../components/tvone-promo-strip';
import { TvoneSiteNav } from '../components/tvone-site-nav';
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 Link from 'next/link';
import { Users } from 'lucide-react';
import { v4 as uuidv4 } from 'uuid';
export default function NewsArticlePage() {
// Estado para controlar o play do vídeo customizado
const [isPlaying, setIsPlaying] = useState(false);
const recentes = [
{
cat: "EM FOCO",
readTime: "6",
catBg: "bg-pink-100 text-pink-700",
title: "Governo anuncia medidas para apoiar famílias e pequenas empresas.",
excerpt: "Pacote inclui linhas de crédito e simplificação de procedimentos.",
byline: "Por Redação",
date: "24 Mar 2025",
img: "https://images.unsplash.com/photo-1507679799987-c73779587ccf?w=200&q=80",
},
{
cat: "ECONOMIA",
readTime: "6",
catBg: "bg-amber-100 text-amber-800",
title: "Inflação desce pelo terceiro mês consecutivo, segundo dados preliminares.",
excerpt: "Analistas mantêm cautela face ao cenário internacional.",
byline: "Por Economia",
date: "24 Mar 2025",
img: "https://images.unsplash.com/photo-1611974789855-9c2a0a7236a3?w=200&q=80",
},
{
cat: "CULTURA",
readTime: "6",
catBg: "bg-violet-100 text-violet-800",
title: "Museu inaugura exposição com obras inéditas de artistas locais.",
excerpt: "Visitas guiadas e programa educativo arrancam no próximo fim de semana.",
byline: "Por Cultura",
date: "23 Mar 2025",
img: "https://images.unsplash.com/photo-1507679799987-c73779587ccf?w=200&q=80",
},
{
cat: "SAÚDE",
readTime: "6",
catBg: "bg-emerald-100 text-emerald-800",
title: "Campanha de vacinação alarga faixas etárias em todo o país.",
excerpt: "Autoridades de saúde reforçam importância da adesão às janelas recomendadas.",
byline: "Por Saúde",
date: "23 Mar 2025",
img: "https://images.unsplash.com/photo-1576091160399-112ba8d25d1d?w=200&q=80",
},
];
const destaques = [
{
id: uuidv4(),
cat: "FAMOSOS",
catColor: "text-pink-600",
title: "Cerimónia reúne estrelas nacionais e internacionais em Lisboa.",
date: "24 Mar 2025",
// High-quality video-style thumbnail
img: "https://images.unsplash.com/photo-1492684223066-81342ee5ff30?w=800&q=80",
},
{
id: uuidv4(),
cat: "NEGÓCIOS",
catColor: "text-[#0066cc]",
title: "Mercados reagem às novas projeções de crescimento para a região.",
date: "24 Mar 2025",
img: "https://images.unsplash.com/photo-1486406146926-c627a92ad1ab?w=800&q=80",
},
{
id: uuidv4(),
cat: "DESPORTO",
catColor: "text-emerald-600",
title: "Taça: equipa da casa garante lugar nas meias com exibição sólida.",
date: "23 Mar 2025",
img: "https://images.unsplash.com/photo-1574629810360-7efbbe195018?w=800&q=80",
},
{
id: uuidv4(),
cat: "NEGÓCIOS",
catColor: "text-[#0066cc]",
title: "Mercados reagem às novas projeções de crescimento para a região.",
date: "24 Mar 2025",
img: "https://images.unsplash.com/photo-1486406146926-c627a92ad1ab?w=800&q=80",
},
{
id: uuidv4(),
cat: "FAMOSOS",
catColor: "text-pink-600",
title: "Cerimónia reúne estrelas nacionais e internacionais em Lisboa.",
date: "24 Mar 2025",
// High-quality video-style thumbnail
img: "https://images.unsplash.com/photo-1492684223066-81342ee5ff30?w=800&q=80",
},
{
id: uuidv4(),
cat: "NEGÓCIOS",
catColor: "text-[#0066cc]",
title: "Mercados reagem às novas projeções de crescimento para a região.",
date: "24 Mar 2025",
img: "https://images.unsplash.com/photo-1486406146926-c627a92ad1ab?w=800&q=80",
},
{
id: uuidv4(),
cat: "DESPORTO",
catColor: "text-emerald-600",
title: "Taça: equipa da casa garante lugar nas meias com exibição sólida.",
date: "23 Mar 2025",
img: "https://images.unsplash.com/photo-1574629810360-7efbbe195018?w=800&q=80",
},
{
id: uuidv4(),
cat: "NEGÓCIOS",
catColor: "text-[#0066cc]",
title: "Mercados reagem às novas projeções de crescimento para a região.",
date: "24 Mar 2025",
img: "https://images.unsplash.com/photo-1486406146926-c627a92ad1ab?w=800&q=80",
},
{
id: uuidv4(),
cat: "FAMOSOS",
catColor: "text-pink-600",
title: "Cerimónia reúne estrelas nacionais e internacionais em Lisboa.",
date: "24 Mar 2025",
// High-quality video-style thumbnail
img: "https://images.unsplash.com/photo-1492684223066-81342ee5ff30?w=800&q=80",
},
{
id: uuidv4(),
cat: "NEGÓCIOS",
catColor: "text-[#0066cc]",
title: "Mercados reagem às novas projeções de crescimento para a região.",
date: "24 Mar 2025",
img: "https://images.unsplash.com/photo-1486406146926-c627a92ad1ab?w=800&q=80",
},
{
id: uuidv4(),
cat: "DESPORTO",
catColor: "text-emerald-600",
title: "Taça: equipa da casa garante lugar nas meias com exibição sólida.",
date: "23 Mar 2025",
img: "https://images.unsplash.com/photo-1574629810360-7efbbe195018?w=800&q=80",
},
{
id: uuidv4(),
cat: "NEGÓCIOS",
catColor: "text-[#0066cc]",
title: "Mercados reagem às novas projeções de crescimento para a região.",
date: "24 Mar 2025",
img: "https://images.unsplash.com/photo-1486406146926-c627a92ad1ab?w=800&q=80",
},
];
return (
<div className="min-h-screen bg-white font-sans text-[#1d1d1f] selection:bg-[#0066CC]/20">
<TvonePromoStrip />
<TvoneSiteNav />
<main className="mx-auto max-w-[1240px] px-6 py-12">
<div className="flex flex-col gap-16 lg:flex-row lg:items-start">
{/* --- COLUNA PRINCIPAL (ARTIGO) --- */}
<article className="flex-1 min-w-0">
<div className="mb-10 flex items-end justify-between border-b border-neutral-100 pb-6">
<div className="max-w-[600px]">
<h2 className="text-3xl font-bold tracking-tight text-neutral-900 md:text-4xl">
Noticias Recentes
</h2>
<p className="mt-2 text-sm md:text-base text-neutral-500 leading-relaxed">
Conversas exclusivas com as personalidades que movem o mercado, a tecnologia e a cultura em Angola.
</p>
</div>
</div>
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-2">
{destaques.map((item, index) => (
<article
key={item.id}
className="group cursor-pointer bg-white transition"
>
<Link href="#" className="block">
<div className="relative aspect-[16/9] w-full overflow-hidden rounded-2xl bg-neutral-100 shadow-sm transition-all duration-300 group-hover:shadow-xl group-hover:shadow-neutral-200">
<Image
src={item.img}
alt=""
fill
className="object-cover transition duration-500 group-hover:scale-105"
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 25vw"
/>
<div className="absolute inset-0 flex flex-col items-center justify-center bg-black/10 opacity-0 transition-opacity duration-300 group-hover:opacity-100">
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-white/30 backdrop-blur-md border border-white/40">
<svg className="h-5 w-5 text-white" fill="currentColor" viewBox="0 0 20 20">
<path d="M6.3 2.841A1.5 1.5 0 004 4.11V15.89a1.5 1.5 0 002.3 1.269l9.344-5.89a1.5 1.5 0 000-2.538L6.3 2.841z" />
</svg>
</div>
</div>
</div>
<div className="py-4">
<div className="flex items-center gap-3 pb-2">
<span className="text-[10px] font-bold uppercase tracking-wider text-blue-600">
{item.cat}
</span>
<span className="h-1 w-1 rounded-2xl bg-neutral-300" />
{/* Data de Publicação em destaque suave */}
<span className="text-[10px] font-semibold text-neutral-500 uppercase tracking-tight">
{item.date}
</span>
</div>
<h3 className="line-clamp-4 text-base font-bold leading-snug text-neutral-900 transition-colors group-hover:text-blue-600">
{item.title}
</h3>
</div>
</Link>
</article>
))}
</div>
</article>
{/* --- SIDEBAR REFINADA --- */}
{/* --- SIDEBAR REFINADA COM SOCIAL SHARE --- */}
<aside className="w-full lg:w-[340px] flex flex-col justify-center align-center items-center shrink-0">
<div className="sticky top-10 space-y-10 max-w-[400px]">
<div className="bg-white p-5 rounded-xl border border-neutral-100 shadow-[0_2px_15px_rgba(0,0,0,0.03)]">
{/* 1. Header: Adapted from your Share component */}
<div className="flex items-center gap-2 mb-4">
{/* Usando Users icon for Community */}
<Users size={15} className="text-neutral-400" />
<h3 className="text-[11px] font-bold text-neutral-400 uppercase tracking-widest">
Community
</h3>
</div>
{/* 2. Main Content Area: Simplified from the UI mockup */}
<div className="flex flex-col gap-5">
{/* Column for Brand and Button */}
<div className="flex flex-col gap-4">
{/* Brand Info */}
{/* Brand Info - A highly stylized placeholder that looks 'premium' */}
{/* Brand Info - FIXED to use your actual logo image */}
<div className="flex items-center gap-3">
{/* 1. Use Next/Image for your official icon */}
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-xl overflow-hidden shadow-inner border border-neutral-100">
<Image
src="/logo.png" // <-- UPDATE THIS PATH to your square logo
alt="TVone Icon Logo"
width={48} // Width in pixels (matches h-12/w-12 class)
height={48} // Height in pixels
className="object-contain" // Ensures the logo doesn't stretch
/>
</div>
{/* 2. Brand text matches previous styling */}
<div>
<p className="text-sm font-bold text-neutral-900 leading-tight">TVone</p>
<p className="text-[11px] font-medium text-neutral-500">500k seguidores</p>
</div>
</div>
{/* Main Action Button */}
<button className="flex h-11 w-full items-center justify-center gap-2.5 rounded-lg bg-[#1877F2] text-white font-semibold text-sm transition-all hover:bg-[#166fe5] active:scale-95 shadow-sm">
<SiFacebook size={16} />
Gosto da TVone
</button>
</div>
</div>
</div>
{/* 1. SEÇÃO DE SHARE (ESTILO APPLE NEWS) */}
{/* <div className="bg-white p-5 rounded-xl border border-neutral-100 shadow-[0_2px_15px_rgba(0,0,0,0.03)]">
<div className="flex items-center gap-2 mb-4">
<Share2 size={15} className="text-neutral-400" />
<h3 className="text-[11px] font-bold text-neutral-400 uppercase tracking-widest">
Compartilhar
</h3>
</div>
<div className="grid grid-cols-4 gap-3">
<button className="group flex flex-col items-center gap-2">
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-neutral-100 text-neutral-600 transition-all group-hover:bg-neutral-200 active:scale-95">
<SiWhatsapp size={20} />
</div>
<span className="text-[10px] font-medium text-neutral-500">WhatsApp</span>
</button>
<button className="group flex flex-col items-center gap-2">
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-neutral-100 text-neutral-600 transition-all group-hover:bg-neutral-200 active:scale-95">
<SiFacebook size={20} />
</div>
<span className="text-[10px] font-medium text-neutral-500">Facebook</span>
</button>
<button className="group flex flex-col items-center gap-2">
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-neutral-100 text-neutral-600 transition-all group-hover:bg-neutral-200 active:scale-95">
<SiX size={18} />
</div>
<span className="text-[10px] font-medium text-neutral-500">Twitter</span>
</button>
<button className="group flex flex-col items-center gap-2">
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-neutral-100 text-neutral-600 transition-all group-hover:bg-neutral-200 active:scale-95">
<Link2 size={20} />
</div>
<span className="text-[10px] font-medium text-neutral-500">Link</span>
</button>
</div>
</div> */}
{/* 2. ADVERTORIAL (CEO UNITEL) - REPLACING TV CARD */}
<div className="group cursor-pointer">
<div className="flex items-center justify-between mb-3 px-1">
<span className="text-[10px] font-bold text-neutral-400 uppercase">Publicidade</span>
</div>
<div className="relative aspect-[4/5] w-full overflow-hidden rounded-xl bg-neutral-100 shadow-sm transition-shadow">
<Image
src="/zap.jpeg" // Substitua pelo path real da imagem do print
alt="CEO da Unitel - O futuro do 5G em Angola"
fill
unoptimized
className="object-contain"
/>
{/* Overlay de gradiente para legibilidade do texto se estiver na imagem */}
{/* <div className="absolute inset-0 bg-gradient-to-t from-black/80 via-transparent to-transparent opacity-60" /> */}
{/* <div className="absolute bottom-6 left-6 right-6">
<span className="text-[10px] font-bold text-[#007AFF] uppercase tracking-widest">CEO DA UNITEL</span>
<h4 className="text-white text-xl font-bold mt-2 leading-tight">
"ro do 5G em Angola e a expansão da conectividade rural"
</h4>
</div> */}
</div>
{/* <div className="mt-4 px-1">
<div className="flex items-center text-[13px] font-bold text-[#007AFF]">
Ler entrevista completa <ChevronRight size={14} className="ml-1" />
</div>
</div> */}
</div>
<div className="flex items-center justify-between mb-3 px-1">
<span className="text-[10px] font-bold text-neutral-400 uppercase">Publicidade</span>
</div>
{/* 3. PREMIUM SUBSCRIPTION - APPLE STYLE */}
<div className="bg-black p-7 rounded-xl relative overflow-hidden text-white shadow-xl">
<div className="relative z-10">
<h4 className="text-[20px] font-bold leading-tight tracking-tight mb-2">
Assine o Premium.
</h4>
<p className="text-[13px] text-[#8E8E93] font-medium mb-6">
Acesso ilimitado às notícias e análises exclusivas.
</p>
<button className="flex w-full items-center justify-center gap-2 rounded-xl bg-white py-3 text-black font-bold text-[15px] transition-all hover:bg-neutral-100 active:scale-95">
<svg viewBox="0 0 384 512" className="h-4 w-4 fill-black">
<path d="M318.7 268.7c-.2-36.7 16.4-64.4 50-84.8-18.8-26.9-47.2-41.7-84.7-44.6-35.5-2.8-74.3 20.7-88.5 20.7-15 0-49.4-19.7-76.4-19.7C63.3 141.2 4 184.8 4 273.5q0 39.3 14.4 81.2c12.8 36.7 59 126.7 107.2 125.2 25.2-.6 43-17.9 75.8-17.9 31.8 0 48.3 17.9 76.4 17.9 48.6-.7 90.4-82.5 102.6-119.3-65.2-30.7-61.7-90-61.7-91.9zm-56.6-164.2c27.3-32.4 24.8-61.9 24-72.5-24.1 1.4-52 16.4-67.9 34.9-17.5 19.8-27.8 44.3-25.6 71.9 26.1 2 49.9-11.4 69.5-34.3z"/>
</svg>
<span>Pay</span>
</button>
</div>
{/* Sutil glow de fundo */}
<div className="absolute top-0 right-0 w-32 h-32 bg-blue-600/20 blur-[40px]" />
</div>
<div className="px-1">
<div className="flex items-center justify-between mb-4 border-b border-neutral-100 pb-2">
<h3 className="text-[13px] font-bold text-black uppercase tracking-tight">Mais Lidas</h3>
<span className="text-[11px] font-medium text-neutral-400">Últimas 24h</span>
</div>
<div className="space-y-4">
{[
{ id: "01", title: "Câmbio: Kwanza mantém trajetória de estabilidade frente ao Dólar", cat: "Economia" },
{ id: "02", title: "Novas infraestruturas no Porto de Luanda aumentam eficiência", cat: "Logística" },
{ id: "03", title: "Selecção Nacional prepara amistoso contra Marrocos", cat: "Desporto" }
].map((news) => (
<div key={news.id} className="group cursor-pointer flex align-start justify-start gap-4 text-black">
<span className="text-2xl font-black text-black group-hover:text-blue transition-colors leading-none">
{news.id}
</span>
<div className="space-y-1 flex flex-col">
<span className="text-[10px] font-bold text-blue-600 uppercase tracking-widest">{news.cat}</span>
<h4 className="text-[14px] font-bold text-neutral-900 leading-tight group-hover:text-blue-600 transition-colors">
{news.title}
</h4>
</div>
</div>
))}
</div>
<div className="grid gap-x-12 mt-12 gap-y-3 md:grid-cols-1">
<div className="flex items-center justify-between mb-2 border-b border-neutral-100 pb-2">
<h3 className="text-[13px] font-bold text-black uppercase tracking-tight">Noticias Principais</h3>
{/* <span className="text-[11px] font-medium text-neutral-400">Últimas 24h</span> */}
</div>
{recentes.map((item) => (
<article key={item.title} className="group cursor-pointer mb-1">
<Link href="#" className="flex flex-row items-start gap-6">
{/* 1. LADO DA IMAGEM (Limpo e Minimalista) */}
<div className="relative aspect-[4/3] w-25 shrink-0 overflow-hidden rounded-2xl bg-neutral-100 shadow-sm transition-all duration-500 group-hover:shadow-xl sm:w-25 md:w-25 lg:w-25">
<Image
src={item.img}
alt=""
fill
className="object-cover transition duration-700 group-hover:scale-110"
sizes="(max-width: 60px) 60px, 64px"
/>
</div>
{/* 2. LADO DO TEXTO (Categoria externa ao topo) */}
<div className="flex flex-1 flex-col ">
{/* CATEGORIA E DATA LADO A LADO */}
<div className="gap-3 mb-2 flex flex-col justify-start items-start">
<span className="text-[10px] font-bold uppercase tracking-wider text-[#0066CC]">
{item.cat}
</span>
{/* <span className="h-1 w-1 rounded-2xl bg-neutral-300" /> */}
{/* <span className="text-[10px] font-semibold text-neutral-400 uppercase tracking-tight">
{item.date}
</span>
<span className="h-1 w-1 rounded-2xl bg-neutral-300" />
<span className="text-[10px] font-bold uppercase text-neutral-400">
{item.readTime} min
</span> */}
</div>
<h3 className="line-clamp-3 text-[14px] font-bold leading-tight text-neutral-900 transition-colors group-hover:text-[#0066cc]">
{item.title}
</h3>
</div>
</Link>
</article>
))}
</div>
</div>
{/* 2. VIDEO - Minimalist Edge-to-Edge */}
<div className="group">
<div className="flex items-center justify-between mb-3 px-1">
<h3 className="text-[13px] font-bold text-black tracking-tight">TV One Angola</h3>
<div className="flex items-center gap-1.5">
<span className="h-1.5 w-1.5 rounded-full bg-[#FF3B30] animate-pulse" />
<span className="text-[11px] font-bold text-[#FF3B30] uppercase">Ao Vivo</span>
</div>
</div>
<div className="relative aspect-video w-full overflow-hidden rounded-xl bg-black shadow-sm group-hover:shadow-md transition-shadow">
{!isPlaying ? (
<div className="absolute inset-0 z-10 cursor-pointer" onClick={() => setIsPlaying(true)}>
<Image
src="https://i.ytimg.com/vi/bfEYtb2O3iI/maxresdefault.jpg"
alt="Workshop"
fill
className="object-cover transition-transform duration-500 group-hover:scale-105 opacity-90"
/>
<div className="absolute inset-0 flex items-center justify-center">
<div className="flex h-14 w-14 items-center justify-center rounded-full bg-white/20 backdrop-blur-md border border-white/30 text-white transition-all group-hover:bg-white group-hover:text-[#007AFF] group-hover:scale-110">
<svg className="h-6 w-6 ml-0.5" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z" />
</svg>
</div>
</div>
</div>
) : (
<iframe
className="absolute inset-0 h-full w-full"
src="https://www.youtube.com/embed/bfEYtb2O3iI?autoplay=1"
frameBorder="0"
allow="autoplay; encrypted-media"
allowFullScreen
/>
)}
</div>
<div className="mt-3.5 px-1">
<h4 className="text-[16px] font-semibold text-black leading-snug tracking-tight group-hover:text-[#007AFF] transition-colors">
Workshop Acelere a Sua Empresa impulsiona o empreendedorismo em Angola
</h4>
<p className="mt-2 text-[12px] text-[#8E8E93] font-medium tracking-tight">Publicado em 24 de Março, 2026</p>
</div>
</div>
</div>
</aside>
</div>
</main>
<TvoneAdBanner />
<TvoneFooter />
</div>
);
}
+153
View File
@@ -0,0 +1,153 @@
import React, { useState, useCallback } from 'react';
import Cropper from 'react-easy-crop';
import { ImageIcon, Maximize2, RefreshCw, Copy, Check, X } from 'lucide-react';
const ASPECT_RATIOS = [
{ id: 'hero', label: 'Hero Banner (21:9)', ratio: 21 / 9 },
{ id: 'news', label: 'Notícia/Media (16:9)', ratio: 16 / 9 },
{ id: 'square', label: 'Quadrado/Social (1:1)', ratio: 1 / 1 },
];
const MultiAspectEditor = ({ image, onClose, onExport }: { image: string, onClose: () => void, onExport: (results: Record<string, string>) => void }) => {
const [crops, setCrops] = useState(
ASPECT_RATIOS.reduce((acc, curr) => ({
...acc,
[curr.id]: { crop: { x: 0, y: 0 }, zoom: 1, croppedAreaPixels: null }
}), {})
);
const onCropChange = (id: string, crop: any) => {
setCrops(prev => ({ ...prev, [id]: { ...(prev as any)[id], crop } }));
};
const onZoomChange = (id: string, zoom: number) => {
setCrops(prev => ({ ...prev, [id]: { ...(prev as any)[id], zoom } }));
};
const onCropComplete = useCallback((id: string, _: any, croppedAreaPixels: any) => {
setCrops(prev => ({ ...prev, [id]: { ...(prev as any)[id], croppedAreaPixels } }));
}, []);
const generateBase64 = async () => {
const results: Record<string, string> = {};
for (const ratio of ASPECT_RATIOS) {
const pixelCrop = (crops as any)[ratio.id].croppedAreaPixels;
if (pixelCrop) {
results[ratio.id as keyof typeof results] = await getCroppedImg(image, pixelCrop);
}
}
onExport(results);
};
return (
<div className="fixed inset-0 z-[100] bg-slate-900/60 backdrop-blur-sm flex items-center justify-center p-4">
<div className="bg-white w-full max-w-6xl h-[90vh] rounded-3xl shadow-2xl flex flex-col overflow-hidden border border-white/20">
{/* Header do Modal */}
<div className="p-6 border-b border-slate-100 flex justify-between items-center bg-white/50 backdrop-blur-md">
<div>
<h2 className="text-xl font-bold text-slate-900">Editor de Enquadramento</h2>
<p className="text-xs text-slate-500">Ajuste a imagem para os diferentes formatos do portal</p>
</div>
<button onClick={onClose} className="p-2 hover:bg-slate-100 rounded-full transition-colors">
<X size={20} className="text-slate-400" />
</button>
</div>
{/* Área de Edição - Grid de Croppers */}
<div className="flex-1 overflow-y-auto p-8 bg-slate-50/50">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
{ASPECT_RATIOS.map((r) => (
<div key={r.id} className="flex flex-col gap-4">
<div className="flex justify-between items-center">
<span className="text-[11px] font-bold uppercase tracking-wider text-slate-400">{r.label}</span>
<button
onClick={() => onZoomChange(r.id, 1)}
className="text-blue-600 p-1 hover:bg-blue-50 rounded text-[10px] flex items-center gap-1"
>
<RefreshCw size={12}/> Reset
</button>
</div>
{/* Container do Cropper */}
<div className="relative h-64 bg-slate-200 rounded-2xl overflow-hidden border border-slate-200 shadow-inner">
<Cropper
image={image}
crop={(crops as any)[r.id].crop}
zoom={(crops as any)[r.id].zoom}
aspect={r.ratio}
onCropChange={(c) => onCropChange(r.id, c)}
onCropComplete={(_, pix) => onCropComplete(r.id, _, pix)}
onZoomChange={(z) => onZoomChange(r.id, z)}
/>
</div>
{/* Slider de Zoom Customizado */}
<div className="flex items-center gap-3">
<span className="text-slate-400"><Maximize2 size={14}/></span>
<input
type="range"
value={(crops as any)[r.id].zoom}
min={1}
max={3}
step={0.1}
aria-labelledby="Zoom"
onChange={(e) => onZoomChange(r.id, Number(e.target.value))}
className="w-full h-1.5 bg-slate-200 rounded-lg appearance-none cursor-pointer accent-blue-600"
/>
</div>
</div>
))}
</div>
</div>
{/* Footer com Ações */}
<div className="p-6 border-t border-slate-100 bg-white flex justify-end gap-4">
<button onClick={onClose} className="px-6 py-2.5 text-sm font-semibold text-slate-500 hover:text-slate-700">
Cancelar
</button>
<button
onClick={generateBase64}
className="px-8 py-2.5 bg-blue-600 text-white rounded-xl text-sm font-bold shadow-lg shadow-blue-200 hover:bg-blue-700 transition-all flex items-center gap-2"
>
<Check size={18}/> Finalizar e Exportar
</button>
</div>
</div>
</div>
);
};
// Função Utilitária para Canvas -> Base64
async function getCroppedImg(imageSrc: string, pixelCrop: any) {
const image = await new Promise((resolve, reject) => {
const img = new Image();
img.addEventListener('load', () => resolve(img));
img.addEventListener('error', (error) => reject(error));
img.setAttribute('crossOrigin', 'anonymous');
img.src = imageSrc;
});
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d') as CanvasRenderingContext2D | null;
if (!ctx) return '';
canvas.width = pixelCrop.width;
canvas.height = pixelCrop.height;
ctx.drawImage(
image as CanvasImageSource,
pixelCrop.x,
pixelCrop.y,
pixelCrop.width,
pixelCrop.height,
0,
0,
pixelCrop.width,
pixelCrop.height
);
return canvas.toDataURL('image/jpeg', 0.9);
}
export default MultiAspectEditor;
+169
View File
@@ -0,0 +1,169 @@
import Image from "next/image";
import Link from "next/link";
const destaques = [
{
cat: "FAMOSOS",
catColor: "text-pink-600",
title: "Cerimónia reúne estrelas nacionais e internacionais em Lisboa.",
date: "24 Mar 2025",
// High-quality video-style thumbnail
img: "https://images.unsplash.com/photo-1492684223066-81342ee5ff30?w=800&q=80",
},
{
cat: "NEGÓCIOS",
catColor: "text-[#0066cc]",
title: "Mercados reagem às novas projeções de crescimento para a região.",
date: "24 Mar 2025",
img: "https://images.unsplash.com/photo-1486406146926-c627a92ad1ab?w=800&q=80",
},
{
cat: "DESPORTO",
catColor: "text-emerald-600",
title: "Taça: equipa da casa garante lugar nas meias com exibição sólida.",
date: "23 Mar 2025",
img: "https://images.unsplash.com/photo-1574629810360-7efbbe195018?w=800&q=80",
},
{
cat: "NEGÓCIOS",
catColor: "text-[#0066cc]",
title: "Mercados reagem às novas projeções de crescimento para a região.",
date: "24 Mar 2025",
img: "https://images.unsplash.com/photo-1486406146926-c627a92ad1ab?w=800&q=80",
},
];
// export function TvoneDestaquesCultura() {
// return (
// <section className="mx-auto w-full max-w-[1200px] px-4 pb-10">
// <h2 className="mb-6 text-2xl font-bold tracking-tight text-neutral-900">Cultura</h2>
// <div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-4">
// {destaques.map((item, index) => (
// <article
// key={index}
// className="group cursor-pointer bg-white transition"
// >
// <Link href="#" className="block">
// {/* VIDEO CONTAINER */}
// <div className="relative aspect-[16/9] w-full overflow-hidden rounded-2xl bg-neutral-900 shadow-sm transition-all duration-300 group-hover:shadow-xl group-hover:shadow-neutral-200">
// <Image
// src={item.img}
// alt=""
// fill
// className="object-cover transition duration-500 group-hover:scale-110 group-hover:opacity-70"
// sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 25vw"
// />
// {/* OVERLAY: REPLAY STYLE (iOS/EURO) */}
// <div className="absolute inset-0 flex flex-col items-center justify-center bg-black/20 opacity-0 transition-opacity duration-300 group-hover:opacity-100">
// <div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-white/20 backdrop-blur-md border border-white/30">
// <svg className="h-6 w-6 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
// <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
// </svg>
// </div>
// <span className="mt-2 text-[10px] font-bold uppercase tracking-widest text-white">Replay</span>
// </div>
// {/* BOTTOM REPLICA TAG (Optional, Euro Style) */}
// <div className="absolute bottom-2 left-2 rounded bg-black/60 px-1.5 py-0.5 text-[9px] font-bold text-white uppercase tracking-tighter">
// Replica
// </div>
// </div>
// {/* TEXT CONTENT */}
// <div className="py-4">
// {/* <p className={`mb-1.5 text-[10px] font-bold uppercase tracking-wider ${item.catColor}`}>
// {item.cat}
// </p> */}
// <h3 className="line-clamp-2 text-[15px] font-bold leading-tight text-neutral-900 transition-colors group-hover:text-[#0066cc] md:text-[16px]">
// {item.title}
// </h3>
// <p className="mt-2 text-[11px] font-medium text-neutral-400">{item.date}</p>
// </div>
// </Link>
// </article>
// ))}
// </div>
// {/* iOS STYLE "SEE MORE" BUTTON */}
// <div className="mt-10 flex justify-center">
// <Link
// href="/cultura"
// className="inline-flex items-center justify-center rounded-2xl bg-neutral-100 px-8 py-3 text-sm font-semibold text-neutral-900 transition-all hover:bg-neutral-200 active:scale-95"
// >
// Ver mais notícias de Cultura
// <svg className="ml-2 h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
// <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M9 5l7 7-7 7" />
// </svg>
// </Link>
// </div>
// </section>
// );
// }
export function TvoneDestaquesCultura() {
return (
<section className="mx-auto w-full max-w-[1200px] px-4 pb-16">
{/* Cabeçalho Minimalista com Link Azul */}
<div className="mb-10 flex items-end justify-between border-b border-neutral-100 pb-6">
<h2 className="text-3xl font-bold tracking-tight text-neutral-900 md:text-4xl">
Cultura
</h2>
<Link
href="/negocios"
className="group flex items-center gap-1 text-sm font-semibold text-[#0066CC] transition-colors hover:text-[#004499]"
>
Ver tudo
<svg
className="h-4 w-4 transition-transform group-hover:translate-x-0.5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2.5}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
</svg>
</Link>
</div>
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-4">
{destaques.map((item, index) => (
<article
key={index}
className="group cursor-pointer bg-white transition"
>
<Link href="#" className="block">
<div className="relative aspect-[16/9] w-full overflow-hidden rounded-2xl bg-neutral-100 shadow-sm transition-all duration-300 group-hover:shadow-xl group-hover:shadow-neutral-200">
<Image
src={item.img}
alt=""
fill
className="object-cover transition duration-500 group-hover:scale-105"
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 25vw"
/>
<div className="absolute inset-0 flex flex-col items-center justify-center bg-black/10 opacity-0 transition-opacity duration-300 group-hover:opacity-100">
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-white/30 backdrop-blur-md border border-white/40">
<svg className="h-5 w-5 text-white" fill="currentColor" viewBox="0 0 20 20">
<path d="M6.3 2.841A1.5 1.5 0 004 4.11V15.89a1.5 1.5 0 002.3 1.269l9.344-5.89a1.5 1.5 0 000-2.538L6.3 2.841z" />
</svg>
</div>
</div>
</div>
<div className="py-4">
<h3 className="line-clamp-2 text-base font-bold leading-snug text-neutral-900 transition-colors group-hover:text-blue-600">
{item.title}
</h3>
<p className="mt-1.5 text-xs font-medium text-neutral-500">{item.date}</p>
</div>
</Link>
</article>
))}
</div>
</section>
);
}
+292 -213
View File
@@ -1,40 +1,89 @@
import Image from "next/image";
import Link from "next/link";
function IconFacebook({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="currentColor" aria-hidden>
<path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z" />
</svg>
);
}
function IconInstagram({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="currentColor" aria-hidden>
<path d="M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.919.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.849-.07-3.26-.149-4.771-1.699-4.92-4.92-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.92-4.919 1.266-.057 1.645-.069 4.849-.069zm0-2.163c-3.259 0-3.667.014-4.947.072-4.358.2-6.78 2.618-6.98 6.98-.059 1.281-.073 1.689-.073 4.948 0 3.259.014 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98 1.281.058 1.689.072 4.948.072 3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.196-4.354-2.617-6.78-6.979-6.98-1.281-.059-1.69-.073-4.949-.073zm0 5.838c-3.403 0-6.162 2.759-6.162 6.162s2.759 6.163 6.162 6.163 6.162-2.759 6.162-6.163c0-3.403-2.759-6.162-6.162-6.162zm0 10.162c-2.209 0-4-1.79-4-4 0-2.209 1.791-4 4-4s4 1.791 4 4c0 2.21-1.791 4-4 4zm6.406-11.845c-.796 0-1.441.645-1.441 1.44s.645 1.44 1.441 1.44c.795 0 1.439-.645 1.439-1.44s-.644-1.44-1.439-1.44z" />
</svg>
);
}
function IconLinkedIn({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="currentColor" aria-hidden>
<path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433c-1.144 0-2.063-.926-2.063-2.065 0-1.138.92-2.063 2.063-2.063 1.14 0 2.064.925 2.064 2.063 0 1.139-.925 2.065-2.064 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z" />
</svg>
);
}
function IconYouTube({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="currentColor" aria-hidden>
<path d="M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z" />
</svg>
);
}
function IconX({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="currentColor" aria-hidden>
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
</svg>
);
}
/** Apple-style footer social: flat icon, no pill border */
const footerSocialIconClass =
"text-[#6e6e73] transition-colors hover:text-[#1d1d1f] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#0071e3]";
const destaques = [
{
cat: "FAMOSOS",
cat: "Musica",
catColor: "text-pink-600",
title: "Cerimónia reúne estrelas nacionais e internacionais em Lisboa.",
date: "24 Mar 2025",
readTime: "24 Mar 2025",
img: "https://images.unsplash.com/photo-1524504388940-b1c1722653e1?w=600&q=80",
},
{
cat: "NEGÓCIOS",
cat: "FAMOSOS",
catColor: "text-[#0066cc]",
title: "Mercados reagem às novas projeções de crescimento para a região.",
date: "24 Mar 2025",
readTime: "24 Mar 2025",
img: "https://images.unsplash.com/photo-1518770660439-4636190af475?w=600&q=80",
},
{
cat: "DESPORTO",
cat: "FAMOSOS",
catColor: "text-emerald-600",
title: "Taça: equipa da casa garante lugar nas meias com exibição sólida.",
date: "23 Mar 2025",
readTime: "24 Mar 2025",
img: "https://images.unsplash.com/photo-1574629810360-7efbbe195018?w=600&q=80",
},
{
cat: "TECNOLOGIA",
catColor: "text-violet-600",
title: "Novos dispositivos chegam às lojas com foco em sustentabilidade.",
cat: "Musica",
catColor: "text-emerald-600",
title: "Taça: equipa da casa garante lugar nas meias com exibição sólida.",
date: "23 Mar 2025",
img: "https://images.unsplash.com/photo-1518770660439-4636190af475?w=600&q=80",
readTime: "24 Mar 2025",
img: "https://images.unsplash.com/photo-1574629810360-7efbbe195018?w=600&q=80",
},
];
const recentes = [
{
cat: "EM FOCO",
readTime: "6",
catBg: "bg-pink-100 text-pink-700",
title: "Governo anuncia medidas para apoiar famílias e pequenas empresas.",
excerpt: "Pacote inclui linhas de crédito e simplificação de procedimentos.",
@@ -44,6 +93,7 @@ const recentes = [
},
{
cat: "ECONOMIA",
readTime: "6",
catBg: "bg-amber-100 text-amber-800",
title: "Inflação desce pelo terceiro mês consecutivo, segundo dados preliminares.",
excerpt: "Analistas mantêm cautela face ao cenário internacional.",
@@ -53,6 +103,7 @@ const recentes = [
},
{
cat: "CULTURA",
readTime: "6",
catBg: "bg-violet-100 text-violet-800",
title: "Museu inaugura exposição com obras inéditas de artistas locais.",
excerpt: "Visitas guiadas e programa educativo arrancam no próximo fim de semana.",
@@ -62,6 +113,7 @@ const recentes = [
},
{
cat: "SAÚDE",
readTime: "6",
catBg: "bg-emerald-100 text-emerald-800",
title: "Campanha de vacinação alarga faixas etárias em todo o país.",
excerpt: "Autoridades de saúde reforçam importância da adesão às janelas recomendadas.",
@@ -92,244 +144,271 @@ const aSeguir = [
export function TvoneDestaques() {
return (
<section className="mx-auto w-full max-w-[1200px] px-4 pb-10">
<h2 className="mb-5 text-xl font-bold tracking-tight text-neutral-900">Destaques</h2>
<div className="mb-10 flex items-end justify-between border-b border-neutral-100 pb-6">
<h2 className="text-3xl font-bold tracking-tight text-neutral-900 md:text-4xl">
Destaques
</h2>
<Link
href="/negocios"
className="group flex items-center gap-1 text-sm font-semibold text-[#0066CC] transition-colors hover:text-[#004499]"
>
Ver tudo
<svg
className="h-4 w-4 transition-transform group-hover:translate-x-0.5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2.5}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
</svg>
</Link>
</div>
<div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-4">
{destaques.map((item) => (
{destaques.map((item, index) => (
<article
key={item.title}
className="group overflow-hidden rounded-xl border border-neutral-200/80 bg-white shadow-sm transition hover:shadow-md"
className="group cursor-pointer bg-white transition"
>
<article
key={index}
className="group cursor-pointer bg-white transition"
>
<Link href="#" className="block">
<div className="relative aspect-[4/3] w-full overflow-hidden">
<div className="relative aspect-[16/9] w-full overflow-hidden rounded-2xl bg-neutral-100 shadow-sm transition-all duration-300 group-hover:shadow-xl group-hover:shadow-neutral-200">
<Image
src={item.img}
alt=""
fill
className="object-cover transition duration-300 group-hover:scale-[1.03]"
className="object-cover transition duration-500 group-hover:scale-105"
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 25vw"
/>
</div>
<div className="p-4">
<p className={`mb-2 text-[11px] font-bold uppercase tracking-wide ${item.catColor}`}>{item.cat}</p>
<h3 className="text-[15px] font-semibold leading-snug text-neutral-900">{item.title}</h3>
<p className="mt-3 text-xs text-neutral-500">{item.date}</p>
<div className="py-4">
<div className="flex items-center gap-3 pb-2">
<span className="text-[10px] font-bold uppercase tracking-wider text-blue-600">
{item.cat}
</span>
<span className="h-1 w-1 rounded-2xl bg-neutral-300" />
{/* Data de Publicação em destaque suave */}
<span className="text-[10px] font-semibold text-neutral-500 uppercase tracking-tight">
{item.date}
</span>
</div>
<h3 className="line-clamp-4 text-base font-bold leading-snug text-neutral-900 transition-colors group-hover:text-blue-600">
{item.title}
</h3>
</div>
</Link>
</article>
</article>
))}
</div>
</section>
);
}
export function TvoneMainColumns() {
return (
<section className="mx-auto w-full max-w-[1200px] px-4 pb-20">
{/* SECTION HEADER UNIFICADO */}
<div className="mb-10 flex items-end justify-between border-b border-neutral-100 pb-6">
<h2 className="text-3xl font-bold tracking-tight text-neutral-900 md:text-4xl">
Notícias recentes
</h2>
<Link
href="/noticias"
className="group flex items-center gap-1 text-sm font-semibold text-[#0066CC] transition-colors hover:text-[#004499]"
>
Ver tudo
<svg
className="h-4 w-4 transition-transform group-hover:translate-x-0.5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2.5}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
</svg>
</Link>
</div>
{/* GRID DE CARDS HORIZONTAIS */}
<div className="grid gap-x-12 gap-y-12 md:grid-cols-2">
{recentes.map((item) => (
<article key={item.title} className="group cursor-pointer">
<Link href="#" className="flex flex-row items-start gap-6">
{/* 1. LADO DA IMAGEM (Limpo e Minimalista) */}
<div className="relative aspect-[4/3] w-32 shrink-0 overflow-hidden rounded-2xl bg-neutral-100 shadow-sm transition-all duration-500 group-hover:shadow-xl sm:w-44 md:w-48 lg:w-52">
<Image
src={item.img}
alt=""
fill
className="object-cover transition duration-700 group-hover:scale-110"
sizes="(max-width: 768px) 128px, 224px"
/>
</div>
{/* 2. LADO DO TEXTO (Categoria externa ao topo) */}
<div className="flex flex-1 flex-col pt-1">
{/* CATEGORIA E DATA LADO A LADO */}
<div className="flex items-center gap-2 mb-2">
<span className="text-[10px] font-bold uppercase tracking-wider text-[#0066CC]">
{item.cat}
</span>
<span className="h-1 w-1 rounded-2xl bg-neutral-300" />
<span className="text-[10px] font-semibold text-neutral-400 uppercase tracking-tight">
{item.date}
</span>
<span className="h-1 w-1 rounded-2xl bg-neutral-300" />
<span className="text-[10px] font-bold uppercase text-neutral-400">
{item.readTime} min
</span>
</div>
<h3 className="line-clamp-3 text-base font-bold leading-tight text-neutral-900 transition-colors group-hover:text-[#0066cc] sm:text-lg lg:text-xl">
{item.title}
</h3>
<p className="mt-2 hidden line-clamp-2 text-sm leading-relaxed text-neutral-500 lg:block">
{item.excerpt}
</p>
{/* AUTOR / BYLINE */}
{/* <div className="mt-4 flex items-center gap-2 text-[10px] font-bold text-neutral-400 uppercase tracking-tighter">
<span className="text-neutral-900">{item.byline}</span>
</div> */}
</div>
</Link>
</article>
))}
</div>
</section>
);
}
export function TvoneMainColumns() {
return (
<div className="mx-auto grid w-full max-w-[1200px] gap-10 px-4 pb-12 lg:grid-cols-[1fr_340px]">
<section>
<h2 className="mb-6 text-xl font-bold tracking-tight text-neutral-900">Mais Recentes</h2>
<ul className="flex flex-col gap-6">
{recentes.map((item) => (
<li key={item.title}>
<Link href="#" className="group flex gap-4 rounded-xl border border-transparent p-1 transition hover:border-neutral-200 hover:bg-neutral-50">
<div className="relative h-24 w-28 shrink-0 overflow-hidden rounded-lg sm:h-28 sm:w-36">
<Image src={item.img} alt="" fill className="object-cover" sizes="144px" />
</div>
<div className="min-w-0 flex-1">
<span className={`inline-block rounded px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide ${item.catBg}`}>
{item.cat}
</span>
<h3 className="mt-2 text-base font-semibold leading-snug text-neutral-900 group-hover:text-[#0066cc]">
{item.title}
</h3>
<p className="mt-1 line-clamp-2 text-sm text-neutral-600">{item.excerpt}</p>
<p className="mt-2 text-xs text-neutral-500">
{item.byline} · {item.date}
</p>
</div>
</Link>
</li>
))}
</ul>
<Link
href="#"
className="mt-8 flex w-full items-center justify-center rounded-xl bg-[#f5f5f7] py-3 text-sm font-medium text-neutral-800 transition hover:bg-neutral-200/80"
>
Ver todas as notícias
</Link>
</section>
<aside className="flex flex-col gap-8">
<div className="overflow-hidden rounded-xl border border-neutral-200 bg-white shadow-sm">
<div className="flex items-center gap-2 border-b border-neutral-100 px-4 py-3">
<svg className="h-5 w-5 text-neutral-800" viewBox="0 0 24 24" fill="currentColor" aria-hidden>
<path d="M18.71 19.5c-.83 1.24-1.71 2.45-3.05 2.47-1.34.03-1.77-.79-3.29-.79-1.53 0-2 .77-3.27.82-1.31.05-2.3-1.32-3.14-2.53C4.25 17 2.94 12.45 4.7 9.39c.87-1.52 2.43-2.48 4.12-2.51 1.28-.02 2.5.87 3.29.87.78 0 2.26-1.07 3.81-.91.65.03 2.47.26 3.64 1.98-.09.06-2.17 1.28-2.15 3.81.03 3.02 2.65 4.03 2.68 4.04-.03.07-.42 1.44-1.38 2.83M13 3.5c.73-.83 1.94-1.46 2.94-1.5.13 1.17-.34 2.35-1.04 3.19-.69.85-1.83 1.51-2.95 1.42-.15-1.15.41-2.35 1.05-3.11z" />
</svg>
<span className="text-sm font-semibold text-neutral-800">Apple Line</span>
</div>
<div className="relative aspect-[16/10] w-full">
<Image
src="https://images.unsplash.com/photo-1518770660439-4636190af475?w=600&q=80"
alt=""
fill
className="object-cover"
sizes="340px"
/>
</div>
<div className="p-4">
<span className="text-[11px] font-bold uppercase tracking-wide text-[#0066cc]">Bancos</span>
<h3 className="mt-2 text-lg font-bold leading-snug text-neutral-900">
BAI regista lucros acima das expectativas no último trimestre.
</h3>
<p className="mt-2 text-xs text-neutral-500">24 Mar 2025</p>
<Link
href="#"
className="mt-4 flex w-full items-center justify-center rounded-lg bg-neutral-900 py-2.5 text-sm font-medium text-white transition hover:bg-neutral-800"
>
Ver Página Apple Line
</Link>
</div>
</div>
<div>
<h3 className="mb-4 text-lg font-bold text-neutral-900">A seguir</h3>
<ul className="flex flex-col gap-4">
{aSeguir.map((item) => (
<li key={item.title}>
<Link href="#" className="group flex gap-3">
<div className="relative h-16 w-16 shrink-0 overflow-hidden rounded-lg">
<Image src={item.img} alt="" fill className="object-cover" sizes="64px" />
</div>
<div className="min-w-0">
<p className="text-sm font-medium leading-snug text-neutral-900 group-hover:text-[#0066cc]">{item.title}</p>
<p className="mt-1 text-xs text-neutral-500">{item.date}</p>
</div>
</Link>
</li>
))}
</ul>
</div>
</aside>
</div>
);
}
export function TvoneAdBanner() {
return (
<section className="mx-auto w-full max-w-[1200px] px-4 pb-10">
<div className="relative overflow-hidden rounded-xl bg-gradient-to-r from-[#0a4d8c] via-[#1e6fb8] to-[#e85c2a] px-6 py-8 text-white md:flex md:items-center md:justify-between md:py-10">
<div className="max-w-lg">
<p className="text-[11px] font-semibold uppercase tracking-widest text-white/90">BAI Directo</p>
<h2 className="mt-2 text-2xl font-bold md:text-3xl">Actualização do BAI Directo</h2>
<p className="mt-2 text-sm text-white/90">Faça as suas operações com mais rapidez e segurança em qualquer dispositivo.</p>
<section className="mx-auto w-full flex justify-center items-center px-4 pt-10 pb-10">
<div className="max-w-[1200px] w-full">
<div className="relative overflow-hidden rounded-2xl bg-gradient-to-r from-[#0a4d8c] via-[#1e6fb8] to-[#e85c2a] px-6 py-8 text-white md:flex md:items-center md:justify-between md:py-10">
<div className="max-w-lg">
<p className="text-[11px] font-semibold uppercase tracking-widest text-white/90">BAI Directo</p>
<h2 className="mt-2 text-3xl font-bold leading-tight tracking-tight md:text-4xl">Actualização do BAI Directo</h2>
<p className="mt-2 text-sm text-white/90">Faça as suas operações com mais rapidez e segurança em qualquer dispositivo.</p>
</div>
<div className="relative mt-6 h-32 w-full max-w-xs shrink-0 md:mt-0 md:h-36">
<Image
src="https://images.unsplash.com/photo-1511707171634-5f897ff02aa9?w=400&q=80"
alt=""
fill
className="object-contain object-right"
sizes="320px"
/>
</div>
<span className="absolute right-6 top-1/2 hidden -translate-y-1/2 text-2xl font-black tracking-tight opacity-90 md:block">
BAI
</span>
</div>
<div className="relative mt-6 h-32 w-full max-w-xs shrink-0 md:mt-0 md:h-36">
<Image
src="https://images.unsplash.com/photo-1511707171634-5f897ff02aa9?w=400&q=80"
alt=""
fill
className="object-contain object-right"
sizes="320px"
/>
</div>
<span className="absolute right-6 top-1/2 hidden -translate-y-1/2 text-2xl font-black tracking-tight opacity-90 md:block">
BAI
</span>
</div>
</section>
);
}
const services = [
{ icon: "truck", label: "Entrega gratuita" },
{ icon: "return", label: "Devoluções fáceis" },
{ icon: "shield", label: "Pagamento seguro" },
{ icon: "headset", label: "Apoio 24/7" },
];
function ServiceIcon({ name }: { name: string }) {
const common = "h-6 w-6 text-neutral-600";
switch (name) {
case "truck":
return (
<svg className={common} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M9 17a2 2 0 11-4 0 2 2 0 014 0zM19 17a2 2 0 11-4 0 2 2 0 014 0z" />
<path d="M13 16V6a1 1 0 00-1-1H4a1 1 0 00-1 1v10a1 1 0 001 1h1m8-1a1 1 0 01-1 1H9m4-1V8a1 1 0 011-1h2.586a1 1 0 01.707.293l3.414 3.414a1 1 0 01.293.707V16a1 1 0 01-1 1h-1m-6-1a1 1 0 001 1h1M5 17a2 2 0 104 0m-4 0a2 2 0 114 0m6 0a2 2 0 104 0m-4 0a2 2 0 114 0" />
</svg>
);
case "return":
return (
<svg className={common} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="1.5">
<path d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
);
case "shield":
return (
<svg className={common} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="1.5">
<path d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
);
case "headset":
return (
<svg className={common} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="1.5">
<path d="M19 14v3a2 2 0 01-2 2h-2v-6h2a2 2 0 012 2zm-8 5H7a2 2 0 01-2-2v-3a2 2 0 012-2h2v7z" />
<path d="M5 14v-1a7 7 0 0114 0v1" strokeLinecap="round" />
</svg>
);
default:
return null;
}
}
export function TvoneServiceStrip() {
return (
<div className="border-y border-neutral-200 bg-[#f5f5f7] py-6">
<div className="mx-auto flex max-w-[1200px] flex-wrap items-center justify-center gap-8 px-4 md:justify-between md:gap-4">
{services.map((s) => (
<div key={s.label} className="flex items-center gap-3 text-sm text-neutral-700">
<ServiceIcon name={s.icon} />
<span className="font-medium">{s.label}</span>
</div>
))}
</div>
</div>
);
}
export function TvoneFooter() {
return (
<footer className="border-t border-neutral-200 bg-white py-10">
<div className="mx-auto flex max-w-[1200px] flex-col gap-8 px-4 md:flex-row md:items-center md:justify-between">
<div>
<p className="text-lg font-bold tracking-tight text-neutral-900">PLATINALINE</p>
<p className="mt-1 text-sm text-neutral-500">© 2025 PlatinaLine. Todos os direitos reservados.</p>
<footer className="bg-[#f5f5f7] text-[#1d1d1f]">
<div className="mx-auto max-w-[980px] px-4 pb-6 pt-10 sm:px-6 md:pb-8 md:pt-12">
<div className="grid gap-10 sm:grid-cols-2 lg:grid-cols-12 lg:gap-8">
<div className="lg:col-span-5">
<p className="text-[12px] font-semibold leading-[1.33337] tracking-[0.01em] text-[#1d1d1f]">Sobre a TV ONE</p>
<div className="mt-3 border-t border-[#d2d2d7] pt-3">
<div className="relative h-7 w-7 sm:h-8 sm:w-8 md:h-10 md:w-10">
<Image
src="/logo.png" // put your image in /public
alt="tvone logo"
fill
className="rounded-2xl object-cover"
/>
</div>
</div>
<div className="mt-4 space-y-3 text-[12px] leading-[1.33337] text-[#6e6e73]">
<p className="text-pretty">
Com uma abordagem moderna, dinâmica e sempre atual, a TV ONE destaca-se pela oferta de conteúdos diversificados e relevantes, que abrangem áreas como cultura, turismo, moda, celebridades, entretenimento, atualidade, lifestyle, conteúdos virais e desporto.
</p>
<p className="text-pretty">
Mais do que informar, o portal proporciona uma experiência completa, envolvente e interativa, conectando os seus leitores e espectadores às principais tendências e acontecimentos do momento.
</p>
</div>
</div>
<div className="lg:col-span-3 lg:border-[#d2d2d7] lg:pl-8">
<p className="text-[12px] font-semibold leading-[1.33337] tracking-[0.01em] text-[#1d1d1f]">Contactos</p>
<ul className="mt-3 space-y-3 border-t border-[#d2d2d7] pt-3 text-[12px]">
<li>
<span className="block text-[11px] font-normal text-[#6e6e73]">Email</span>
<a
href="mailto:tvone.geral@gmail.com"
className="mt-0.5 inline-block text-[12px] text-[#06c] hover:underline"
>
tvone.geral@gmail.com
</a>
</li>
<li>
<span className="block text-[11px] font-normal text-[#6e6e73]">Call Center</span>
<a href="tel:+244934292121" className="mt-0.5 inline-block text-[12px] text-[#06c] hover:underline">
934 292 121
</a>
</li>
</ul>
</div>
<div className="lg:col-span-4 lg:pl-8">
<p className="text-[12px] font-semibold leading-[1.33337] tracking-[0.01em] text-[#1d1d1f]">
Redes sociais
</p>
<div className="mt-3 flex flex-wrap items-center gap-x-1 gap-y-1 border-t border-[#d2d2d7] pt-4">
<Link href="#" aria-label="Facebook" className={`${footerSocialIconClass} rounded p-2`}>
<IconFacebook className="h-[22px] w-[22px]" />
</Link>
<Link href="#" aria-label="Instagram" className={`${footerSocialIconClass} rounded p-2`}>
<IconInstagram className="h-[22px] w-[22px]" />
</Link>
<Link href="#" aria-label="YouTube" className={`${footerSocialIconClass} rounded p-2`}>
<IconYouTube className="h-[22px] w-[22px]" />
</Link>
</div>
</div>
</div>
<nav className="flex flex-wrap gap-x-6 gap-y-2 text-sm text-neutral-600">
<Link href="#" className="hover:text-neutral-900">
Privacidade
</Link>
<Link href="#" className="hover:text-neutral-900">
Termos de Uso
</Link>
<Link href="#" className="hover:text-neutral-900">
Publicidade
</Link>
<Link href="#" className="hover:text-neutral-900">
Contactos
</Link>
</nav>
<div className="flex items-center gap-4 text-neutral-500">
<Link href="#" aria-label="Facebook" className="hover:text-neutral-800">
f
</Link>
<Link href="#" aria-label="Instagram" className="hover:text-neutral-800">
in
</Link>
<Link href="#" aria-label="YouTube" className="hover:text-neutral-800">
</Link>
<Link href="#" aria-label="X" className="hover:text-neutral-800">
𝕏
</Link>
<div className="mt-8 border-t border-[#d2d2d7] pt-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-2 text-[12px] leading-[1.33337] text-[#6e6e73]">
<span>
Copyright © {new Date().getFullYear()}
</span>
<img
src="/logo.png"
alt="tvone logo"
className="h-4 w-4 sm:h-5 sm:w-5 md:h-6 md:w-6 rounded-2xl object-cover"
/>
<span>Todos os direitos reservados.</span>
</div>
<p className="text-[12px] text-[#6e6e73]">Angola</p>
</div>
</div>
</div>
</footer>
-143
View File
@@ -1,143 +0,0 @@
import Image from "next/image";
import Link from "next/link";
const PROMO_IMG_LEFT =
"https://images.unsplash.com/photo-1571019613454-1cb2f99b2d8b?w=800&q=80&auto=format&fit=crop";
const PROMO_IMG_RIGHT =
"https://images.unsplash.com/photo-1544367567-0f2fcb009e0b?w=800&q=80&auto=format&fit=crop";
const appleNav = [
"Loja",
"Mac",
"iPad",
"iPhone",
"Watch",
"AirPods",
"TV e Casa",
"Entretenimento",
"Acessórios",
"Suporte",
];
function AppleLogo({ className }: { className?: string }) {
return (
<svg aria-hidden className={className} viewBox="0 0 24 24" fill="currentColor">
<path d="M18.71 19.5c-.83 1.24-1.71 2.45-3.05 2.47-1.34.03-1.77-.79-3.29-.79-1.53 0-2 .77-3.27.82-1.31.05-2.3-1.32-3.14-2.53C4.25 17 2.94 12.45 4.7 9.39c.87-1.52 2.43-2.48 4.12-2.51 1.28-.02 2.5.87 3.29.87.78 0 2.26-1.07 3.81-.91.65.03 2.47.26 3.64 1.98-.09.06-2.17 1.28-2.15 3.81.03 3.02 2.65 4.03 2.68 4.04-.03.07-.42 1.44-1.38 2.83M13 3.5c.73-.83 1.94-1.46 2.94-1.5.13 1.17-.34 2.35-1.04 3.19-.69.85-1.83 1.51-2.95 1.42-.15-1.15.41-2.35 1.05-3.11z" />
</svg>
);
}
function SearchIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 15 15" width="15" height="15" fill="none" stroke="currentColor" strokeWidth="1.2">
<circle cx="6.5" cy="6.5" r="5" />
<path d="M10 10l3.5 3.5" strokeLinecap="round" />
</svg>
);
}
function BagIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M16 11V7a4 4 0 00-8 0v4M5 9h14l1 12H4L5 9z" strokeLinejoin="round" />
</svg>
);
}
export function TvoneHeader() {
return (
<header className="w-full">
<nav
className="flex h-11 items-center justify-center gap-1 bg-[#1d1d1f] px-4 text-[12px] font-normal text-[#f5f5f7] md:gap-2 md:text-[13px]"
aria-label="Navegação principal"
>
<div className="flex w-full max-w-[980px] items-center justify-between gap-2">
<Link href="/" className="opacity-90 hover:opacity-100" aria-label="Apple">
<AppleLogo className="h-5 w-5 text-white md:h-[22px] md:w-[22px]" />
</Link>
<ul className="hidden flex-1 items-center justify-center gap-4 lg:flex xl:gap-6">
{appleNav.map((item) => (
<li key={item}>
<Link href="#" className="whitespace-nowrap opacity-90 hover:opacity-100">
{item}
</Link>
</li>
))}
</ul>
<div className="flex items-center gap-5">
<button type="button" className="opacity-90 hover:opacity-100" aria-label="Pesquisar">
<SearchIcon className="text-white" />
</button>
<button type="button" className="opacity-90 hover:opacity-100" aria-label="Sacola">
<BagIcon className="text-white" />
</button>
</div>
</div>
</nav>
<div className="relative flex min-h-[148px] w-full overflow-hidden bg-[#9d1f55] text-white sm:min-h-[180px] md:min-h-[204px] lg:min-h-[228px]">
<div className="relative w-[22%] min-w-[80px] max-w-[300px] shrink-0 sm:w-[24%] md:w-[26%]">
<Image
src={PROMO_IMG_LEFT}
alt="Mulher em atividade ao ar livre"
fill
className="object-cover object-[center_28%]"
sizes="(max-width: 640px) 28vw, 300px"
priority
/>
<div
className="absolute inset-0 bg-gradient-to-r from-black/35 from-[8%] via-[#b8326a]/88 via-[55%] to-[#b8326a]"
aria-hidden
/>
</div>
<div className="relative z-[1] flex min-w-0 flex-1 flex-col justify-center gap-3 bg-gradient-to-r from-[#b8326a] via-[#d9468f] to-[#b8326a] px-4 py-6 sm:flex-row sm:items-center sm:justify-between sm:gap-6 sm:px-7 sm:py-7 md:gap-10 md:px-9 md:py-8 lg:px-10">
<div className="flex min-w-0 flex-col gap-2 sm:flex-row sm:items-center sm:gap-5 md:gap-9">
<span className="shrink-0 text-[11px] font-semibold uppercase tracking-[0.2em] text-white/95 sm:text-xs">
Nossa Seguros
</span>
<p className="text-pretty text-sm font-medium leading-relaxed text-white sm:text-base md:text-lg lg:max-w-[40rem] lg:leading-relaxed">
Seguro Saúde Mulher cuidado que acompanha o seu ritmo.
</p>
</div>
<div className="flex shrink-0 items-center gap-4 text-white/95 sm:pl-2">
<span className="sr-only">Redes sociais</span>
<Link href="#" className="text-sm hover:opacity-80" aria-label="Facebook">
f
</Link>
<Link href="#" className="text-sm hover:opacity-80" aria-label="LinkedIn">
in
</Link>
<Link href="#" className="text-sm hover:opacity-80" aria-label="Instagram">
</Link>
</div>
</div>
<div className="relative w-[22%] min-w-[80px] max-w-[300px] shrink-0 sm:w-[24%] md:w-[26%]">
<Image
src={PROMO_IMG_RIGHT}
alt="Bem-estar e cuidados de saúde"
fill
className="object-cover object-[center_38%]"
sizes="(max-width: 640px) 28vw, 300px"
priority
/>
<div
className="absolute inset-0 bg-gradient-to-l from-black/35 from-[8%] via-[#b8326a]/88 via-[55%] to-[#b8326a]"
aria-hidden
/>
</div>
</div>
<div className="border-b border-[#0066cc]/20 bg-[#0066cc] py-2">
<div className="mx-auto flex max-w-[1200px] items-center gap-3 px-4">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-white text-sm font-bold tracking-tight text-[#0066cc]">
tv
</div>
<span className="text-lg font-semibold tracking-tight text-white">tvone</span>
</div>
</div>
</header>
);
}
+6 -6
View File
@@ -36,20 +36,20 @@ export function TvoneHero() {
const slide = slides[active]!;
return (
<section className="mx-auto w-full max-w-[1200px] px-4 py-6">
<div className="relative overflow-hidden rounded-xl shadow-[0_12px_40px_rgba(0,0,0,0.12)]">
<div className="relative aspect-[21/9] min-h-[280px] w-full md:aspect-[2.4/1]">
<section className="mx-auto w-full max-w-[1200px] px-4 py-6 pb-20 pt-12">
<div className="relative overflow-hidden shadow-[0_12px_40px_rgba(0,0,0,0.12)] rounded-2xl ">
<div className="relative aspect-[21/9] min-h-[280px] w-full md:aspect-[2.4/1] rounded-2xl">
<Image
src={slide.image}
alt=""
fill
className="object-cover"
className="object-cover rounded-2xl"
sizes="(max-width: 1200px) 100vw, 1200px"
priority
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/75 via-black/25 to-transparent" />
<div className="absolute inset-0 flex flex-col justify-end p-6 md:p-10">
<span className="mb-2 inline-flex w-fit rounded bg-[#7c3aed] px-2 py-0.5 text-[11px] font-semibold uppercase tracking-wide text-white">
<span className="mb-2 inline-flex w-fit px-2 py-0.5 text-[11px] font-semibold uppercase tracking-wide bg-blue-600 text-white">
{slide.tag}
</span>
<h1 className="max-w-3xl text-balance text-2xl font-bold leading-tight text-white md:text-3xl lg:text-4xl">
@@ -64,7 +64,7 @@ export function TvoneHero() {
key={s.id}
type="button"
onClick={() => setActive(i)}
className={`h-2 w-2 rounded-full transition ${i === active ? "bg-white" : "bg-white/45 hover:bg-white/70"}`}
className={`h-2 w-2 rounded-2xl transition ${i === active ? "bg-white" : "bg-white/45 hover:bg-white/70"}`}
aria-label={`Slide ${i + 1}`}
aria-current={i === active}
/>
+142
View File
@@ -0,0 +1,142 @@
"use client";
import Image from "next/image";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { CSSProperties } from "react";
export interface SliderPhoto {
src: string;
alt: string;
id?: string | number;
}
const ROTATE_MS = 5500;
// --- 1. SLIDE COMPONENT ---
interface SlideProps {
photo: SliderPhoto;
}
function PromoStripSingleSlide({ photo }: SlideProps) {
return (
<div className="relative h-full w-full">
<Image
src={photo.src}
alt={photo.alt}
fill
priority
className="object-cover object-center"
sizes="100vw"
/>
</div>
);
}
// --- 2. DATA HOOK ---
function useSliderPhotos(): { photos: SliderPhoto[]; loading: boolean } {
const [photos, setPhotos] = useState<SliderPhoto[]>([]);
const [loading, setLoading] = useState<boolean>(true);
useEffect(() => {
let cancelled = false;
fetch("/api/slider-photos", { cache: "no-store" })
.then((r) => (r.ok ? r.json() : []))
.then((data: unknown) => {
if (cancelled) return;
if (Array.isArray(data)) {
const validated = data.filter(
(x): x is SliderPhoto =>
typeof x === "object" && x !== null && "src" in x && "alt" in x
);
setPhotos(validated);
}
})
.catch(() => {
if (!cancelled) setPhotos([]);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => { cancelled = true; };
}, []);
return { photos, loading };
}
// --- 3. MAIN COMPONENT ---
export function TvonePromoStrip() {
const { photos, loading } = useSliderPhotos();
const [index, setIndex] = useState<number>(0);
const [reduceMotion, setReduceMotion] = useState<boolean>(false);
// Constants for the 4.8:1 ratio
const FIXED_RATIO = 4.8 / 1;
useEffect(() => {
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
const sync = () => setReduceMotion(mq.matches);
sync();
mq.addEventListener("change", sync);
return () => mq.removeEventListener("change", sync);
}, []);
const advance = useCallback(() => {
if (photos.length <= 1) return;
setIndex((i: number) => (i + 1) % photos.length);
}, [photos.length]);
useEffect(() => {
if (photos.length <= 1 || reduceMotion) return;
const id = window.setInterval(advance, ROTATE_MS);
return () => window.clearInterval(id);
}, [photos.length, reduceMotion, advance]);
// Loading state with fixed ratio
if (loading && photos.length === 0) {
return (
<div
className="w-full animate-pulse bg-neutral-100 dark:bg-neutral-800"
style={{ aspectRatio: "4.8 / 1" }}
/>
);
}
if (photos.length === 0) return null;
return (
<section
className="relative w-full overflow-hidden bg-neutral-100 dark:bg-neutral-900 transition-all duration-700 ease-[cubic-bezier(0.32,0.72,0,1)]"
style={{ aspectRatio: "4.8 / 1" }} // Locked to 4.8:1
role="region"
aria-roledescription="carrossel"
>
{/* THE TRACK */}
<div
className="flex h-full w-full transition-transform duration-1000 ease-[cubic-bezier(0.32,0.72,0,1)]"
style={{ transform: `translateX(-${index * 100}%)` }}
>
{photos.map((photo: SliderPhoto, i: number) => (
<div key={photo.src || i} className="h-full w-full flex-shrink-0">
<PromoStripSingleSlide photo={photo} />
</div>
))}
</div>
{/* INDICATORS */}
{photos.length > 1 && (
<div className="absolute bottom-4 left-1/2 flex -translate-x-1/2 gap-2 z-20">
{photos.map((_, i: number) => (
<button
key={i}
onClick={() => setIndex(i)}
className={`h-1 transition-all duration-500 ${
index === i ? "w-6 bg-white" : "w-1.5 bg-white/30 hover:bg-white/60"
}`}
aria-label={`Ir para slide ${i + 1}`}
/>
))}
</div>
)}
</section>
);
}
+1 -1
View File
@@ -38,7 +38,7 @@ export function TvonePublicationBanner({
<p className="text-sm font-semibold text-neutral-800">{title}</p>
<p className="mt-0.5 max-w-xl text-xs text-neutral-600 md:text-sm">{subtitle}</p>
</div>
<span className="mt-2 shrink-0 rounded-full border border-neutral-300 bg-white px-3 py-1 text-[11px] font-medium text-neutral-700 md:mt-0">
<span className="mt-2 shrink-0 rounded-2xl border border-neutral-300 bg-white px-3 py-1 text-[11px] font-medium text-neutral-700 md:mt-0">
Espaço disponível
</span>
</div>
+257
View File
@@ -0,0 +1,257 @@
"use client";
import Link from "next/link";
import { useCallback, useEffect, useId, useRef, useState } from "react";
import Image from "next/image";
import { useTheme } from "next-themes";
import { Moon, Sun } from "lucide-react";
const primaryNav = [
{ label: "Música", href: "#" },
{ label: "Atualidade", href: "#" },
{ label: "Cultura", href: "#" },
{ label: "Lifestyle", href: "#" },
{ label: "Entrevistas", href: "#" },
{ label: "Girl Power", href: "#" },
{ label: "Desporto", href: "#" },
{ label: "Contactos", href: "#" },
];
const secondaryNav = [
{ label: "Opinião", href: "#" },
{ label: "Programas", href: "#" },
{ label: "Vídeo", href: "#" },
{ label: "Podcasts", href: "#" },
{ label: "Especiais", href: "#" },
{ label: "Verificação de factos", href: "#" },
];
function SearchIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<circle cx="11" cy="11" r="8" />
<path d="m21 21-4.5-4.5" />
</svg>
);
}
function MenuIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<path d="M4 7h16M4 12h16M4 17h16" />
</svg>
);
}
function CloseIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<path d="M6 6l12 12M18 6L6 18" />
</svg>
);
}
function ChevronDown({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<path d="M6 9l6 6 6-6" />
</svg>
);
}
/**
* Euronews-style navigation: primary row + secondary topic row (desktop),
* sticky with shadow when docked, mobile sheet menu.
*/
export function TvoneSiteNav() {
const sentinelRef = useRef<HTMLDivElement>(null);
const [navIsStuck, setNavIsStuck] = useState(false);
const [menuOpen, setMenuOpen] = useState(false);
const menuId = useId();
const closeMenu = useCallback(() => setMenuOpen(false), []);
const toggleMenu = useCallback(() => setMenuOpen((o) => !o), []);
const { theme, setTheme } = useTheme();
const [mounted, setMounted] = useState(false);
useEffect(() => {
const el = sentinelRef.current;
if (!el) return;
const sync = () => {
const rect = el.getBoundingClientRect();
setNavIsStuck(rect.bottom < 0);
};
sync();
window.addEventListener("scroll", sync, { passive: true });
window.addEventListener("resize", sync);
setMounted(true);
return () => {
window.removeEventListener("scroll", sync);
window.removeEventListener("resize", sync);
};
}, []);
useEffect(() => {
if (!menuOpen) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") closeMenu();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [menuOpen, closeMenu]);
useEffect(() => {
if (!menuOpen) return;
const prev = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
document.body.style.overflow = prev;
};
}, [menuOpen]);
useEffect(() => {
const onResize = () => {
if (window.matchMedia("(min-width: 1024px)").matches) setMenuOpen(false);
};
window.addEventListener("resize", onResize);
return () => window.removeEventListener("resize", onResize);
}, []);
return (
<>
<div ref={sentinelRef} className="pointer-events-none h-px w-full" aria-hidden />
<div className="sticky top-0 z-50">
<nav
className={`border-b border-white/15 bg-[#0066d3] pt-[env(safe-area-inset-top)] text-white transition-shadow duration-200 ${
navIsStuck ? "shadow-[0_4px_16px_rgba(0,0,0,0.18)]" : "shadow-none"
}`}
aria-label="Navegação principal"
>
{/* Row 1 — Euronews-style main bar */}
<div className="mx-auto flex h-[52px] w-full max-w-[1280px] items-center gap-2 px-3 sm:h-[58px] sm:gap-3 sm:px-4 md:h-[64px] md:px-6">
<button
type="button"
className="flex h-11 w-11 shrink-0 items-center justify-center rounded-md hover:bg-white/10 lg:hidden"
aria-expanded={menuOpen}
aria-controls={menuId}
aria-label={menuOpen ? "Fechar menu" : "Abrir menu"}
onClick={toggleMenu}
>
{menuOpen ? <CloseIcon className="h-6 w-6" /> : <MenuIcon className="h-6 w-6" />}
</button>
<Link href="/" className="flex shrink-0 items-center gap-2 py-1.5 text-white" onClick={closeMenu}>
<div className="relative h-6 w-25 sm:h-10 sm:w-30 md:h-10 md:w-40">
<Image
src="/logo1.png" // put your image in /public
alt="tvone logo"
fill
className="rounded-2xl object-cover"
/>
</div>
</Link>
<ul className="hidden min-w-0 flex-1 items-center justify-center gap-4 lg:flex xl:gap-4">
{primaryNav.map((item) => (
<li key={item.label}>
<Link
href={item.href}
className="block whitespace-nowrap rounded-md px-2 py-2.5 text-[10px] font-semibold uppercase tracking-wide text-white/95 transition hover:bg-white/10 xl:px-2.5 xl:text-[14px]"
>
{item.label}
</Link>
</li>
))}
</ul>
<div className="ml-auto flex shrink-0 items-center gap-1 sm:gap-2">
{/* Theme Toggle */}
{mounted && (
<button
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
className="flex h-10 w-10 items-center justify-center rounded-md hover:bg-white/10"
>
{theme === "dark" ? <Moon size={18} /> : <Sun size={18} />}
</button>
)}
<button
type="button"
className="flex h-10 w-10 items-center justify-center rounded-md hover:bg-white/10 sm:h-11 sm:w-11"
aria-label="Pesquisar"
>
<SearchIcon className="h-5 w-5 text-white sm:h-[22px] sm:w-[22px]" />
</button>
</div>
</div>
{/* Row 2 — secondary topics (desktop), Euronews-style strip */}
{/* <div className="hidden border-t border-white/10 bg-[#002f5e] lg:block">
<div className="mx-auto flex max-w-[1280px] items-center gap-1 px-4 py-2.5 md:px-6">
<span className="flex shrink-0 items-center gap-1 pr-2 text-[11px] font-bold uppercase tracking-wider text-white/55">
<ChevronDown className="h-3 w-3" aria-hidden />
Tópicos
</span>
<ul className="flex min-w-0 flex-1 flex-wrap items-center gap-x-4 gap-y-1">
{secondaryNav.map((item) => (
<li key={item.label}>
<Link
href={item.href}
className="text-[13px] font-medium text-white/90 transition hover:text-white hover:underline"
>
{item.label}
</Link>
</li>
))}
</ul>
</div>
</div> */}
</nav>
{/* Mobile: menu is fixed + height from content only (no full-screen empty panel). */}
{menuOpen ? (
<>
<button
type="button"
className="fixed right-0 bottom-0 left-0 z-40 bg-black/25 top-[calc(env(safe-area-inset-top,0px)+52px)] sm:top-[calc(env(safe-area-inset-top,0px)+58px)] md:top-[calc(env(safe-area-inset-top,0px)+64px)] lg:hidden"
aria-label="Fechar menu"
onClick={closeMenu}
/>
<div
id={menuId}
className="fixed right-0 left-0 z-50 max-h-[min(75dvh,calc(100dvh-52px-env(safe-area-inset-top,0px)))] overflow-y-auto rounded-b-xl border-b border-white/15 bg-[#0066d3] shadow-xl top-[calc(env(safe-area-inset-top,0px)+52px)] sm:top-[calc(env(safe-area-inset-top,0px)+58px)] sm:max-h-[min(75dvh,calc(100dvh-58px-env(safe-area-inset-top,0px)))] md:top-[calc(env(safe-area-inset-top,0px)+64px)] md:max-h-[min(75dvh,calc(100dvh-64px-env(safe-area-inset-top,0px)))] lg:hidden"
role="dialog"
aria-modal="true"
aria-label="Menu"
>
<div className="border-b border-white/10 px-2 py-2 sm:px-2.5 sm:py-2.5">
<p className="px-2.5 text-[10px] font-bold uppercase tracking-wider text-white/50 sm:px-3">Secções</p>
<ul className="mt-0.5">
{primaryNav.map((item) => (
<li key={item.label}>
<Link
href={item.href}
className="block rounded-md px-2.5 py-2 text-[14px] font-semibold uppercase tracking-wide text-white sm:px-3 sm:py-2.5 sm:text-[15px]"
onClick={closeMenu}
>
{item.label}
</Link>
</li>
))}
</ul>
</div>
</div>
</>
) : null}
</div>
</>
);
}
+479
View File
@@ -0,0 +1,479 @@
"use client"; // Necessário para animações no Next.js App Router
import Image from "next/image";
import Link from "next/link";
import { motion } from "framer-motion";
const recentes = [
{
title: "Diddy na XB Label? Gerilson Israel responde após anúncio de nova música em conjunto",
excerpt: "O artista angolano esclareceu os rumores sobre a sua possível entrada para a editora internacional...",
cat: "Música",
catBg: "bg-blue-50 text-blue-600",
byline: "Por Redação",
date: "04 Mar 2026",
// Premium Studio Image
img: "https://images.unsplash.com/photo-1598488035139-bdbb2231ce04?q=80&w=800&auto=format&fit=crop",
},
{
title: "Inflação desce pelo terceiro mês consecutivo em Angola, segundo dados preliminares.,",
excerpt: "Os preços dos bens de consumo registaram uma ligeira queda, trazendo alívio para as famílias...",
cat: "Economia",
catBg: "bg-green-50 text-green-600",
byline: "Por Economia Viva",
date: "03 Mar 2026",
// Modern Finance/City Image
img: "https://images.unsplash.com/photo-1526304640581-d334cdbbf45e?q=80&w=800&auto=format&fit=crop",
},
{
title: "Museu de Luanda inaugura exposição com obras inéditas de artistas locais.",
excerpt: "A mostra reúne pinturas e esculturas que retratam a evolução urbana da capital angolana...",
cat: "Cultura",
catBg: "bg-purple-50 text-purple-600",
byline: "Por Cultura Mais",
date: "02 Mar 2026",
// Elegant Art Gallery Image
img: "https://images.unsplash.com/photo-1507676184212-d03ab07a01bf?q=80&w=800&auto=format&fit=crop",
},
{
title: "Diddy na XB Label? Gerilson Israel responde após anúncio de nova música em conjunto,",
excerpt: "O artista angolano esclareceu os rumores sobre a sua possível entrada para a editora internacional...",
cat: "Música",
catBg: "bg-blue-50 text-blue-600",
byline: "Por Redação",
date: "04 Mar 2026",
// Premium Studio Image
img: "https://images.unsplash.com/photo-1598488035139-bdbb2231ce04?q=80&w=800&auto=format&fit=crop",
},
{
title: "Inflação desce pelo terceiro mês consecutivo em Angola, segundo dados preliminares.",
excerpt: "Os preços dos bens de consumo registaram uma ligeira queda, trazendo alívio para as famílias...",
cat: "Economia",
catBg: "bg-green-50 text-green-600",
byline: "Por Economia Viva",
date: "03 Mar 2026",
// Modern Finance/City Image
img: "https://images.unsplash.com/photo-1526304640581-d334cdbbf45e?q=80&w=800&auto=format&fit=crop",
},
{
title: "Inflação desce pelo terceiro mês consecutivo em Angola, segundo dados preliminares",
excerpt: "Os preços dos bens de consumo registaram uma ligeira queda, trazendo alívio para as famílias...",
cat: "Economia",
catBg: "bg-green-50 text-green-600",
byline: "Por Economia Viva",
date: "03 Mar 2026",
// Modern Finance/City Image
img: "https://images.unsplash.com/photo-1526304640581-d334cdbbf45e?q=80&w=800&auto=format&fit=crop",
},
];
const aSeguir = [
{
title: "Mercado imobiliário: especialistas explicam tendências para 2026.",
date: "Há 2 horas",
img: "https://images.unsplash.com/photo-1560518883-ce09059eeffa?w=200&q=80",
},
{
title: "Cinema: estreias nacionais batem recordes de bilheteira no fim de semana.",
date: "Há 5 horas",
img: "https://images.unsplash.com/photo-1485846234645-a62644f84728?w=200&q=80",
},
];
export function TvoneMainColumns1() {
return (
<div className="mx-auto grid w-full max-w-[1200px] gap-10 px-4 pb-20 ">
<section>
{/* Cabeçalho Minimalista com Link Azul */}
<div className="mb-10 flex items-end justify-between border-b border-neutral-100 pb-6">
<h2 className="text-3xl font-bold tracking-tight text-neutral-900 md:text-4xl">
Vídeos Recentes
</h2>
<Link
href="/negocios"
className="group flex items-center gap-1 text-sm font-semibold text-[#0066CC] transition-colors hover:text-[#004499]"
>
Ver tudo
<svg
className="h-4 w-4 transition-transform group-hover:translate-x-0.5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2.5}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
</svg>
</Link>
</div>
<div className="mx-auto flex w-full max-w-[1200px] flex-col gap-10">
{/* SECTION: PREMIUM VIDEO GALLERY (Euronews + iOS Style) */}
<section className="grid w-full gap-6 lg:grid-cols-[1fr_340px]">
{/* Main Player Area */}
<div className="group relative aspect-video overflow-hidden rounded-2xl bg-black shadow-2xl">
<Image
src="https://images.unsplash.com/photo-1504711434969-e33886168f5c?q=80&w=1200&auto=format&fit=crop"
alt="Main Video"
fill
className="object-cover opacity-90 transition-transform duration-700 group-hover:scale-105"
/>
{/* iOS Play Button Overlay */}
<div className="absolute inset-0 flex items-center justify-center">
<div className="flex h-20 w-20 items-center justify-center rounded-full border border-white/30 bg-white/20 backdrop-blur-md transition-transform group-hover:scale-110">
<div className="ml-1 h-0 w-0 border-y-[12px] border-l-[20px] border-y-transparent border-l-white" />
</div>
</div>
{/* Video Info Overlay */}
<div className="absolute bottom-0 left-0 w-full bg-gradient-to-t from-black/80 p-8">
<span className="rounded bg-blue-600 px-2 py-1 text-[10px] font-bold uppercase text-white">Música</span>
<h2 className="mt-3 text-2xl font-bold text-white md:text-3xl">
Diddy na XB Label? Gerilson Israel responde após anúncio...
</h2>
</div>
</div>
{/* Right Playlist: "Vídeos a Seguir" */}
<aside className="flex flex-col rounded-2xl border border-neutral-200 bg-[#f5f5f7]/50 p-4 backdrop-blur-sm">
<h3 className="mb-4 px-2 text-xs font-bold uppercase tracking-widest text-neutral-500">Vídeos a Seguir</h3>
<div className="flex flex-col gap-3 overflow-y-auto lg:max-h-[380px]">
{recentes.slice(0, 6).map((item) => (
<button key={item.title} className="group flex gap-3 rounded-2xl p-2 transition hover:bg-white hover:shadow-sm">
<div className="relative aspect-video w-24 shrink-0 overflow-hidden rounded-lg bg-neutral-200">
<Image src={item.img} alt="" fill className="object-cover" />
<div className="absolute bottom-1 right-1 rounded bg-black/60 px-1 text-[9px] text-white">3:15</div>
</div>
<div className="text-left">
<span className="text-[9px] font-bold uppercase text-blue-600">{item.cat}</span>
<h4 className="line-clamp-2 text-xs font-bold leading-tight text-neutral-900 group-hover:text-blue-600">
{item.title}
</h4>
</div>
</button>
))}
</div>
</aside>
</section>
</div>
</section>
</div>
);
}
function AppleIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="currentColor">
<path d="M18.71 19.5c-.83 1.24-1.71 2.45-3.05 2.47-1.34.03-1.77-.79-3.29-.79-1.53 0-2 .77-3.27.82-1.31.05-2.3-1.32-3.14-2.53C4.25 17 2.94 12.45 4.7 9.39c.87-1.52 2.43-2.48 4.12-2.51 1.28-.02 2.5.87 3.29.87.78 0 2.26-1.07 3.81-.91.65.03 2.47.26 3.64 1.98-.09.06-2.17 1.28-2.15 3.81.03 3.02 2.65 4.03 2.68 4.04-.03.07-.42 1.44-1.38 2.83M13 3.5c.73-.83 1.94-1.46 2.94-1.5.13 1.17-.34 2.35-1.04 3.19-.69.85-1.83 1.51-2.95 1.42-.15-1.15.41-2.35 1.05-3.11z" />
</svg>
);
}
const entrevistas = [
{
guest: "CEO da Unitel",
title: "O futuro do 5G em Angola e a expansão da conectividade rural",
img: "https://images.unsplash.com/photo-1560250097-0b93528c311a?q=80&w=800&auto=format&fit=crop",
},
{
guest: "Sandra Rodrigues",
title: "Como a inteligência artificial está a mudar o jornalismo digital",
img: "https://images.unsplash.com/photo-1573496359142-b8d87734a5a2?q=80&w=800&auto=format&fit=crop",
},
{
guest: "Fundador da Startup X",
title: "O desafio de escalar uma fintech no mercado africano atual",
img: "https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?q=80&w=800&auto=format&fit=crop",
},
];
const negocios = [
{
category: "Investimento",
title: "Banca nacional regista crescimento de 15% no crédito à produção",
description: "Novas medidas de estímulo económico impulsionam o setor agrícola e industrial neste trimestre.",
readTime: "5",
publishDate: "08 ABR 2026",
img: "https://images.unsplash.com/photo-1460925895917-afdab827c52f?q=80&w=800&auto=format&fit=crop",
},
{
category: "Tecnologia",
title: "Hub tecnológico em Luanda atrai investidores estrangeiros",
description: "Empresas de capital de risco olham para o ecossistema de startups angolano com novo otimismo.",
readTime: "4",
publishDate: "08 ABR 2026",
img: "https://images.unsplash.com/photo-1559136555-9303baea8ebd?q=80&w=800&auto=format&fit=crop",
},
{
category: "Mercados",
title: "Preço das commodities: O impacto direto na inflação local",
description: "Análise profunda sobre como a volatilidade externa está a moldar os preços no consumidor final.",
readTime: "7",
publishDate: "08 ABR 2026",
img: "https://images.unsplash.com/photo-1590283603385-17ffb3a7f29f?q=80&w=800&auto=format&fit=crop",
},
];
const editorChoice = [
{
category: "Exclusivo",
title: "O renascimento da arquitetura moderna em Luanda: Equilíbrio entre história e inovação",
description: "Um olhar detalhado sobre como os novos projetos urbanos estão a redefinir a linha do horizonte da capital.",
readTime: "8",
publishDate: "09 ABR 2026", // Data fixa para artigos de fundo
img: "https://images.unsplash.com/photo-1486406146926-c627a92ad1ab?q=80&w=800&auto=format&fit=crop",
},
{
category: "Cultura",
title: "Documentário premiado explora as raízes rítmicas do interior de Angola",
description: "A jornada cinematográfica que capturou sons ancestrais antes que desaparecessem no tempo.",
readTime: "6",
publishDate: "Há 3 horas", // Tempo relativo para notícias quentes
img: "https://images.unsplash.com/photo-1516280440614-37939bbacd81?q=80&w=800&auto=format&fit=crop",
},
{
category: "Estilo de Vida",
title: "Gastronomia sustentável: O novo movimento que está a conquistar os chefs locais",
description: "Como a utilização de produtos sazonais e produtores regionais está a mudar o menu dos melhores restaurantes.",
readTime: "5",
publishDate: "08 ABR 2026",
img: "https://images.unsplash.com/photo-1504674900247-0877df9cc836?q=80&w=800&auto=format&fit=crop",
},
{
category: "Exclusivo",
title: "O renascimento da arquitetura moderna em Luanda: Equilíbrio entre história e inovação",
description: "Um olhar detalhado sobre como os novos projetos urbanos estão a redefinir a linha do horizonte da capital.",
readTime: "8",
publishDate: "09 ABR 2026", // Data fixa para artigos de fundo
img: "https://images.unsplash.com/photo-1486406146926-c627a92ad1ab?q=80&w=800&auto=format&fit=crop",
},
{
category: "Cultura",
title: "Documentário premiado explora as raízes rítmicas do interior de Angola",
description: "A jornada cinematográfica que capturou sons ancestrais antes que desaparecessem no tempo.",
readTime: "6",
publishDate: "Há 3 horas", // Tempo relativo para notícias quentes
img: "https://images.unsplash.com/photo-1516280440614-37939bbacd81?q=80&w=800&auto=format&fit=crop",
},
{
category: "Estilo de Vida",
title: "Gastronomia sustentável: O novo movimento que está a conquistar os chefs locais",
description: "Como a utilização de produtos sazonais e produtores regionais está a mudar o menu dos melhores restaurantes.",
readTime: "5",
publishDate: "08 ABR 2026",
img: "https://images.unsplash.com/photo-1504674900247-0877df9cc836?q=80&w=800&auto=format&fit=crop",
},
];
export function TvoneEntrevistas() {
return (
<section className="mx-auto w-full max-w-[1200px] px-4 pb-20">
{/* Cabeçalho com Título, Descrição e Link Azul */}
<div className="mb-10 flex items-end justify-between border-b border-neutral-100 pb-6">
<div className="max-w-[600px]">
<h2 className="text-3xl font-bold tracking-tight text-neutral-900 md:text-4xl">
Entrevistas
</h2>
<p className="mt-2 text-sm md:text-base text-neutral-500 leading-relaxed">
Conversas exclusivas com as personalidades que movem o mercado, a tecnologia e a cultura em Angola.
</p>
</div>
<Link
href="/entrevistas"
className="group flex items-center gap-1 text-sm font-semibold text-[#0066CC] transition-colors hover:text-[#004499] whitespace-nowrap mb-1"
>
Ver tudo
<svg
className="h-4 w-4 transition-transform group-hover:translate-x-0.5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2.5}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
</svg>
</Link>
</div>
{/* Grid de Cards */}
<div className="grid gap-8 sm:grid-cols-2 lg:grid-cols-3">
{entrevistas.map((item, index) => (
<article key={index} className="group cursor-pointer">
<Link href="#" className="block">
<div className="relative aspect-[4/5] overflow-hidden rounded-2xl bg-neutral-100 shadow-sm transition-all duration-500 group-hover:shadow-2xl group-hover:shadow-neutral-300">
<Image
src={item.img}
alt={item.title}
fill
className="object-cover transition duration-700 group-hover:scale-105"
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/95 via-black/20 to-transparent opacity-90 transition-opacity duration-500 group-hover:opacity-100" />
<div className="absolute bottom-0 p-8">
<span className="text-[10px] font-bold uppercase tracking-[0.15em] text-blue-400">
{item.guest}
</span>
<h3 className="mt-3 text-xl font-bold leading-tight text-white md:text-2xl">
"{item.title}"
</h3>
</div>
</div>
</Link>
</article>
))}
</div>
</section>
);
}
export function TvoneNegocios() {
return (
<section className="mx-auto w-full max-w-[1200px] px-4 pb-20">
{/* Cabeçalho Minimalista com Link Azul */}
<div className="mb-10 flex items-end justify-between border-b border-neutral-100 pb-6">
<h2 className="text-3xl font-bold tracking-tight text-neutral-900 md:text-4xl">
Negócios
</h2>
<Link
href="/negocios"
className="group flex items-center gap-1 text-sm font-semibold text-[#0066CC] transition-colors hover:text-[#004499]"
>
Ver tudo
<svg
className="h-4 w-4 transition-transform group-hover:translate-x-0.5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2.5}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
</svg>
</Link>
</div>
<div className="grid gap-10 lg:grid-cols-3">
{negocios.map((item, index) => (
<article key={index} className="group flex flex-col gap-5">
<div className="relative aspect-video overflow-hidden rounded-2xl bg-neutral-100 shadow-sm transition-all duration-500 group-hover:shadow-md">
<Image
src={item.img}
alt=""
fill
className="object-cover transition-transform duration-700 group-hover:scale-105"
/>
</div>
<div className="px-1">
<div className="flex items-center gap-3">
<span className="text-[10px] font-bold uppercase tracking-wider text-blue-600">
{item.category}
</span>
<span className="h-1 w-1 rounded-2xl bg-neutral-300" />
{/* Data de Publicação em destaque suave */}
<span className="text-[10px] font-semibold text-neutral-500 uppercase tracking-tight">
{item.publishDate}
</span>
</div>
<h3 className="mt-3 text-xl font-bold leading-snug text-neutral-900 transition-colors group-hover:text-[#0066CC]">
{item.title}
</h3>
<p className="mt-3 line-clamp-2 text-sm leading-relaxed text-neutral-500">
{item.description}
</p>
<div className="mt-5 flex items-center gap-2 border-t border-neutral-50 pt-4">
<svg className="h-3.5 w-3.5 text-neutral-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span className="text-[10px] font-bold uppercase text-neutral-400">
{item.readTime} min de leitura
</span>
</div>
</div>
</article>
))}
</div>
</section>
);
}
export function TvoneEscolhaEditor() {
return (
<section className="mx-auto w-full max-w-[1200px] px-4 pb-20 overflow-hidden">
{/* HEADER */}
<div className="mb-10 flex items-end justify-between border-b border-neutral-100 pb-6">
<h2 className="text-3xl font-bold tracking-tight text-neutral-900 md:text-4xl">
Escolha do Editor
</h2>
<Link href="/escolhas" className="group flex items-center gap-1 text-sm font-semibold text-[#0066CC]">
Ver tudo
<svg className="h-4 w-4 transition-transform group-hover:translate-x-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path d="M9 5l7 7-7 7" />
</svg>
</Link>
</div>
{/* CONTAINER COM ANIMACÃO "WHILE IN VIEW" */}
<motion.div
initial={{ x: 80, opacity: 0 }}
whileInView={{ x: 0, opacity: 1 }} // Dispara quando entra na viewport
viewport={{ once: true, margin: "-100px" }} // Executa uma vez, 100px antes de chegar no centro
transition={{
type: "spring",
stiffness: 200, // Aumentado de 50 para 200 (muito mais rápido)
damping: 25, // Mantém o controle para não balançar demais
mass: 0.5 // Reduzimos a massa para o objeto parecer mais leve
}}
className="no-scrollbar flex flex-col gap-10 md:flex-row md:overflow-x-auto md:snap-x md:snap-mandatory md:gap-8 md:pb-6"
>
{editorChoice.slice(0, 6).map((item, index) => (
<article
key={index}
className={`group flex flex-col gap-5 md:min-w-[31%] md:snap-start
${index >= 3 ? 'hidden md:flex' : 'flex'}`}
>
<Link href="#" className="block">
<div className="relative aspect-video overflow-hidden rounded-2xl bg-neutral-100 shadow-sm transition-all duration-500 group-hover:shadow-xl">
<Image
src={item.img}
alt={item.title}
fill
className="object-cover transition-transform duration-700 group-hover:scale-105"
/>
</div>
<div className="mt-5 px-1">
<div className="flex items-center gap-3">
<span className="text-[10px] font-bold uppercase tracking-wider text-blue-600">{item.category}</span>
<span className="h-1 w-1 rounded-2xl bg-neutral-300" />
<span className="text-[10px] font-semibold text-neutral-500 uppercase tracking-tight">{item.publishDate}</span>
</div>
<h3 className="mt-3 text-xl font-bold leading-snug text-neutral-900 group-hover:text-[#0066CC] transition-colors">
{item.title}
</h3>
<p className="mt-3 line-clamp-2 text-sm leading-relaxed text-neutral-500">{item.description}</p>
<div className="mt-5 flex items-center gap-2 border-t border-neutral-50 pt-4">
<svg className="h-3.5 w-3.5 text-neutral-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span className="text-[10px] font-bold uppercase text-neutral-400">
{item.readTime} min de leitura
</span>
</div>
</div>
</Link>
</article>
))}
</motion.div>
</section>
);
}
+3
View File
@@ -4,6 +4,8 @@
--background: #ffffff;
--foreground: #171717;
--tvone-blue: #0066cc;
/** Same as site header / `theme-color` in layout.tsx — iPhone notch + Safari UI */
--tvone-header: #0066d4;
--tvone-magenta: #d9468f;
--tvone-muted: #f5f5f7;
}
@@ -18,4 +20,5 @@ body {
background: var(--background);
color: var(--foreground);
font-family: var(--font-inter), ui-sans-serif, system-ui, sans-serif;
/* Body copy stays regular weight; news titles use bold/semibold in components */
}
+32 -7
View File
@@ -1,15 +1,35 @@
import type { Metadata } from "next";
import type { Metadata, Viewport } from "next";
import { Inter } from "next/font/google";
import "./globals.css";
import { GoogleOAuthProvider } from "@react-oauth/google";
import { ThemeProvider } from 'next-themes'
const inter = Inter({
variable: "--font-inter",
subsets: ["latin"],
});
/** Matches `TvoneSiteNav` — Safari/iOS uses this for the browser chrome tint from the first paint. */
const TVONE_HEADER_BLUE = "#0066D4";
export const viewport: Viewport = {
themeColor: TVONE_HEADER_BLUE,
colorScheme: "light",
viewportFit: "cover",
};
export const metadata: Metadata = {
title: "tvone — Notícias e entretenimento",
description: "O seu portal de notícias, música e cultura.",
icons: {
icon: "/logo.png", // or "/favicon.png"
apple: "/logo.png", // optional for iOS
},
appleWebApp: {
capable: true,
statusBarStyle: "black-translucent",
title: "tvone",
},
};
export default function RootLayout({
@@ -18,12 +38,17 @@ export default function RootLayout({
children: React.ReactNode;
}>) {
return (
<html lang="pt" className={`${inter.variable} h-full antialiased`}>
<body
className={`min-h-full flex flex-col bg-white text-neutral-900 ${inter.className}`}
>
{children}
// 1. We remove "light" from className so ThemeProvider can inject it
// 2. We remove style={{ colorScheme: 'light' }}
<html lang="pt" className={`${inter.variable} h-full antialiased`} suppressHydrationWarning>
<body className={`min-h-full flex flex-col bg-[#f5f5f7] text-neutral-900 dark:bg-black dark:text-white ${inter.className}`}>
<GoogleOAuthProvider clientId="618391854803-gtdbtnf5t78stsmd1724s8c456tfq4lr.apps.googleusercontent.com">
{/* Ensure attribute="class" is set here */}
<ThemeProvider attribute="class" defaultTheme="light" enableSystem={true}>
{children}
</ThemeProvider>
</GoogleOAuthProvider>
</body>
</html>
);
}
}
+21 -5
View File
@@ -1,22 +1,38 @@
import { TvoneHeader } from "./components/tvone-header";
import { TvoneHero } from "./components/tvone-hero";
import { TvonePublicationBanner } from "./components/tvone-publication-banner";
import { TvonePromoStrip } from "./components/tvone-promo-strip";
import { TvoneSiteNav } from "./components/tvone-site-nav";
import {
TvoneAdBanner,
TvoneDestaques,
TvoneFooter,
TvoneMainColumns,
TvoneServiceStrip,
} from "./components/tvone-content";
import { TvoneEntrevistas, TvoneEscolhaEditor, TvoneMainColumns1, TvoneNegocios } from "./components/video-galary";
import { TvoneDestaquesCultura } from "./components/cultura";
export default function Home() {
return (
<div className="flex min-h-full flex-col bg-white">
<TvoneHeader />
{/* <TvonePublicationBanner /> */}
<TvonePromoStrip />
<TvoneSiteNav />
<TvoneHero />
<TvoneDestaques />
<div className="bg-[#f5f5f7] mb-10">
<TvoneAdBanner />
</div>
<TvoneMainColumns />
<TvoneAdBanner />
<TvoneServiceStrip />
<TvoneNegocios />
<TvoneEscolhaEditor />
<TvoneMainColumns1 />
<TvoneDestaquesCultura />
<TvoneEntrevistas />
<div className=" mb-10">
<TvoneAdBanner />
</div>
<TvoneFooter />
</div>
);
+188
View File
@@ -0,0 +1,188 @@
"use client";
import React, { useState, useCallback } from "react";
import Cropper from "react-easy-crop";
const RATIOS = [
{ label: "Hero Banner (Ultra Wide)", value: 21 / 9, text: "21/9" },
{ label: "News Feed (Widescreen)", value: 16 / 9, text: "16/9" },
{ label: "Profile / Post (Square)", value: 1 / 1, text: "1/1" },
];
export default function FullPageEditor() {
const [image, setImage] = useState<string | null>(null);
const [crops, setCrops] = useState<Record<string, any>>(
RATIOS.reduce((acc, r) => ({ ...acc, [r.text]: { x: 0, y: 0, zoom: 1 } }), {})
);
const [completedCrops, setCompletedCrops] = useState<Record<string, any>>({});
const [isExporting, setIsExporting] = useState(false);
const [results, setResults] = useState<Record<string, string>>({});
const onSelectFile = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files.length > 0) {
const reader = new FileReader();
reader.onload = () => setImage(reader.result as string);
reader.readAsDataURL(e.target.files[0]);
}
};
const handleExport = async () => {
setIsExporting(true);
const bundle: Record<string, string> = {};
for (const ratio of RATIOS) {
const crop = completedCrops[ratio.text];
if (crop) bundle[ratio.text] = await getCroppedImg(image!, crop);
}
setResults(bundle);
setIsExporting(false);
};
return (
<div className="min-h-screen bg-zinc-950 text-zinc-100 font-sans selection:bg-indigo-500">
{/* 1. Global Navigation */}
<nav className="sticky top-0 z-50 border-b border-zinc-800 bg-zinc-950/80 backdrop-blur-md px-8 py-4 flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="bg-indigo-600 p-2 rounded-lg font-black text-xs">FIX</div>
<h1 className="text-lg font-bold tracking-tighter">IMAGE FRAMER <span className="text-zinc-500 font-medium text-sm ml-2">PRO v3.0</span></h1>
</div>
<div className="flex gap-4">
<label className="cursor-pointer bg-zinc-800 hover:bg-zinc-700 text-sm font-bold px-5 py-2 rounded-full transition-all border border-zinc-700">
{image ? "New Photo" : "Upload Source"}
<input type="file" accept="image/*" className="hidden" onChange={onSelectFile} />
</label>
{image && (
<button
onClick={handleExport}
className="bg-indigo-600 hover:bg-indigo-500 text-white text-sm font-bold px-8 py-2 rounded-full shadow-lg shadow-indigo-900/20 transition-all active:scale-95"
>
{isExporting ? "Processing..." : "Generate Base64"}
</button>
)}
</div>
</nav>
{/* 2. Workspace Area */}
<main className="max-w-screen-xl mx-auto p-8 space-y-20">
{!image ? (
<div className="h-[70vh] flex flex-col items-center justify-center border-2 border-dashed border-zinc-800 rounded-[3rem] bg-zinc-900/30">
<div className="w-20 h-20 bg-zinc-800 rounded-full flex items-center justify-center text-3xl mb-6">📁</div>
<h2 className="text-2xl font-bold mb-2">Editor is Empty</h2>
<p className="text-zinc-500 max-w-xs text-center">Upload a high-resolution image to begin the multi-aspect framing process.</p>
</div>
) : (
<div className="space-y-32 pb-40">
{RATIOS.map((ratio) => (
<section key={ratio.text} className="group">
{/* Section Header */}
<div className="flex items-end justify-between mb-6 border-b border-zinc-800 pb-4">
<div>
<span className="text-indigo-500 text-xs font-black uppercase tracking-[0.2em]">Aspect Ratio</span>
<h3 className="text-3xl font-bold mt-1">{ratio.label}</h3>
</div>
<div className="text-right">
<p className="text-zinc-500 text-xs font-mono uppercase">Current Zoom</p>
<p className="text-2xl font-black">{Math.round((crops[ratio.text]?.zoom || 1) * 100)}%</p>
</div>
</div>
{/* BIG Editor Window */}
<div className="relative w-full overflow-hidden rounded-[2rem] bg-zinc-900 shadow-2xl ring-1 ring-zinc-800 h-[600px]">
<Cropper
image={image}
crop={{ x: crops[ratio.text].x, y: crops[ratio.text].y }}
zoom={crops[ratio.text].zoom}
aspect={ratio.value}
onCropChange={(c) => setCrops(prev => ({ ...prev, [ratio.text]: { ...prev[ratio.text], ...c } }))}
onZoomChange={(z) => setCrops(prev => ({ ...prev, [ratio.text]: { ...prev[ratio.text], zoom: z } }))}
onCropComplete={(_, px) => setCompletedCrops(prev => ({ ...prev, [ratio.text]: px }))}
objectFit="cover"
showGrid={true}
/>
</div>
{/* Floating Zoom Control (Large & Accessible) */}
<div className="mt-8 flex items-center gap-8 bg-zinc-900/50 p-6 rounded-2xl border border-zinc-800">
<span className="text-xs font-bold text-zinc-500 uppercase tracking-widest min-w-[80px]">Adjust Zoom</span>
<input
type="range"
min={1}
max={3}
step={0.01}
value={crops[ratio.text].zoom}
onChange={(e) => setCrops(prev => ({ ...prev, [ratio.text]: { ...prev[ratio.text], zoom: Number(e.target.value) } }))}
className="flex-1 accent-indigo-500 h-2 bg-zinc-800 rounded-lg appearance-none cursor-pointer"
/>
<button
onClick={() => setCrops(prev => ({ ...prev, [ratio.text]: { x:0, y:0, zoom: 1 } }))}
className="text-[10px] font-black uppercase tracking-widest text-zinc-600 hover:text-white"
>
Reset
</button>
</div>
</section>
))}
</div>
)}
</main>
{/* 3. Global Results Modal/Area */}
{Object.keys(results).length > 0 && (
<div className="fixed inset-0 z-[100] bg-zinc-950 flex flex-col">
<header className="p-8 border-b border-zinc-800 flex justify-between items-center">
<h2 className="text-2xl font-black">EXPORT BUNDLE</h2>
<button onClick={() => setResults({})} className="text-zinc-500 hover:text-white font-bold">Close Editor</button>
</header>
<div className="flex-1 overflow-y-auto p-8 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
{Object.entries(results).map(([ratio, b64]) => (
<div key={ratio} className="bg-zinc-900 rounded-3xl p-6 space-y-4 border border-zinc-800">
<div className="flex justify-between items-center">
<span className="text-xs font-bold text-indigo-500 uppercase">{ratio} Result</span>
<button
onClick={() => {navigator.clipboard.writeText(b64); alert('Copied!');}}
className="text-[10px] bg-indigo-600 px-3 py-1 rounded-full font-bold"
>
Copy Base64
</button>
</div>
<img src={b64} className="w-full rounded-xl border border-white/5 shadow-lg" alt="Crop preview" />
<textarea
readOnly
value={b64}
className="w-full h-24 bg-zinc-950 text-[10px] font-mono p-4 rounded-xl border-none outline-none text-zinc-600"
/>
</div>
))}
</div>
</div>
)}
</div>
);
}
/* ---------------- CANVAS EXPORT UTILITY ---------------- */
async function getCroppedImg(imageSrc: string, pixelCrop: any): Promise<string> {
const image = new Image();
image.src = imageSrc;
await new Promise((resolve) => (image.onload = resolve));
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
if (!ctx) return "";
canvas.width = pixelCrop.width;
canvas.height = pixelCrop.height;
ctx.drawImage(
image,
pixelCrop.x,
pixelCrop.y,
pixelCrop.width,
pixelCrop.height,
0,
0,
pixelCrop.width,
pixelCrop.height
);
return canvas.toDataURL("image/jpeg", 0.9);
}
+50
View File
@@ -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>
// );
// }
+116
View File
@@ -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>
// );
// }
+89
View File
@@ -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,
};
}
+54
View File
@@ -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 : [];
}
+63
View File
@@ -0,0 +1,63 @@
// import fs from "fs";
// import path from "path";
// export type SliderPhoto = { src: string; alt: string };
// function slugToAlt(filename: string): string {
// const base = filename.replace(/\.[^.]+$/, "");
// const words = base.replace(/[-_]+/g, " ").trim();
// return words || "Slide";
// }
// export function parseManifest(data: unknown): SliderPhoto[] {
// if (!Array.isArray(data)) return [];
// const out: SliderPhoto[] = [];
// for (const item of data) {
// if (typeof item !== "object" || item === null || !("src" in item)) continue;
// const src = (item as { src: unknown }).src;
// if (typeof src !== "string" || !src.startsWith("/")) continue;
// const altRaw = (item as { alt?: unknown }).alt;
// const alt = typeof altRaw === "string" && altRaw.trim() ? altRaw.trim() : slugToAlt(src.split("/").pop() ?? "");
// out.push({ src, alt });
// }
// return out;
// }
// const IMAGE_EXT = /\.(jpe?g|png|webp|gif|avif)$/i;
// function scanSliderDirectory(dir: string): SliderPhoto[] {
// let names: string[] = [];
// try {
// names = fs.readdirSync(dir);
// } catch {
// return [];
// }
// return names
// .filter((f) => IMAGE_EXT.test(f))
// .sort((a, b) => a.localeCompare(b, undefined, { sensitivity: "base" }))
// .map((f) => ({
// src: `/slider/${f}`,
// alt: slugToAlt(f),
// }));
// }
// /**
// * Reads `public/slider/manifest.json` when present (full control: order + alt).
// * Otherwise scans `public/slider` for image files (drop-in updates, no code edits).
// */
// export function readSliderPhotos(): SliderPhoto[] {
// const dir = path.join(process.cwd(), "public", "slider");
// if (!fs.existsSync(dir)) return [];
// const manifestPath = path.join(dir, "manifest.json");
// if (fs.existsSync(manifestPath)) {
// try {
// const raw = JSON.parse(fs.readFileSync(manifestPath, "utf8")) as unknown;
// return parseManifest(raw);
// } catch {
// return [];
// }
// }
// return scanSliderDirectory(dir);
// }
+7
View File
@@ -0,0 +1,7 @@
export function slugify(text: string) {
return text
.toLowerCase()
.trim()
.replace(/\s+/g, "-")
.replace(/[^a-z0-9-]/g, "");
}
+4
View File
@@ -9,6 +9,10 @@ const nextConfig: NextConfig = {
hostname: "images.unsplash.com",
pathname: "/**",
},
{
protocol: 'https',
hostname: 'i.ytimg.com', // NOVO: Adicionado para as thumbnails do YouTube
},
],
},
};
+13 -1
View File
@@ -9,9 +9,21 @@
"lint": "eslint"
},
"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",
"@tinymce/tinymce-react": "^6.3.0",
"framer-motion": "^12.38.0",
"keycloak-js": "^26.2.3",
"lucide-react": "^1.8.0",
"next": "16.2.1",
"next-themes": "^0.4.6",
"react": "19.2.4",
"react-dom": "19.2.4"
"react-dom": "19.2.4",
"react-easy-crop": "^5.5.7",
"react-icons": "^5.6.0",
"uuid": "^13.0.0"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
+203
View File
@@ -8,15 +8,51 @@ importers:
.:
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':
specifier: ^0.13.5
version: 0.13.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@tinymce/tinymce-react':
specifier: ^6.3.0
version: 6.3.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
framer-motion:
specifier: ^12.38.0
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:
specifier: ^1.8.0
version: 1.8.0(react@19.2.4)
next:
specifier: 16.2.1
version: 16.2.1(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
next-themes:
specifier: ^0.4.6
version: 0.4.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
react:
specifier: 19.2.4
version: 19.2.4
react-dom:
specifier: 19.2.4
version: 19.2.4(react@19.2.4)
react-easy-crop:
specifier: ^5.5.7
version: 5.5.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
react-icons:
specifier: ^5.6.0
version: 5.6.0(react@19.2.4)
uuid:
specifier: ^13.0.0
version: 13.0.0
devDependencies:
'@tailwindcss/postcss':
specifier: ^4
@@ -116,6 +152,28 @@ packages:
resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==}
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':
resolution: {integrity: sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA==}
@@ -425,6 +483,12 @@ packages:
resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==}
engines: {node: '>=12.4.0'}
'@react-oauth/google@0.13.5':
resolution: {integrity: sha512-xQWri2s/3nNekZJ4uuov2aAfQYu83bN3864KcFqw2pK1nNbFurQIjPFDXhWaKH3IjYJ2r/9yyIIpsn5lMqrheQ==}
peerDependencies:
react: '>=16.8.0'
react-dom: '>=16.8.0'
'@rtsao/scc@1.1.0':
resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==}
@@ -523,6 +587,16 @@ packages:
'@tailwindcss/postcss@4.2.2':
resolution: {integrity: sha512-n4goKQbW8RVXIbNKRB/45LzyUqN451deQK0nzIeauVEqjlI49slUlgKYJM2QyUzap/PcpnS7kzSUmPb1sCRvYQ==}
'@tinymce/tinymce-react@6.3.0':
resolution: {integrity: sha512-E++xnn0XzDzpKr40jno2Kj7umfAE6XfINZULEBBeNjTMvbACWzA6CjiR6V8eTDc9yVmdVhIPqVzV4PqD5TZ/4g==}
peerDependencies:
react: ^19.0.0 || ^18.0.0 || ^17.0.1 || ^16.7.0
react-dom: ^19.0.0 || ^18.0.0 || ^17.0.1 || ^16.7.0
tinymce: ^8.0.0 || ^7.0.0 || ^6.0.0 || ^5.5.1
peerDependenciesMeta:
tinymce:
optional: true
'@tybys/wasm-util@0.10.1':
resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==}
@@ -1130,6 +1204,20 @@ packages:
resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==}
engines: {node: '>= 0.4'}
framer-motion@12.38.0:
resolution: {integrity: sha512-rFYkY/pigbcswl1XQSb7q424kSTQ8q6eAC+YUsSKooHQYuLdzdHjrt6uxUC+PRAO++q5IS7+TamgIw1AphxR+g==}
peerDependencies:
'@emotion/is-prop-valid': '*'
react: ^18.0.0 || ^19.0.0
react-dom: ^18.0.0 || ^19.0.0
peerDependenciesMeta:
'@emotion/is-prop-valid':
optional: true
react:
optional: true
react-dom:
optional: true
function-bind@1.1.2:
resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==}
@@ -1394,6 +1482,9 @@ packages:
resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==}
engines: {node: '>=4.0'}
keycloak-js@26.2.3:
resolution: {integrity: sha512-widjzw/9T6bHRgEp6H/Se3NCCarU7u5CwFKBcwtu7xfA1IfdZb+7Q7/KGusAnBo34Vtls8Oz9vzSqkQvQ7+b4Q==}
keyv@4.5.4:
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
@@ -1496,6 +1587,11 @@ packages:
lru-cache@5.1.1:
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
lucide-react@1.8.0:
resolution: {integrity: sha512-WuvlsjngSk7TnTBJ1hsCy3ql9V9VOdcPkd3PKcSmM34vJD8KG6molxz7m7zbYFgICwsanQWmJ13JlYs4Zp7Arw==}
peerDependencies:
react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
@@ -1521,6 +1617,12 @@ packages:
minimist@1.2.8:
resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==}
motion-dom@12.38.0:
resolution: {integrity: sha512-pdkHLD8QYRp8VfiNLb8xIBJis1byQ9gPT3Jnh2jqfFtAsWUA3dEepDlsWe/xMpO8McV+VdpKVcp+E+TGJEtOoA==}
motion-utils@12.36.0:
resolution: {integrity: sha512-eHWisygbiwVvf6PZ1vhaHCLamvkSbPIeAYxWUuL3a2PD/TROgE7FvfHWTIH4vMl798QLfMw15nRqIaRDXTlYRg==}
ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
@@ -1537,6 +1639,12 @@ packages:
natural-compare@1.4.0:
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
next-themes@0.4.6:
resolution: {integrity: sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==}
peerDependencies:
react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc
react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc
next@16.2.1:
resolution: {integrity: sha512-VaChzNL7o9rbfdt60HUj8tev4m6d7iC1igAy157526+cJlXOQu5LzsBXNT+xaJnTP/k+utSX5vMv7m0G+zKH+Q==}
engines: {node: '>=20.9.0'}
@@ -1565,6 +1673,9 @@ packages:
node-releases@2.0.36:
resolution: {integrity: sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==}
normalize-wheel@1.0.1:
resolution: {integrity: sha512-1OnlAPZ3zgrk8B91HyRj+eVv+kS5u+Z0SCsak6Xil/kmgEia50ga7zfkumayonZrImffAxPU/5WcyGhzetHNPA==}
object-assign@4.1.1:
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
engines: {node: '>=0.10.0'}
@@ -1670,6 +1781,17 @@ packages:
peerDependencies:
react: ^19.2.4
react-easy-crop@5.5.7:
resolution: {integrity: sha512-kYo4NtMeXFQB7h1U+h5yhUkE46WQbQdq7if54uDlbMdZHdRgNehfvaFrXnFw5NR1PNoUOJIfTwLnWmEx/MaZnA==}
peerDependencies:
react: '>=16.4.0'
react-dom: '>=16.4.0'
react-icons@5.6.0:
resolution: {integrity: sha512-RH93p5ki6LfOiIt0UtDyNg/cee+HLVR6cHHtW3wALfo+eOHTp8RnU2kRkI6E+H19zMIs03DyxUG/GfZMOGvmiA==}
peerDependencies:
react: '*'
react-is@16.13.1:
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
@@ -1914,6 +2036,10 @@ packages:
uri-js@4.4.1:
resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
uuid@13.0.0:
resolution: {integrity: sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==}
hasBin: true
which-boxed-primitive@1.1.1:
resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==}
engines: {node: '>= 0.4'}
@@ -2059,6 +2185,31 @@ snapshots:
'@babel/helper-string-parser': 7.27.1
'@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':
dependencies:
'@emnapi/wasi-threads': 1.2.0
@@ -2299,6 +2450,11 @@ snapshots:
'@nolyfill/is-core-module@1.0.39': {}
'@react-oauth/google@0.13.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies:
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
'@rtsao/scc@1.1.0': {}
'@swc/helpers@0.5.15':
@@ -2374,6 +2530,12 @@ snapshots:
postcss: 8.5.8
tailwindcss: 4.2.2
'@tinymce/tinymce-react@6.3.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies:
prop-types: 15.8.1
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
'@tybys/wasm-util@0.10.1':
dependencies:
tslib: 2.8.1
@@ -3140,6 +3302,15 @@ snapshots:
dependencies:
is-callable: 1.2.7
framer-motion@12.38.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
dependencies:
motion-dom: 12.38.0
motion-utils: 12.36.0
tslib: 2.8.1
optionalDependencies:
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
function-bind@1.1.2: {}
function.prototype.name@1.1.8:
@@ -3405,6 +3576,8 @@ snapshots:
object.assign: 4.1.7
object.values: 1.2.1
keycloak-js@26.2.3: {}
keyv@4.5.4:
dependencies:
json-buffer: 3.0.1
@@ -3483,6 +3656,10 @@ snapshots:
dependencies:
yallist: 3.1.1
lucide-react@1.8.0(react@19.2.4):
dependencies:
react: 19.2.4
magic-string@0.30.21:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
@@ -3506,6 +3683,12 @@ snapshots:
minimist@1.2.8: {}
motion-dom@12.38.0:
dependencies:
motion-utils: 12.36.0
motion-utils@12.36.0: {}
ms@2.1.3: {}
nanoid@3.3.11: {}
@@ -3514,6 +3697,11 @@ snapshots:
natural-compare@1.4.0: {}
next-themes@0.4.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
dependencies:
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
next@16.2.1(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
dependencies:
'@next/env': 16.2.1
@@ -3547,6 +3735,8 @@ snapshots:
node-releases@2.0.36: {}
normalize-wheel@1.0.1: {}
object-assign@4.1.1: {}
object-inspect@1.13.4: {}
@@ -3659,6 +3849,17 @@ snapshots:
react: 19.2.4
scheduler: 0.27.0
react-easy-crop@5.5.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
dependencies:
normalize-wheel: 1.0.1
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
tslib: 2.8.1
react-icons@5.6.0(react@19.2.4):
dependencies:
react: 19.2.4
react-is@16.13.1: {}
react@19.2.4: {}
@@ -4016,6 +4217,8 @@ snapshots:
dependencies:
punycode: 2.3.1
uuid@13.0.0: {}
which-boxed-primitive@1.1.1:
dependencies:
is-bigint: 1.1.0
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="137.99" height="73.494" viewBox="0 0 137.99 73.494"><defs><style>.a{fill:#cf0f67;}</style></defs><g transform="translate(-75.515 -311.607)"><path class="a" d="M551.386,312.758c3.383-1.734,6.8-.934,7.62,1.787s-1.275,6.323-4.666,8.058-6.8.923-7.62-1.787,1.266-6.322,4.666-8.047" transform="translate(-394.5 -0.197)"/><path class="a" d="M640.595,312.758c3.391-1.734,6.8-.934,7.618,1.787s-1.264,6.323-4.656,8.058-6.806.923-7.626-1.787,1.267-6.322,4.664-8.047" transform="translate(-469.217 -0.197)"/><path class="a" d="M781,404.09h0Z" transform="translate(-590.867 -77.458)"/><path class="a" d="M96.8,443.115c-22.3,1.123-23.629-5.514-19.351-11.655,1.846-2.645,8.812-8.286,13.576-11.836,7.2-5.361,14.135-9.823,9.826-11.758-3.412-1.543-12.772.82-17.048,2.011-3.045.846-5.036-1.844-2.174-4.062,8.643-6.7,26.419-7.03,34.768-4.292a7.6,7.6,0,0,1,3.816,2.668c3.49,5.217-5.963,12.733-12.172,18.521a50.905,50.905,0,0,0-6.211,7.116,3.876,3.876,0,0,0-.453,3.348c.635,1.451,1.672,1.964,3.4,2.356,3.519.794,6.986-.375,10.4-1.149,3.116-.713,4.684,1.959,1.9,3.56-5.74,3.314-11.9,4.742-20.276,5.166" transform="translate(0 -73.959)"/><path class="a" d="M659.492,331.988a.112.112,0,0,0-.122-.11h-.7a.162.162,0,0,0-.135.11l-.955,3.992c-.019.078-.044.1-.109.1s-.083-.024-.107-.1l-.962-3.992a.133.133,0,0,0-.123-.11h-.705a.114.114,0,0,0-.125.11l1.1,4.19a.842.842,0,0,0,.92.681.856.856,0,0,0,.926-.681l1.1-4.154Zm-4.538.543V332a.124.124,0,0,0-.127-.123h-3.266a.124.124,0,0,0-.122.123v.53a.117.117,0,0,0,.122.115h1.171v4.029a.14.14,0,0,0,.137.13h.65a.135.135,0,0,0,.138-.13v-4.029h1.168a.117.117,0,0,0,.127-.115" transform="translate(-482.356 -16.977)"/><path class="a" d="M569.591,334.481c0,1.006-.51,1.259-1.175,1.259-.236,0-.418-.018-.552-.018v-3.449c.135,0,.325-.02.552-.02.664,0,1.175.253,1.175,1.256Zm.916,0v-.975a1.869,1.869,0,0,0-2.091-2.036,10.387,10.387,0,0,0-1.31.085.172.172,0,0,0-.162.2v4.474a.18.18,0,0,0,.162.208q.652.071,1.31.076a1.869,1.869,0,0,0,2.091-2.036m-4.5,1.852V331.66a.13.13,0,0,0-.135-.123h-.65a.132.132,0,0,0-.133.123v1.826h-1.9V331.66a.122.122,0,0,0-.119-.123H562.4a.137.137,0,0,0-.145.123v4.674a.145.145,0,0,0,.145.13h.669a.13.13,0,0,0,.119-.13V334.3h1.888v2.032a.14.14,0,0,0,.133.13h.65a.136.136,0,0,0,.135-.13" transform="translate(-407.665 -16.636)"/><path class="a" d="M651.194,356.721c-5.215,2.65-10.453,1.425-11.717-2.749s1.95-9.714,7.163-12.369,10.453-1.433,11.715,2.741-1.95,9.712-7.161,12.377M667.545,330.6a21.445,21.445,0,0,0-14.328-3.933c-.31.023-.64.055-.952.1a2.306,2.306,0,0,1-1.011-.137c-.7-.263-.848-.564-1.092-1.553-.27-1.774-.037-3.18.383-3.456,2.643-1.891,4.141-4.936,3.423-7.311-.812-2.721-4.236-3.519-7.636-1.787s-5.477,5.342-4.664,8.06c.65,2.166,2.944,3.11,5.553,2.538a3.947,3.947,0,0,1,.179,1.8,4.02,4.02,0,0,1-.894,2.57,6.087,6.087,0,0,1-2.53,1.763,30.032,30.032,0,0,0-8.179,6.031c-4.746,4.874-7.374,9.82-9.132,17.209-2.547,10.671-1.553,21.031,3.737,30.255,2.112,3.67,6.417,2.77,5.771-.8a52.485,52.485,0,0,1-1.184-11.6,38.615,38.615,0,0,1,1.331-8.055,1.09,1.09,0,0,1,.65-.712,1.187,1.187,0,0,1,1.069.026c9.095,5.248,21.741,3.192,29.475-5.171,8.221-8.884,9.03-19.334.024-25.832" transform="translate(-460.484)"/><path class="a" d="M373.062,433.509c-3.071,1.569-6.156.84-6.9-1.612s1.137-5.72,4.224-7.282,6.146-.843,6.89,1.61-1.147,5.719-4.211,7.283M371.3,401.683c-8.575.772-14.664,4.765-19.363,11.417-1.787,2.53.954,4.525,3.283,3.18,3.683-2.154,7.066-5.353,12.529-5.719,6.632-.437,10.406,2.762,9.936,7.218-.146,1.405-.907,1.655-2.078,1.462-5.215-.632-23.576-2.551-26.762,10.659a8.174,8.174,0,0,0-.195,2.655c.242,7.3,7.68,11.223,14.947,11.654a35.413,35.413,0,0,0,3.688-.01,33.239,33.239,0,0,0,5.652-.73c.263-.089.518-.153.741-.214a24.675,24.675,0,0,0,5.8-2.414c1.087-.6,2.429-1.3,3.706-.637a3.81,3.81,0,0,1,1.378,1.3v-.01c2.575,3.184,5.849,2.284,5.387-2.437-.478-5.036-.64-5.524,1.09-14.339a26.851,26.851,0,0,0,.629-3.5c1.3-10.815-6.872-20.742-20.36-19.53" transform="translate(-228.745 -75.365)"/></g></svg>

After

Width:  |  Height:  |  Size: 3.9 KiB