From e8ea2ab93a8a4d838529db7cd42616f6ef0b06db Mon Sep 17 00:00:00 2001 From: John Enderson Date: Sun, 5 Jul 2026 21:53:40 -0300 Subject: [PATCH 1/3] feat(blog): reacoes por secao nos artigos MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Barra de reacoes (emoji joinha, fogo e mente explodida) injetada apos cada h2 dos artigos, reaproveitando a infra dos likes: - Redis: hash duravel reactions: (campo :, HINCRBY), sem TTL, mais um segundo rate limiter (rl:reactions, 60/60s) via helper generalizado. - API /api/reactions/[id]: GET com o mapa completo do artigo e POST validado por whitelist dupla — likesId de artigo real + slug de h2 real (extraidos do MDX com github-slugger, o mesmo slugger do rehype-slug que gera os ids no DOM, com paridade na contagem de duplicatas e acentos). - Client: SectionReactions injeta as barras via portal apos cada h2[id] (padrao do HeadingAnchors), um unico fetch SWR por artigo, update otimista com rollback e estado ja-reagi em localStorage. - getClientIp extraido para src/lib/request.ts e reutilizado na rota de likes. Verificado em build de producao local: GET/POST/400/404 na API, whitelist aceita slug acentuado (referências), clique gera update otimista + persistencia no Redis e 8 barras injetadas no artigo do Karpenter. Co-Authored-By: Claude Fable 5 --- app/api/likes/[id]/route.ts | 7 +- app/api/reactions/[id]/route.ts | 105 ++++++++ package.json | 3 +- src/base/article/Layout/Layout.tsx | 2 + .../SectionReactions/SectionReactions.tsx | 225 ++++++++++++++++++ src/base/article/SectionReactions/index.tsx | 1 + src/features/articles/lib/articles.ts | 70 ++++++ src/lib/reactions.ts | 23 ++ src/lib/redis.ts | 90 +++++-- src/lib/request.ts | 10 + 10 files changed, 511 insertions(+), 25 deletions(-) create mode 100644 app/api/reactions/[id]/route.ts create mode 100644 src/base/article/SectionReactions/SectionReactions.tsx create mode 100644 src/base/article/SectionReactions/index.tsx create mode 100644 src/lib/reactions.ts create mode 100644 src/lib/request.ts diff --git a/app/api/likes/[id]/route.ts b/app/api/likes/[id]/route.ts index 34df7dc..bd7e9be 100644 --- a/app/api/likes/[id]/route.ts +++ b/app/api/likes/[id]/route.ts @@ -7,6 +7,7 @@ import { incrementLikes, isRedisConfigured, } from '@/lib/redis'; +import { getClientIp } from '@/lib/request'; export const dynamic = 'force-dynamic'; @@ -21,12 +22,6 @@ const ID_PATTERN = /^[A-Za-z0-9_-]{1,64}$/; const isKnownArticle = (id: string) => ID_PATTERN.test(id) && getArticleLikesIds().has(id); -const getClientIp = (request: Request): string => { - const forwarded = request.headers.get('x-forwarded-for'); - if (forwarded) return forwarded.split(',')[0].trim(); - return request.headers.get('x-real-ip') ?? 'unknown'; -}; - export async function GET(_request: Request, { params }: Params) { const { id } = await params; diff --git a/app/api/reactions/[id]/route.ts b/app/api/reactions/[id]/route.ts new file mode 100644 index 0000000..45ebd54 --- /dev/null +++ b/app/api/reactions/[id]/route.ts @@ -0,0 +1,105 @@ +import { NextResponse } from 'next/server'; + +import { + getArticleLikesIds, + getArticleSectionSlugs, +} from '@/features/articles/lib/articles'; +import { ArticleReactions, isReactionId } from '@/lib/reactions'; +import { + checkReactionsRateLimit, + getReactionFields, + incrementReaction, + isRedisConfigured, +} from '@/lib/redis'; +import { getClientIp } from '@/lib/request'; + +export const dynamic = 'force-dynamic'; + +type Params = { params: Promise<{ id: string }> }; + +const noStore = { headers: { 'Cache-Control': 'no-store' } }; + +const ID_PATTERN = /^[A-Za-z0-9_-]{1,64}$/; + +// Whitelist: só artigos reais (mesma proteção da API de likes). +const isKnownArticle = (id: string) => + ID_PATTERN.test(id) && getArticleLikesIds().has(id); + +/** Converte o hash cru (`":" → n`) no shape por seção. */ +const toArticleReactions = ( + fields: Record, +): ArticleReactions => { + const reactions: ArticleReactions = {}; + + for (const [field, count] of Object.entries(fields)) { + const separator = field.lastIndexOf(':'); + if (separator === -1) continue; + + const slug = field.slice(0, separator); + const reaction = field.slice(separator + 1); + if (!isReactionId(reaction) || Number(count) <= 0) continue; + + reactions[slug] = { ...reactions[slug], [reaction]: Number(count) }; + } + + return reactions; +}; + +export async function GET(_request: Request, { params }: Params) { + const { id } = await params; + + if (!isKnownArticle(id)) { + return NextResponse.json({ reactions: null }, { ...noStore, status: 404 }); + } + + // Reações desativadas (sem Redis) — null faz o widget não renderizar. + if (!isRedisConfigured()) { + return NextResponse.json({ reactions: null }, noStore); + } + + const fields = await getReactionFields(id); + return NextResponse.json({ reactions: toArticleReactions(fields) }, noStore); +} + +export async function POST(request: Request, { params }: Params) { + const { id } = await params; + + if (!isKnownArticle(id)) { + return NextResponse.json({ count: null }, { ...noStore, status: 404 }); + } + + if (!isRedisConfigured()) { + return NextResponse.json({ count: null }, noStore); + } + + let body: { slug?: unknown; reaction?: unknown }; + try { + body = await request.json(); + } catch { + return NextResponse.json({ count: null }, { ...noStore, status: 400 }); + } + + const { slug, reaction } = body; + + // Whitelist dupla: a reação precisa existir e o slug precisa ser um h2 real + // do artigo — bloqueia a criação de campos arbitrários no hash do Redis. + if ( + typeof reaction !== 'string' || + !isReactionId(reaction) || + typeof slug !== 'string' || + !getArticleSectionSlugs(id).has(slug) + ) { + return NextResponse.json({ count: null }, { ...noStore, status: 400 }); + } + + const allowed = await checkReactionsRateLimit(getClientIp(request)); + if (!allowed) { + return NextResponse.json( + { count: null, error: 'rate_limited' }, + { ...noStore, status: 429 }, + ); + } + + const count = await incrementReaction(id, `${slug}:${reaction}`); + return NextResponse.json({ count }, noStore); +} diff --git a/package.json b/package.json index 3ef6cd8..3ad4797 100644 --- a/package.json +++ b/package.json @@ -20,15 +20,16 @@ }, "dependencies": { "@fortawesome/fontawesome-svg-core": "^7.2.0", - "@shikijs/rehype": "^4.1.0", "@fortawesome/free-brands-svg-icons": "^7.2.0", "@fortawesome/free-solid-svg-icons": "^7.2.0", "@fortawesome/react-fontawesome": "^3.3.1", "@radix-ui/react-tooltip": "^1.2.8", + "@shikijs/rehype": "^4.1.0", "@upstash/ratelimit": "^2.0.8", "@upstash/redis": "^1.38.0", "debounce": "^2.1.0", "feed": "^5.2.1", + "github-slugger": "^2.0.0", "lightningcss-linux-x64-gnu": "^1.32.0", "motion": "^12.40.0", "next": "^16.2.6", diff --git a/src/base/article/Layout/Layout.tsx b/src/base/article/Layout/Layout.tsx index 666fd35..3438596 100644 --- a/src/base/article/Layout/Layout.tsx +++ b/src/base/article/Layout/Layout.tsx @@ -9,6 +9,7 @@ import { Footer } from '@/base/article/Layout/Footer'; import { Likes } from '@/base/article/Likes'; import { Meta } from '@/base/article/Meta'; import { ReadingProgress } from '@/base/article/ReadingProgress'; +import { SectionReactions } from '@/base/article/SectionReactions'; import { ShareMenu } from '@/base/article/ShareMenu'; import { TableOfContents } from '@/base/article/TableOfContents/TableOfContents'; import { Title } from '@/base/article/Title'; @@ -96,6 +97,7 @@ export const Layout: FC> = ({ {children} +
diff --git a/src/base/article/SectionReactions/SectionReactions.tsx b/src/base/article/SectionReactions/SectionReactions.tsx new file mode 100644 index 0000000..c656b98 --- /dev/null +++ b/src/base/article/SectionReactions/SectionReactions.tsx @@ -0,0 +1,225 @@ +'use client'; + +import { FC, useEffect, useState, useSyncExternalStore } from 'react'; + +import { createPortal } from 'react-dom'; +import useSWR, { type KeyedMutator } from 'swr'; + +import { ArticleReactions, REACTIONS, ReactionId } from '@/lib/reactions'; + +type ReactionsResponse = { reactions: ArticleReactions | null }; + +const jsonFetcher = (url: string): Promise => + fetch(url).then((r) => r.json()); + +const storageKey = (likesId: string, slug: string, reaction: ReactionId) => + `reacted:${likesId}:${slug}:${reaction}`; + +// localStorage como "external store" do estado "você já reagiu" — mesmo +// padrão do Likes: evita setState em effect e hydration mismatch. +const REACTED_EVENT = 'article-reaction-change'; + +const subscribe = (callback: () => void) => { + window.addEventListener(REACTED_EVENT, callback); + window.addEventListener('storage', callback); + return () => { + window.removeEventListener(REACTED_EVENT, callback); + window.removeEventListener('storage', callback); + }; +}; + +const readReacted = (likesId: string, slug: string, reaction: ReactionId) => { + try { + return localStorage.getItem(storageKey(likesId, slug, reaction)) === '1'; + } catch { + return false; + } +}; + +const markReacted = (likesId: string, slug: string, reaction: ReactionId) => { + try { + localStorage.setItem(storageKey(likesId, slug, reaction), '1'); + window.dispatchEvent(new Event(REACTED_EVENT)); + } catch { + // ignora — o POST ainda é enviado, só não persistimos o estado local. + } +}; + +/** Novo estado com o contador da reação incrementado (ou fixado em `exact`). */ +const bumpCount = ( + data: ReactionsResponse | undefined, + slug: string, + reaction: ReactionId, + exact?: number, +): ReactionsResponse => { + const reactions = data?.reactions ?? {}; + const section = reactions[slug] ?? {}; + const count = exact ?? (section[reaction] ?? 0) + 1; + + return { + reactions: { ...reactions, [slug]: { ...section, [reaction]: count } }, + }; +}; + +type ReactionButtonProps = { + likesId: string; + slug: string; + reaction: typeof REACTIONS[number]; + count: number; + onReact: (slug: string, reaction: ReactionId) => void; +}; + +const ReactionButton: FC = ({ + likesId, + slug, + reaction, + count, + onReact, +}) => { + const reacted = useSyncExternalStore( + subscribe, + () => readReacted(likesId, slug, reaction.id), + () => false, + ); + + return ( + + ); +}; + +type ReactionBarProps = { + likesId: string; + slug: string; + counts: ArticleReactions; + onReact: (slug: string, reaction: ReactionId) => void; +}; + +const ReactionBar: FC = ({ + likesId, + slug, + counts, + onReact, +}) => ( + + {REACTIONS.map((reaction) => ( + + ))} + +); + +type Anchor = { slug: string; container: HTMLElement }; + +const createReact = + ( + endpoint: string, + mutate: KeyedMutator, + ): ((slug: string, reaction: ReactionId) => void) => + (slug, reaction) => { + void mutate( + async (current) => { + const res = await fetch(endpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ slug, reaction }), + }); + const json: { count: number | null } = await res + .json() + .catch(() => ({ count: null })); + + // 429 / erro: mantém o otimista — mesma postura do widget de likes. + return bumpCount( + current, + slug, + reaction, + typeof json.count === 'number' ? json.count : undefined, + ); + }, + { + optimisticData: (current) => bumpCount(current, slug, reaction), + revalidate: false, + rollbackOnError: true, + }, + ); + }; + +/** + * Injeta uma barra de reações (👍 🔥 🤯) logo após cada h2 do artigo. + * Os slugs vêm dos ids reais do DOM (gerados pelo rehype-slug), e o POST é + * validado no servidor contra a whitelist de seções do artigo. + */ +export const SectionReactions: FC<{ likesId: string }> = ({ likesId }) => { + const endpoint = `/api/reactions/${likesId}`; + const [anchors, setAnchors] = useState([]); + + const { data, mutate } = useSWR(endpoint, jsonFetcher, { + revalidateOnFocus: false, + }); + + useEffect(() => { + const headings = document.querySelectorAll( + 'article.post h2[id]', + ); + + const created = Array.from(headings).map((heading) => { + const container = document.createElement('div'); + container.className = 'section-reactions mb-6 mt-2'; + heading.insertAdjacentElement('afterend', container); + return { slug: heading.id, container }; + }); + + // Leitura legítima do DOM pós-mount (padrão do TableOfContents). + // eslint-disable-next-line react-hooks/set-state-in-effect + setAnchors(created); + + return () => { + created.forEach(({ container }) => container.remove()); + setAnchors([]); + }; + }, []); + + // Desativado (sem Redis) ou ainda carregando — não renderiza nada. + if (!data?.reactions) return null; + + const onReact = createReact(endpoint, mutate); + + return anchors.map(({ slug, container }) => + createPortal( + , + container, + slug, + ), + ); +}; diff --git a/src/base/article/SectionReactions/index.tsx b/src/base/article/SectionReactions/index.tsx new file mode 100644 index 0000000..83e45ab --- /dev/null +++ b/src/base/article/SectionReactions/index.tsx @@ -0,0 +1 @@ +export { SectionReactions } from './SectionReactions'; diff --git a/src/features/articles/lib/articles.ts b/src/features/articles/lib/articles.ts index 671feb0..75f011f 100644 --- a/src/features/articles/lib/articles.ts +++ b/src/features/articles/lib/articles.ts @@ -1,6 +1,7 @@ import type { ImageProps } from 'next/image'; import { cache } from 'react'; +import GithubSlugger from 'github-slugger'; import readingTime from 'reading-time'; import { @@ -499,6 +500,75 @@ export function getArticleLikesIds(): Set { return ids; } +// Um heading em MDX: `#` a `######` seguidos de espaço e texto. +const HEADING_PATTERN = /^(#{1,6})\s+(.+?)\s*$/; +// Link markdown inline: mantemos só o texto ao gerar o slug. +const MD_LINK_PATTERN = /\[([^\]]*)\]\([^)]*\)/g; +// Marcadores inline que o render remove antes do rehype-slug slugificar. +const MD_INLINE_MARKS_PATTERN = /[`*_~]/g; + +/** + * Extrai os slugs dos h2 de um MDX, replicando os ids que o rehype-slug + * (github-slugger) gera no DOM. Todos os níveis de heading passam pelo + * slugger na ordem do documento para a contagem de duplicatas (`-1`, `-2`…) + * bater com a do render. Ignora linhas dentro de code fences (um comentário + * `## foo` num bloco de código não é seção). + */ +function extractSectionSlugs(content: string): string[] { + const slugger = new GithubSlugger(); + const slugs: string[] = []; + let insideFence = false; + + for (const line of content.split(/\r?\n/)) { + if (line.trimStart().startsWith('```')) { + insideFence = !insideFence; + continue; + } + if (insideFence) continue; + + const match = HEADING_PATTERN.exec(line); + if (!match) continue; + + const text = match[2] + .replace(MD_LINK_PATTERN, '$1') + .replace(MD_INLINE_MARKS_PATTERN, ''); + const slug = slugger.slug(text); + + // Só h2 recebe reações — são as seções do artigo. + if (match[1].length === 2 && slug) slugs.push(slug); + } + + return slugs; +} + +let _sectionSlugs: Map> | null = null; + +/** + * Slugs das seções (h2) de cada artigo publicado, indexados por likesId e + * somando todos os locales. A API de reações usa isto como whitelist: só + * aceita reações em seções que existem de verdade, bloqueando a criação de + * campos arbitrários no Redis. Memoizado — o conteúdo é estático (build). + */ +export function getArticleSectionSlugs(likesId: string): Set { + if (!_sectionSlugs) { + _sectionSlugs = new Map(); + for (const locale of Languages) { + for (const slug of getArticleSlugs(locale)) { + if (!hasArticleMetadata(slug, locale)) continue; + + const { content, metadata } = readArticleFile(slug, locale); + const existing = _sectionSlugs.get(metadata.likesId) ?? new Set(); + for (const sectionSlug of extractSectionSlugs(content)) { + existing.add(sectionSlug); + } + _sectionSlugs.set(metadata.likesId, existing); + } + } + } + + return _sectionSlugs.get(likesId) ?? new Set(); +} + export function getArticlePaths(locale: Locale = DEFAULT_ARTICLE_LOCALE) { return getArticleSlugs(locale).map((slug) => ({ params: { diff --git a/src/lib/reactions.ts b/src/lib/reactions.ts new file mode 100644 index 0000000..a0c1b03 --- /dev/null +++ b/src/lib/reactions.ts @@ -0,0 +1,23 @@ +/** + * Reações disponíveis por seção de artigo. Os ids são as chaves canônicas + * usadas no Redis e na API — o emoji é só apresentação. Não renomeie um id + * depois de publicado, ou as contagens já gravadas ficam órfãs. + */ +export const REACTIONS = [ + { id: 'up', emoji: '👍', label: 'Gostei desta seção' }, + { id: 'fire', emoji: '🔥', label: 'Seção excelente' }, + { id: 'mind', emoji: '🤯', label: 'Mente explodida' }, +] as const; + +export type ReactionId = typeof REACTIONS[number]['id']; + +export const REACTION_IDS = new Set(REACTIONS.map((r) => r.id)); + +export const isReactionId = (value: string): value is ReactionId => + REACTION_IDS.has(value); + +/** Contagens de uma seção: { up: 2, fire: 1, ... } (chaves ausentes = 0). */ +export type SectionCounts = Partial>; + +/** Contagens do artigo inteiro, indexadas pelo slug da seção. */ +export type ArticleReactions = Record; diff --git a/src/lib/redis.ts b/src/lib/redis.ts index a8f79b5..346bd5e 100644 --- a/src/lib/redis.ts +++ b/src/lib/redis.ts @@ -50,41 +50,81 @@ export const incrementLikes = async (id: string): Promise => { } }; -// Rate limit + proteção de tráfego dos likes — biblioteca oficial +// --- Reações por seção ------------------------------------------------------ +// Assim como os likes, as reações são DURÁVEIS: um hash por artigo, sem TTL. +// Campo do hash: `:` (slug nunca contém ':'). + +const REACTIONS_PREFIX = 'reactions:'; + +/** + * Lê todas as reações de um artigo como o hash cru + * (`{ ":": count }`). Vazio se inexistente ou sem Redis. + */ +export const getReactionFields = async ( + id: string, +): Promise> => { + const redis = getRedis(); + if (!redis) return {}; + try { + return ( + (await redis.hgetall>( + `${REACTIONS_PREFIX}${id}`, + )) ?? {} + ); + } catch { + return {}; + } +}; + +/** Incrementa atômico (HINCRBY) uma reação e retorna o novo total do campo. */ +export const incrementReaction = async ( + id: string, + field: string, +): Promise => { + const redis = getRedis(); + if (!redis) return 0; + try { + return await redis.hincrby(`${REACTIONS_PREFIX}${id}`, field, 1); + } catch { + return 0; + } +}; + +// Rate limit + proteção de tráfego (likes e reações) — biblioteca oficial // @upstash/ratelimit. Combina dois mecanismos automáticos (sem monitoramento): -// 1. Janela deslizante (30/60s por IP) — contém floods/abuso de volume. +// 1. Janela deslizante por IP — contém floods/abuso de volume. // 2. enableProtection — bloqueia IPs maliciosos da Auto IP Deny List do // Upstash (30+ listas de abuso open-source, atualizada diariamente). // analytics: registra permitidos/bloqueados no Ratelimit Dashboard // (console.upstash.com/ratelimit). Exige await pending para sincronizar. -// As chaves rl:likes:* SÃO efêmeras (geridas pela lib), ao contrário das de -// like. ephemeralCache bloqueia em memória um IP já barrado, sem ir ao Redis. -// fail-open: erro/ausência de Redis não bloqueia o fluxo de likes. -let _ratelimit: Ratelimit | null = null; +// As chaves rl:* SÃO efêmeras (geridas pela lib), ao contrário das de +// like/reação. ephemeralCache bloqueia em memória um IP já barrado, sem ir +// ao Redis. fail-open: erro/ausência de Redis não bloqueia o fluxo. +const _limiters = new Map(); -const getRateLimiter = (): Ratelimit | null => { - if (_ratelimit) return _ratelimit; +const getRateLimiter = (prefix: string, tokens: number): Ratelimit | null => { + const existing = _limiters.get(prefix); + if (existing) return existing; const redis = getRedis(); if (!redis) return null; - _ratelimit = new Ratelimit({ + const limiter = new Ratelimit({ redis, - limiter: Ratelimit.slidingWindow(30, '60 s'), - prefix: 'rl:likes', + limiter: Ratelimit.slidingWindow(tokens, '60 s'), + prefix, ephemeralCache: new Map(), enableProtection: true, analytics: true, }); - return _ratelimit; + _limiters.set(prefix, limiter); + return limiter; }; -/** - * True se o IP está liberado. Bloqueia (false) quando estoura a janela - * deslizante OU quando o IP está na Auto IP Deny List do Upstash. - */ -export const checkLikesRateLimit = async (ip: string): Promise => { - const limiter = getRateLimiter(); +const checkRateLimit = async ( + limiter: Ratelimit | null, + ip: string, +): Promise => { if (!limiter) return true; try { // O 1º arg (identifier) conta o rate por IP; o { ip } é checado contra a @@ -97,6 +137,20 @@ export const checkLikesRateLimit = async (ip: string): Promise => { } }; +/** + * True se o IP está liberado. Bloqueia (false) quando estoura a janela + * deslizante OU quando o IP está na Auto IP Deny List do Upstash. + */ +export const checkLikesRateLimit = (ip: string): Promise => + checkRateLimit(getRateLimiter('rl:likes', 30), ip); + +/** + * Rate limit das reações — janela maior que a dos likes porque um leitor + * legítimo pode reagir a várias seções (3 emojis × N seções) em sequência. + */ +export const checkReactionsRateLimit = (ip: string): Promise => + checkRateLimit(getRateLimiter('rl:reactions', 60), ip); + /** Lê um valor cacheado. Retorna null se inexistente ou se o Redis não estiver configurado. */ export const cacheGet = async (key: string): Promise => { const redis = getRedis(); diff --git a/src/lib/request.ts b/src/lib/request.ts new file mode 100644 index 0000000..84d859f --- /dev/null +++ b/src/lib/request.ts @@ -0,0 +1,10 @@ +/** + * IP do cliente para rate limiting. Na Vercel o x-forwarded-for é controlado + * pela plataforma (não spoofável); atrás de outro proxy, reavalie a confiança + * no primeiro valor da lista. + */ +export const getClientIp = (request: Request): string => { + const forwarded = request.headers.get('x-forwarded-for'); + if (forwarded) return forwarded.split(',')[0].trim(); + return request.headers.get('x-real-ip') ?? 'unknown'; +}; From 55e07cb9c052f01a9b824e41991c4fd944052919 Mon Sep 17 00:00:00 2001 From: John Enderson Date: Sun, 5 Jul 2026 21:53:50 -0300 Subject: [PATCH 2/3] chore: ideias de features, script de checagem do LoL e config de preview - docs/ideias.md: brainstorm de funcionalidades por esforco. - scripts/check-lol-live.mjs: mostra o que o spectator-v5 reporta durante uma partida (util para modos novos como ARAM: Desordem). - .claude/launch.json: config prod (next start) para o preview. Co-Authored-By: Claude Fable 5 --- .claude/launch.json | 15 ++++++ docs/ideias.md | 101 +++++++++++++++++++++++++++++++++++ next-env.d.ts | 2 +- scripts/check-lol-live.mjs | 104 +++++++++++++++++++++++++++++++++++++ 4 files changed, 221 insertions(+), 1 deletion(-) create mode 100644 docs/ideias.md create mode 100644 scripts/check-lol-live.mjs diff --git a/.claude/launch.json b/.claude/launch.json index 197235e..e15a68e 100644 --- a/.claude/launch.json +++ b/.claude/launch.json @@ -15,6 +15,21 @@ "PATH=/home/john/.nvm/versions/node/v22.15.0/bin:/usr/local/bin:/usr/bin:/bin yarn dev" ], "port": 3000 + }, + { + "name": "prod", + "runtimeExecutable": "wsl.exe", + "runtimeArgs": [ + "-d", + "Ubuntu", + "--cd", + "/home/john/Code/johnenderson.dev", + "--", + "bash", + "-c", + "PATH=/home/john/.nvm/versions/node/v22.15.0/bin:/usr/local/bin:/usr/bin:/bin yarn start" + ], + "port": 3000 } ] } diff --git a/docs/ideias.md b/docs/ideias.md new file mode 100644 index 0000000..67fefad --- /dev/null +++ b/docs/ideias.md @@ -0,0 +1,101 @@ +# Ideias de inovação para o site + +Brainstorm de funcionalidades que aproveitam a infra já existente (Upstash Redis, +integrações Last.fm/Spotify/Steam/Riot/GitHub, MDX server-side, OG images) ou +expandem a identidade do site. Organizado por esforço estimado. + +## Aproveitando o que já existe (esforço baixo) + +### 1. Trilha sonora do artigo ⭐ + +Ao publicar um artigo, salvar as faixas mais scrobbladas no Last.fm durante o +período de escrita e exibir no rodapé: _"escrito ao som de..."_. + +- Conecta as duas identidades do site (dev + músico enferrujado). +- Implementação: chamada ao Last.fm no build (`user.getrecenttracks` com + `from`/`to`) + campo opcional no frontmatter (`writingPeriod` ou as faixas + já resolvidas). +- Ninguém tem isso. + +### 2. Histórico do status ao vivo + +O `/api/status` já detecta jogando/ouvindo/codando, mas só mostra o instante. +Gravar snapshots no Redis (sorted set por dia) e renderizar uma timeline +tipo heatmap na `/now`: "minha semana real", com as sequências de LoL às 23h. + +- Infra: mesmo padrão de `cacheSet`, com chave `status:history:`. +- Cuidado com cardinalidade/cota do Upstash — agregar por hora basta. + +### 3. Reações por parágrafo + +Estender a infra de likes (Redis + rate limit + whitelist de ids) para reações +ancoradas em headings (`👍 🔥 🤯` por seção do artigo). + +- Descobre _qual parte_ do artigo ressoa, não só se o artigo agradou. +- Implementação: chave `reactions:::`, whitelist + gerada a partir dos slugs do rehype-slug no build. + +### 4. Stats públicos do site + +Página `/stats` estilo "open startup": views por artigo (contador no Redis, +mesmo padrão dos likes), likes totais, status das integrações. + +- Transparência combina com site de dev. +- Contador de views: `INCR` no Redis por rota, com dedupe simples por IP+dia. + +## Diferenciais de conteúdo (esforço médio) + +### 5. Grafo de conhecimento entre artigos ⭐ + +Wiki-links `[[assim]]` no MDX, resolvidos no build (o registry de conteúdo +gerado por script já existe), com um grafo navegável em `/blog`. + +- Já houve um `links-graph` na base (removido como código morto em jul/2026) — + a ideia claramente já rondava. A versão boa começa pelos dados (links entre + artigos), não pelo componente visual. +- Com poucos artigos o grafo é simples, e cresce junto com o site. + +### 6. Diagramas interativos nos artigos de infra + +Componente MDX de diagrama AWS/K8s onde hover explica cada peça — ou um +"simulador" (ex.: NodePool do Karpenter provisionando nós conforme você +adiciona pods). + +- Os artigos são de infra (Karpenter, Datadog): é onde diagrama interativo + mais agrega. +- O pipeline de componentes MDX customizados já está montado (Venn, + Admonition etc. como referência). + +### 7. Modo terminal (easter egg) + +Apertar `~` abre um terminal fake onde `ls blog/`, `cat uses`, `whoami`, +`cd now` funcionam e navegam o site. + +- Barato, memorável, e o público de blog de backend é exatamente quem curte. + +## Mais ousado (esforço alto) + +### 8. "Pergunte ao meu blog" ⭐ + +Busca semântica + Q&A sobre os artigos com a Claude API: embeddings dos MDX +gerados no build, endpoint com rate limit (infra pronta) e resposta em +streaming citando o artigo-fonte. + +- Rende um artigo por si só ("como construí um RAG do meu blog"). +- Atenção a custo: cache agressivo de respostas no Redis + rate limit por IP. + +### 9. Guestbook com GitHub OAuth + +Assinaturas de visitantes autenticados via GitHub (avatar + username), tudo +no Redis. + +- Autenticação controla spam de graça. +- NextAuth/Auth.js com provider GitHub resolve o OAuth. + +## Recomendação + +Top 3 pela relação impacto/custo e identidade do site: + +1. **Trilha sonora do artigo** (#1) — único, custo mínimo. +2. **Grafo de conhecimento** (#5) — efeito composto conforme o blog cresce. +3. **Pergunte ao meu blog** (#8) — o mais "inovador" e ainda vira conteúdo. diff --git a/next-env.d.ts b/next-env.d.ts index c4b7818..9edff1c 100644 --- a/next-env.d.ts +++ b/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/dev/types/routes.d.ts"; +import "./.next/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/scripts/check-lol-live.mjs b/scripts/check-lol-live.mjs new file mode 100644 index 0000000..98ccfd0 --- /dev/null +++ b/scripts/check-lol-live.mjs @@ -0,0 +1,104 @@ +/** + * Checagem ao vivo do LoL — rode DURANTE uma partida para ver o que a Riot + * API reporta (útil para conferir modos novos, ex.: ARAM: Desordem/Mayhem). + * + * node scripts/check-lol-live.mjs + * + * Lê RIOT_API_KEY e LOL_RIOT_ID do .env.local. Não imprime a API key. + */ +import fs from 'node:fs'; +import path from 'node:path'; + +const envPath = path.resolve(process.cwd(), '.env.local'); +const env = {}; +for (const line of fs.readFileSync(envPath, 'utf8').split('\n')) { + const m = /^([A-Z_]+)=(.*)$/.exec(line.trim()); + if (m) env[m[1]] = m[2].replace(/^["']|["']$/g, ''); +} + +const apiKey = env.RIOT_API_KEY; +const [gameName, tagLine] = (env.LOL_RIOT_ID ?? '').split('#'); +if (!apiKey || !gameName || !tagLine) { + console.error('RIOT_API_KEY ou LOL_RIOT_ID ausentes/inválidos no .env.local'); + process.exit(1); +} + +const REGIONAL = 'https://americas.api.riotgames.com'; +const PLATFORM = 'https://br1.api.riotgames.com'; + +// Espelho do QUEUE_LABELS de src/lib/lol.ts + tabela oficial +// https://static.developer.riotgames.com/docs/lol/queues.json +const QUEUE_LABELS = { + 400: 'Normal', + 420: 'Ranked Solo', + 430: 'Normal Blind', + 440: 'Ranked Flex', + 450: 'ARAM', + 480: 'Swiftplay', + 490: 'Quickplay', + 720: 'ARAM Clash', + 900: 'URF', + 1020: 'One for All', + 2400: 'ARAM: Desordem (Mayhem)', +}; + +const get = async (url) => { + const r = await fetch(url, { headers: { 'X-Riot-Token': apiKey } }); + if (r.status === 404) return null; + if (!r.ok) { + throw new Error(`HTTP ${r.status} em ${url.replaceAll(apiKey, '***')}`); + } + return r.json(); +}; + +const account = await get( + `${REGIONAL}/riot/account/v1/accounts/by-riot-id/${encodeURIComponent( + gameName, + )}/${encodeURIComponent(tagLine)}`, +); +if (!account) { + console.error(`Conta ${gameName}#${tagLine} não encontrada.`); + process.exit(1); +} +console.log(`Conta: ${account.gameName}#${account.tagLine}`); + +// 1) Partida ao vivo (spectator-v5) +const live = await get( + `${PLATFORM}/lol/spectator/v5/active-games/by-summoner/${account.puuid}`, +); +if (live) { + const label = QUEUE_LABELS[live.gameQueueConfigId] ?? '(fila não mapeada)'; + console.log('\n🎮 EM PARTIDA AGORA:'); + console.log(` gameMode = ${live.gameMode}`); + console.log(` gameQueueConfigId = ${live.gameQueueConfigId} → ${label}`); + console.log(` duração = ${Math.floor(live.gameLength / 60)}min`); + if (!QUEUE_LABELS[live.gameQueueConfigId]) { + console.log( + ' ⚠️ fila não está no QUEUE_LABELS de src/lib/lol.ts — vale adicionar.', + ); + } +} else { + console.log('\nNão está em partida agora (spectator retornou 404).'); +} + +// 2) Últimas partidas no histórico (match-v5) — para comparar depois do jogo +const ids = + (await get( + `${REGIONAL}/lol/match/v5/matches/by-puuid/${account.puuid}/ids?count=3`, + )) ?? []; +console.log('\nÚltimas 3 partidas no match-v5 (histórico):'); +for (const id of ids) { + const m = await get(`${REGIONAL}/lol/match/v5/matches/${id}`); + if (!m) continue; + const label = QUEUE_LABELS[m.info.queueId] ?? '(fila não mapeada)'; + const end = new Date(m.info.gameEndTimestamp).toISOString().replace('T', ' ').slice(0, 16); + console.log( + ` ${id} queueId=${m.info.queueId} (${label}) mode=${m.info.gameMode} fim=${end}`, + ); +} +console.log( + '\nDica: se a partida ao vivo apareceu acima mas nunca entra no histórico,', +); +console.log( + 'a Riot ainda não indexou esse modo no match-v5 (comum em modos recém-lançados).', +); From 48bfefd000e09415301c72305460636f1781bb8a Mon Sep 17 00:00:00 2001 From: John Enderson Date: Sat, 1 Aug 2026 21:17:26 -0300 Subject: [PATCH 3/3] feat(ui): padroniza cabecalhos de secao e reduz a escala tipografica Antes existiam quatro padroes de cabecalho: home (selo 36px + titulo 24px), /now (selo 48px + titulo 36px + subtitulo com recuo pendurado), /me (titulo sem icone) e /uses (rotulo lateral em maiusculas). Agora ha um so. - SectionHeader compartilhado: selo do icone + titulo + subtitulo opcional + acao a direita; usado pelas 18 secoes de home, /now, /me e /uses - SectionIcon promovido de /now para src/base, com tamanho unico - escala tipografica: corpo 17px -> 16px e titulo de pagina 48px -> 36px, fechando a hierarquia h1 36 > h2 24 > h3 18 > h4 14 (h1 e h2 estavam empatados em 36px) - links de acao das secoes (@usuario, Ver --- app/me/page.tsx | 84 ++++--- app/now/page.tsx | 236 ++++++++---------- app/uses/page.tsx | 62 ++++- src/base/components/Navbar/Navbar.tsx | 43 +++- .../components/Navbar/PreferencesPanel.tsx | 2 +- src/base/components/PageTitle/PageTitle.tsx | 4 +- .../SectionHeader/SectionHeader.tsx | 55 ++++ src/base/components/SectionHeader/index.tsx | 1 + .../components/SectionIcon/SectionIcon.tsx | 11 + src/base/components/SectionIcon/index.tsx | 1 + .../components/ArticlesList/ArticlesList.tsx | 43 ++-- .../home/components/Activity/LastfmCard.tsx | 26 +- .../home/components/DevPulse/DevPulse.tsx | 32 +-- .../components/Projects/ProjectsShowcase.tsx | 45 ++-- src/features/now/components/ArtistCard.tsx | 2 +- src/features/now/components/FeaturedTrack.tsx | 2 +- src/features/now/components/SectionIcon.tsx | 7 - src/features/now/components/index.ts | 2 +- styles/globals.css | 13 +- 19 files changed, 413 insertions(+), 258 deletions(-) create mode 100644 src/base/components/SectionHeader/SectionHeader.tsx create mode 100644 src/base/components/SectionHeader/index.tsx create mode 100644 src/base/components/SectionIcon/SectionIcon.tsx create mode 100644 src/base/components/SectionIcon/index.tsx delete mode 100644 src/features/now/components/SectionIcon.tsx diff --git a/app/me/page.tsx b/app/me/page.tsx index fef5a56..2abd1e6 100644 --- a/app/me/page.tsx +++ b/app/me/page.tsx @@ -8,12 +8,16 @@ import { faCode, faGraduationCap, faHandshake, + faHeadphones, + faIdCard, + faUser, } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import type { Metadata } from 'next'; import { Card } from '@/base/components/Card'; import { PageTitle } from '@/base/components/PageTitle'; +import { SectionHeader } from '@/base/components/SectionHeader'; import { TagList } from '@/features/about/components'; import { getGithubLanguages, getGithubUsername } from '@/lib/github'; import { getLastfmTopTags } from '@/lib/lastfm'; @@ -113,16 +117,18 @@ export default async function Page() {
-
-

+

+ title="Em poucas palavras" + />
    {tldrCards.map((card) => ( @@ -167,14 +173,19 @@ export default async function Page() {
    - + title="Pessoalmente" + />

    Oi, eu sou o John!

    @@ -250,18 +261,20 @@ export default async function Page() {
    - -

    - As linguagens que mais aparecem nos meus repositórios - públicos. -

    + title="Stacks que eu codo" + subtitle="As linguagens que mais aparecem nos meus repositórios públicos." + /> ({ label: language.name, @@ -279,17 +292,20 @@ export default async function Page() {
    - -

    - Os gêneros e tags que dominam o que tenho ouvido no Last.fm. -

    + title="O que ando curtindo" + subtitle="Os gêneros e tags que dominam o que tenho ouvido no Last.fm." + /> ({ diff --git a/app/now/page.tsx b/app/now/page.tsx index 8b5c3e8..02b71ce 100644 --- a/app/now/page.tsx +++ b/app/now/page.tsx @@ -12,6 +12,10 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import type { Metadata } from 'next'; import { PageTitle } from '@/base/components/PageTitle'; +import { + SECTION_ACTION_CLASS, + SectionHeader, +} from '@/base/components/SectionHeader'; import { ActivityFeed, ArtistCard, @@ -25,7 +29,6 @@ import { LolRankedCard, RadarCard, RecentTrack, - SectionIcon, StarredRepos, } from '@/features/now/components'; import { getGithubDev, getGithubStarred } from '@/lib/github'; @@ -85,7 +88,7 @@ const HeadphonesIcon = () => (
    -

    - Radar atual -

    -

    - {RADAR_DESCRIPTION} -

    -
    - +
    )} @@ -281,36 +274,34 @@ export default async function NowPage() { aria-labelledby="listening-title" className="border-b border-site-border-subtle py-16" > -
    - - - -
    -

    } + id="listening-title" + title="No repeat do mês" + subtitle={LISTENING_DESCRIPTION} + action={ + - No repeat da semana -

    -

    - {LISTENING_DESCRIPTION} -

    -
    -
    + Ver no Last.fm → + + } + />
    -
    -

    - Trilha da semana -

    -
    +

    + Trilha do mês +

    {featuredTrack ? (
    @@ -318,14 +309,12 @@ export default async function NowPage() {
    ) : null} -
    -

    - Mais recentes -

    -
    +

    + Mais recentes +

    {recentTrackList.length > 0 ? (
      {recentTrackList.slice(0, 4).map((track, index) => ( @@ -348,14 +337,12 @@ export default async function NowPage() {
    -
    -

    - Companhia da semana -

    -
    +

    + Companhia do mês +

    {artists.length > 0 ? ( <>
    @@ -367,34 +354,19 @@ export default async function NowPage() { /> ))}
    -
    -

    - - Imagens via Spotify -

    - - Ver no Last.fm -
    +

    + + Imagens via Spotify +

    ) : (

    - Sem artistas da semana para mostrar. + Sem artistas do mês para mostrar.

    )}
    @@ -402,22 +374,18 @@ export default async function NowPage() {
    -
    - - - -
    -

    - Provável recaída -

    -

    - {PLAYING_DESCRIPTION} -

    -
    -
    +