feat(payload): add pt-BR admin/frontend copy and site settings social links
- add global and cached global fetch utility - add , , and components driven by global settings - wire global registration in Payload config - enable Payload admin i18n with Portuguese () language pack - translate frontend metadata/homepage/custom route copy to pt-BR - translate collection/global labels, descriptions, and validation messages - improve social-link a11y with Portuguese fallback labels and external-link icon - update francisco.solla and admin labels/groups for Portuguese UX - regenerate for new global/schema changes
This commit is contained in:
parent
c3d1e5c717
commit
56f2d48d8d
12 changed files with 329 additions and 12 deletions
|
|
@ -1,18 +1,22 @@
|
|||
import { Footer } from '@/components/Footer'
|
||||
import { Header } from '@/components/Header'
|
||||
import React from 'react'
|
||||
import './styles.css'
|
||||
|
||||
export const metadata = {
|
||||
description: 'A blank template using Payload in a Next.js app.',
|
||||
title: 'Payload Blank Template',
|
||||
description: 'Um template base usando Payload em uma aplicação Next.js.',
|
||||
title: 'Template Base do Payload',
|
||||
}
|
||||
|
||||
export default async function RootLayout(props: { children: React.ReactNode }) {
|
||||
const { children } = props
|
||||
|
||||
return (
|
||||
<html lang="en">
|
||||
<html lang="pt-BR">
|
||||
<body>
|
||||
<Header />
|
||||
<main>{children}</main>
|
||||
<Footer />
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -26,8 +26,8 @@ export default async function HomePage() {
|
|||
width={65}
|
||||
/>
|
||||
</picture>
|
||||
{!user && <h1>Welcome to your new project.</h1>}
|
||||
{user && <h1>Welcome back, {user.email}</h1>}
|
||||
{!user && <h1>Boas-vindas ao seu novo projeto.</h1>}
|
||||
{user && <h1>Que bom ver você de volta, {user.email}.</h1>}
|
||||
<div className="links">
|
||||
<a
|
||||
className="admin"
|
||||
|
|
@ -35,7 +35,7 @@ export default async function HomePage() {
|
|||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
Go to admin panel
|
||||
Ir para o painel administrativo
|
||||
</a>
|
||||
<a
|
||||
className="docs"
|
||||
|
|
@ -43,18 +43,18 @@ export default async function HomePage() {
|
|||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
Documentation
|
||||
Documentação
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div className="footer">
|
||||
<p>Update this page by editing</p>
|
||||
<p>Atualize esta página editando</p>
|
||||
<a className="codeLink" href={fileURL}>
|
||||
<code>app/(frontend)/page.tsx</code>
|
||||
</a>
|
||||
</div>
|
||||
<div className="mt-4 rounded-md border border-white bg-white px-3 py-1 text-sm text-black">
|
||||
Tailwind is active: utility classes are being applied.
|
||||
Tailwind está ativo: as classes utilitárias estão sendo aplicadas.
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -7,6 +7,6 @@ export const GET = async (request: Request) => {
|
|||
})
|
||||
|
||||
return Response.json({
|
||||
message: 'This is an example of a custom route.',
|
||||
message: 'Este é um exemplo de rota personalizada.',
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,13 @@ import type { CollectionConfig } from 'payload'
|
|||
|
||||
export const Media: CollectionConfig = {
|
||||
slug: 'media',
|
||||
labels: {
|
||||
singular: 'Mídia',
|
||||
plural: 'Mídias',
|
||||
},
|
||||
admin: {
|
||||
group: 'Coleções',
|
||||
},
|
||||
access: {
|
||||
read: () => true,
|
||||
},
|
||||
|
|
@ -9,6 +16,10 @@ export const Media: CollectionConfig = {
|
|||
{
|
||||
name: 'alt',
|
||||
type: 'text',
|
||||
label: 'Texto alternativo',
|
||||
admin: {
|
||||
description: 'Descrição da imagem para acessibilidade.',
|
||||
},
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
|
|
|
|||
|
|
@ -2,7 +2,12 @@ import type { CollectionConfig } from 'payload'
|
|||
|
||||
export const Users: CollectionConfig = {
|
||||
slug: 'users',
|
||||
labels: {
|
||||
singular: 'Usuário',
|
||||
plural: 'Usuários',
|
||||
},
|
||||
admin: {
|
||||
group: 'Coleções',
|
||||
useAsTitle: 'email',
|
||||
},
|
||||
auth: true,
|
||||
|
|
|
|||
7
src/components/Footer.tsx
Normal file
7
src/components/Footer.tsx
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import { SocialLinks } from './socialLinks'
|
||||
|
||||
export const Footer = () => (
|
||||
<footer className="flex items-center justify-center p-4">
|
||||
<SocialLinks ariaLabel="Redes sociais do rodapé" />
|
||||
</footer>
|
||||
)
|
||||
15
src/components/Header.tsx
Normal file
15
src/components/Header.tsx
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import { getCachedGlobal } from '@/utilities/getGlobals'
|
||||
import { SocialLinks } from './socialLinks'
|
||||
|
||||
export const Header = async () => {
|
||||
const settings = await getCachedGlobal('site-settings', 2)()
|
||||
|
||||
console.log(settings)
|
||||
|
||||
return (
|
||||
<header className="flex items-center justify-between p-4">
|
||||
{settings.headerTitle ? <h2>{settings.headerTitle}</h2> : null}
|
||||
<SocialLinks ariaLabel="Redes sociais" />
|
||||
</header>
|
||||
)
|
||||
}
|
||||
114
src/components/socialLinks.tsx
Normal file
114
src/components/socialLinks.tsx
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
import { getCachedGlobal } from '@/utilities/getGlobals'
|
||||
import Link from 'next/link'
|
||||
|
||||
type IconProps = {
|
||||
className?: string
|
||||
}
|
||||
|
||||
const baseIconClass = 'h-5 w-5'
|
||||
|
||||
const InstagramIcon = ({ className }: IconProps) => (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="white"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className || baseIconClass}
|
||||
aria-hidden
|
||||
>
|
||||
<title>Instagram</title>
|
||||
<path d="M7.0301.084c-1.2768.0602-2.1487.264-2.911.5634-.7888.3075-1.4575.72-2.1228 1.3877-.6652.6677-1.075 1.3368-1.3802 2.127-.2954.7638-.4956 1.6365-.552 2.914-.0564 1.2775-.0689 1.6882-.0626 4.947.0062 3.2586.0206 3.6671.0825 4.9473.061 1.2765.264 2.1482.5635 2.9107.308.7889.72 1.4573 1.388 2.1228.6679.6655 1.3365 1.0743 2.1285 1.38.7632.295 1.6361.4961 2.9134.552 1.2773.056 1.6884.069 4.9462.0627 3.2578-.0062 3.668-.0207 4.9478-.0814 1.28-.0607 2.147-.2652 2.9098-.5633.7889-.3086 1.4578-.72 2.1228-1.3881.665-.6682 1.0745-1.3378 1.3795-2.1284.2957-.7632.4966-1.636.552-2.9124.056-1.2809.0692-1.6898.063-4.948-.0063-3.2583-.021-3.6668-.0817-4.9465-.0607-1.2797-.264-2.1487-.5633-2.9117-.3084-.7889-.72-1.4568-1.3876-2.1228C21.2982 1.33 20.628.9208 19.8378.6165 19.074.321 18.2017.1197 16.9244.0645 15.6471.0093 15.236-.005 11.977.0014 8.718.0076 8.31.0215 7.0301.0839m.1402 21.6932c-1.17-.0509-1.8053-.2453-2.2287-.408-.5606-.216-.96-.4771-1.3819-.895-.422-.4178-.6811-.8186-.9-1.378-.1644-.4234-.3624-1.058-.4171-2.228-.0595-1.2645-.072-1.6442-.079-4.848-.007-3.2037.0053-3.583.0607-4.848.05-1.169.2456-1.805.408-2.2282.216-.5613.4762-.96.895-1.3816.4188-.4217.8184-.6814 1.3783-.9003.423-.1651 1.0575-.3614 2.227-.4171 1.2655-.06 1.6447-.072 4.848-.079 3.2033-.007 3.5835.005 4.8495.0608 1.169.0508 1.8053.2445 2.228.408.5608.216.96.4754 1.3816.895.4217.4194.6816.8176.9005 1.3787.1653.4217.3617 1.056.4169 2.2263.0602 1.2655.0739 1.645.0796 4.848.0058 3.203-.0055 3.5834-.061 4.848-.051 1.17-.245 1.8055-.408 2.2294-.216.5604-.4763.96-.8954 1.3814-.419.4215-.8181.6811-1.3783.9-.4224.1649-1.0577.3617-2.2262.4174-1.2656.0595-1.6448.072-4.8493.079-3.2045.007-3.5825-.006-4.848-.0608M16.953 5.5864A1.44 1.44 0 1 0 18.39 4.144a1.44 1.44 0 0 0-1.437 1.4424M5.8385 12.012c.0067 3.4032 2.7706 6.1557 6.173 6.1493 3.4026-.0065 6.157-2.7701 6.1506-6.1733-.0065-3.4032-2.771-6.1565-6.174-6.1498-3.403.0067-6.156 2.771-6.1496 6.1738M8 12.0077a4 4 0 1 1 4.008 3.9921A3.9996 3.9996 0 0 1 8 12.0077" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const FacebookIcon = ({ className }: IconProps) => (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="white"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className || baseIconClass}
|
||||
aria-hidden
|
||||
>
|
||||
<title>Facebook</title>
|
||||
<path d="M9.101 23.691v-7.98H6.627v-3.667h2.474v-1.58c0-4.085 1.848-5.978 5.858-5.978.401 0 .955.042 1.468.103a8.68 8.68 0 0 1 1.141.195v3.325a8.623 8.623 0 0 0-.653-.036 26.805 26.805 0 0 0-.733-.009c-.707 0-1.259.096-1.675.309a1.686 1.686 0 0 0-.679.622c-.258.42-.374.995-.374 1.752v1.297h3.919l-.386 2.103-.287 1.564h-3.246v8.245C19.396 23.238 24 18.179 24 12.044c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.628 3.874 10.35 9.101 11.647Z" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const YoutubeIcon = ({ className }: IconProps) => (
|
||||
<svg
|
||||
role="img"
|
||||
viewBox="0 0 24 24"
|
||||
fill="white"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className || baseIconClass}
|
||||
aria-hidden
|
||||
>
|
||||
<title>YouTube</title>
|
||||
<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>
|
||||
)
|
||||
|
||||
const GlobeIcon = ({ className }: IconProps) => (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="white"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className || baseIconClass}
|
||||
aria-hidden
|
||||
>
|
||||
<title>Link externo</title>
|
||||
<path d="M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20zm7.9 9h-3.02a15.7 15.7 0 0 0-1.18-4.2A8.03 8.03 0 0 1 19.9 11zM12 4.04c.9 1.13 1.65 2.95 1.94 4.96h-3.88c.29-2.01 1.04-3.83 1.94-4.96zM8.3 6.8A15.7 15.7 0 0 0 7.12 11H4.1a8.03 8.03 0 0 1 4.2-4.2zM4.1 13h3.02c.15 1.47.56 2.92 1.18 4.2A8.03 8.03 0 0 1 4.1 13zM12 19.96c-.9-1.13-1.65-2.95-1.94-4.96h3.88c-.29 2.01-1.04 3.83-1.94 4.96zm2.88-2.76c.62-1.28 1.03-2.73 1.18-4.2h3.02a8.03 8.03 0 0 1-4.2 4.2z" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const icons = {
|
||||
instagram: InstagramIcon,
|
||||
facebook: FacebookIcon,
|
||||
youtube: YoutubeIcon,
|
||||
custom: GlobeIcon,
|
||||
} as const
|
||||
|
||||
const defaultA11yLabels: Record<string, string> = {
|
||||
instagram: 'Instagram',
|
||||
facebook: 'Facebook',
|
||||
youtube: 'YouTube',
|
||||
custom: 'Link externo',
|
||||
}
|
||||
|
||||
type SocialLinksProps = {
|
||||
ariaLabel: string
|
||||
}
|
||||
|
||||
export const SocialLinks = async ({ ariaLabel }: SocialLinksProps) => {
|
||||
const { socialLinks: links } = await getCachedGlobal('site-settings', 2)()
|
||||
|
||||
if (!links?.length) return null
|
||||
|
||||
return (
|
||||
<nav aria-label={ariaLabel}>
|
||||
<ul className="flex items-center gap-3">
|
||||
{links.map((link) => {
|
||||
if (!link.enabled || !link.url) return null
|
||||
|
||||
const platform = link.platform ?? 'custom'
|
||||
const Icon = icons[platform] ?? GlobeIcon
|
||||
const label =
|
||||
link.label?.trim() ?? defaultA11yLabels[platform] ?? defaultA11yLabels.custom
|
||||
|
||||
return (
|
||||
<li key={`${platform}-${link.url}`}>
|
||||
<Link
|
||||
href={link.url}
|
||||
aria-label={label}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex rounded-md p-1 transition-opacity hover:opacity-80"
|
||||
>
|
||||
<Icon />
|
||||
</Link>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
80
src/globals/SiteSettings.ts
Normal file
80
src/globals/SiteSettings.ts
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
import { revalidateTag } from 'next/cache'
|
||||
import type { GlobalConfig } from 'payload'
|
||||
|
||||
const revalidate = async () => revalidateTag('global_site-settings')
|
||||
|
||||
export const SiteSettings: GlobalConfig = {
|
||||
slug: 'site-settings',
|
||||
label: 'Configurações do site',
|
||||
admin: {
|
||||
group: 'Configurações',
|
||||
},
|
||||
access: {
|
||||
read: () => true,
|
||||
update: ({ req: { user } }) => Boolean(user),
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'headerTitle',
|
||||
type: 'text',
|
||||
label: 'Título do cabeçalho',
|
||||
},
|
||||
{
|
||||
name: 'socialLinks',
|
||||
type: 'array',
|
||||
label: 'Redes sociais',
|
||||
labels: {
|
||||
singular: 'Rede social',
|
||||
plural: 'Redes sociais',
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'platform',
|
||||
type: 'select',
|
||||
label: 'Plataforma',
|
||||
required: true,
|
||||
options: [
|
||||
{ label: 'Instagram', value: 'instagram' },
|
||||
{ label: 'Facebook', value: 'facebook' },
|
||||
{ label: 'YouTube', value: 'youtube' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'url',
|
||||
type: 'text',
|
||||
label: 'URL',
|
||||
required: true,
|
||||
validate: (value: string | null | undefined) => {
|
||||
if (!value) return 'A URL é obrigatória'
|
||||
try {
|
||||
const parsed = new URL(value)
|
||||
if (!['http:', 'https:'].includes(parsed.protocol)) {
|
||||
return 'A URL deve começar com http:// ou https://'
|
||||
}
|
||||
return true
|
||||
} catch {
|
||||
return 'Informe uma URL válida'
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'label',
|
||||
type: 'text',
|
||||
label: 'Rótulo de acessibilidade',
|
||||
admin: {
|
||||
description:
|
||||
'Rótulo opcional para acessibilidade. Se estiver vazio, usamos um rótulo padrão da plataforma.',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'enabled',
|
||||
type: 'checkbox',
|
||||
label: 'Ativo',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
hooks: {
|
||||
afterChange: [revalidate],
|
||||
},
|
||||
}
|
||||
|
|
@ -87,8 +87,12 @@ export interface Config {
|
|||
defaultIDType: number;
|
||||
};
|
||||
fallbackLocale: null;
|
||||
globals: {};
|
||||
globalsSelect: {};
|
||||
globals: {
|
||||
'site-settings': SiteSetting;
|
||||
};
|
||||
globalsSelect: {
|
||||
'site-settings': SiteSettingsSelect<false> | SiteSettingsSelect<true>;
|
||||
};
|
||||
locale: null;
|
||||
user: User;
|
||||
jobs: {
|
||||
|
|
@ -145,6 +149,9 @@ export interface User {
|
|||
*/
|
||||
export interface Media {
|
||||
id: number;
|
||||
/**
|
||||
* Descrição da imagem para acessibilidade.
|
||||
*/
|
||||
alt: string;
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
|
|
@ -312,6 +319,47 @@ export interface PayloadMigrationsSelect<T extends boolean = true> {
|
|||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "site-settings".
|
||||
*/
|
||||
export interface SiteSetting {
|
||||
id: number;
|
||||
headerTitle?: string | null;
|
||||
socialLinks?:
|
||||
| {
|
||||
platform: 'instagram' | 'facebook' | 'youtube';
|
||||
url: string;
|
||||
/**
|
||||
* Rótulo opcional para acessibilidade. Se estiver vazio, usamos um rótulo padrão da plataforma.
|
||||
*/
|
||||
label?: string | null;
|
||||
enabled?: boolean | null;
|
||||
id?: string | null;
|
||||
}[]
|
||||
| null;
|
||||
updatedAt?: string | null;
|
||||
createdAt?: string | null;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "site-settings_select".
|
||||
*/
|
||||
export interface SiteSettingsSelect<T extends boolean = true> {
|
||||
headerTitle?: T;
|
||||
socialLinks?:
|
||||
| T
|
||||
| {
|
||||
platform?: T;
|
||||
url?: T;
|
||||
label?: T;
|
||||
enabled?: T;
|
||||
id?: T;
|
||||
};
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
globalType?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "auth".
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@ import sharp from 'sharp'
|
|||
|
||||
import { Users } from './collections/Users'
|
||||
import { Media } from './collections/Media'
|
||||
import { SiteSettings } from './globals/SiteSettings'
|
||||
import { pt } from 'payload/i18n/pt'
|
||||
|
||||
const filename = fileURLToPath(import.meta.url)
|
||||
const dirname = path.dirname(filename)
|
||||
|
|
@ -19,6 +21,7 @@ export default buildConfig({
|
|||
},
|
||||
},
|
||||
collections: [Users, Media],
|
||||
globals: [SiteSettings],
|
||||
editor: lexicalEditor(),
|
||||
secret: process.env.PAYLOAD_SECRET || '',
|
||||
typescript: {
|
||||
|
|
@ -31,4 +34,8 @@ export default buildConfig({
|
|||
}),
|
||||
sharp,
|
||||
plugins: [],
|
||||
i18n: {
|
||||
fallbackLanguage: 'pt', // use 'pt' (not 'pt-BR') for Payload admin language code
|
||||
supportedLanguages: { pt },
|
||||
},
|
||||
})
|
||||
|
|
|
|||
26
src/utilities/getGlobals.ts
Normal file
26
src/utilities/getGlobals.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import type { Config } from 'src/payload-types'
|
||||
|
||||
import configPromise from '@payload-config'
|
||||
import { unstable_cache } from 'next/cache'
|
||||
import { getPayload } from 'payload'
|
||||
|
||||
type Global = keyof Config['globals']
|
||||
|
||||
export async function getGlobal(slug: Global, depth = 0) {
|
||||
const payload = await getPayload({ config: configPromise })
|
||||
|
||||
const global = await payload.findGlobal({
|
||||
slug,
|
||||
depth,
|
||||
})
|
||||
|
||||
return global
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a unstable_cache function mapped with the cache tag for the slug
|
||||
*/
|
||||
export const getCachedGlobal = (slug: Global, depth = 0) =>
|
||||
unstable_cache(async () => getGlobal(slug, depth), [slug], {
|
||||
tags: [`global_${slug}`],
|
||||
})
|
||||
Loading…
Reference in a new issue