diff --git a/web-next/app/[locale]/forum/topic/[topicId]/page.tsx b/web-next/app/[locale]/forum/topic/[topicId]/page.tsx index 004f948..56b3a44 100644 --- a/web-next/app/[locale]/forum/topic/[topicId]/page.tsx +++ b/web-next/app/[locale]/forum/topic/[topicId]/page.tsx @@ -10,6 +10,7 @@ import { getAccountCharactersForForum, getForumCharacterPick, } from '@/lib/forum' +import { getPostsSocial, REPORT_REASONS } from '@/lib/forum-social' import { formatForumDate } from '@/lib/forum-format' import { localizeWowheadLinks } from '@/lib/forum-wowhead' import { getSession } from '@/lib/session' @@ -20,6 +21,7 @@ import { ForumBreadcrumb } from '@/components/ForumBreadcrumb' import { Pagination } from '@/components/Pagination' import { ReplyForm } from '@/components/ReplyForm' import { ForumCharacterPicker } from '@/components/ForumCharacterPicker' +import { PostActions } from '@/components/PostActions' import { EditPostForm } from '@/components/EditPostForm' import { ForumModActions } from '@/components/ForumModActions' import { WowheadRefresh } from '@/components/WowheadRefresh' @@ -61,6 +63,32 @@ export default async function TopicPage({ await Promise.all(uniquePosters.map(async (pid) => [pid, await getUserPostCount(pid)] as const)), ) + const social = await getPostsSocial(posts.map((p) => p.id), session.accountId ?? 0) + const REASON_DESC: Record = { + troll: t('rTrollDesc'), threat: t('rThreatDesc'), username: t('rUsernameDesc'), + offtopic: t('rOfftopicDesc'), inappropriate: t('rInappropriateDesc'), spam: t('rSpamDesc'), + violence: t('rViolenceDesc'), child: t('rChildDesc'), illegal: t('rIllegalDesc'), + } + const REASON_LABEL: Record = { + troll: t('rTroll'), threat: t('rThreat'), username: t('rUsername'), offtopic: t('rOfftopic'), + inappropriate: t('rInappropriate'), spam: t('rSpam'), violence: t('rViolence'), + child: t('rChild'), illegal: t('rIllegal'), + } + const reportReasons = REPORT_REASONS.map((k) => ({ key: k, label: REASON_LABEL[k] || k, desc: REASON_DESC[k] || '' })) + const paLabels = { + like: t('like'), copyLink: t('copyLink'), copied: t('copied'), report: t('report'), + bookmark: t('bookmark'), bookmarked: t('bookmarked'), loginRequired: t('loginToPost'), + reportTitle: t('reportTitle'), reportIntro: t('reportIntro'), reportDetail: t('reportDetail'), + reportSubmit: t('reportSubmit'), reportSent: t('reportSent'), + reminderTitle: t('reminderTitle'), savedBookmark: t('savedBookmark'), + remind2h: t('remind2h'), remindTomorrow: t('remindTomorrow'), remind3days: t('remind3days'), remindMore: t('remindMore'), + editTitle: t('editBookmark'), notePlaceholder: t('notePlaceholder'), save: t('save'), cancel: t('cancel'), + remove: t('removeBookmark'), reminderLabel: t('reminderLabel'), rNone: t('rNone'), + rTomorrow: t('rmTomorrow'), rNextWeek: t('rmNextWeek'), rNextMonth: t('rmNextMonth'), rCustom: t('rmCustom'), + } + const paCanAct = Boolean(session.accountId) + const paLoginHref = `/${locale}/my-account` + const [forumChars, forumPick] = session.accountId ? await Promise.all([getAccountCharactersForForum(session.accountId), getForumCharacterPick(session.accountId)]) : [[], null] @@ -110,7 +138,7 @@ export default async function TopicPage({ const author = posters.get(post.poster) const isStaff = author?.isStaff ?? false return ( -
+
@@ -176,6 +204,22 @@ export default async function TopicPage({
    +
  • + +
  • {formatForumDate(post.time, locale)}
  • {canEditPost(session, isMod, post.poster) && (
  • diff --git a/web-next/app/api/forum/bookmark-reminder/route.ts b/web-next/app/api/forum/bookmark-reminder/route.ts new file mode 100644 index 0000000..0468816 --- /dev/null +++ b/web-next/app/api/forum/bookmark-reminder/route.ts @@ -0,0 +1,23 @@ +import { type NextRequest, NextResponse } from 'next/server' +import { getSession } from '@/lib/session' +import { updateBookmark } from '@/lib/forum-social' + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' + +/** Guarda nota + recordatorio del marcador (crea el marcador si no existía). */ +export async function POST(req: NextRequest) { + const session = await getSession() + const accountId = session.accountId ?? 0 + if (!accountId) return NextResponse.json({ ok: false }, { status: 401 }) + let postId = 0, note = '', remindAt: number | null = null + try { + const b = await req.json() + postId = Number(b?.postId) || 0 + note = String(b?.note || '') + remindAt = b?.remindAt ? Number(b.remindAt) : null + } catch { /* */ } + if (!postId) return NextResponse.json({ ok: false }, { status: 400 }) + const ok = await updateBookmark(accountId, postId, note, remindAt) + return NextResponse.json({ ok, bookmarked: true, note, remindAt }, { status: ok ? 200 : 400 }) +} diff --git a/web-next/app/api/forum/bookmark/route.ts b/web-next/app/api/forum/bookmark/route.ts new file mode 100644 index 0000000..ff15514 --- /dev/null +++ b/web-next/app/api/forum/bookmark/route.ts @@ -0,0 +1,17 @@ +import { type NextRequest, NextResponse } from 'next/server' +import { getSession } from '@/lib/session' +import { togglePostBookmark } from '@/lib/forum-social' + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' + +export async function POST(req: NextRequest) { + const session = await getSession() + const accountId = session.accountId ?? 0 + if (!accountId) return NextResponse.json({ ok: false }, { status: 401 }) + let postId = 0 + try { postId = Number((await req.json())?.postId) || 0 } catch { /* */ } + if (!postId) return NextResponse.json({ ok: false }, { status: 400 }) + const r = await togglePostBookmark(accountId, postId) + return NextResponse.json({ ok: true, ...r }) +} diff --git a/web-next/app/api/forum/like/route.ts b/web-next/app/api/forum/like/route.ts new file mode 100644 index 0000000..29f7196 --- /dev/null +++ b/web-next/app/api/forum/like/route.ts @@ -0,0 +1,17 @@ +import { type NextRequest, NextResponse } from 'next/server' +import { getSession } from '@/lib/session' +import { togglePostLike } from '@/lib/forum-social' + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' + +export async function POST(req: NextRequest) { + const session = await getSession() + const accountId = session.accountId ?? 0 + if (!accountId) return NextResponse.json({ ok: false }, { status: 401 }) + let postId = 0 + try { postId = Number((await req.json())?.postId) || 0 } catch { /* */ } + if (!postId) return NextResponse.json({ ok: false }, { status: 400 }) + const r = await togglePostLike(accountId, postId) + return NextResponse.json({ ok: true, ...r }) +} diff --git a/web-next/app/api/forum/report/route.ts b/web-next/app/api/forum/report/route.ts new file mode 100644 index 0000000..02f3f42 --- /dev/null +++ b/web-next/app/api/forum/report/route.ts @@ -0,0 +1,23 @@ +import { type NextRequest, NextResponse } from 'next/server' +import { getSession } from '@/lib/session' +import { reportPost } from '@/lib/forum-social' + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' + +export async function POST(req: NextRequest) { + const session = await getSession() + const accountId = session.accountId ?? 0 + if (!accountId) return NextResponse.json({ ok: false }, { status: 401 }) + let postId = 0, reason = '', detail = '' + try { + const b = await req.json() + postId = Number(b?.postId) || 0 + reason = String(b?.reason || '') + detail = String(b?.detail || '') + } catch { /* */ } + if (!postId || !reason) return NextResponse.json({ ok: false }, { status: 400 }) + const now = Math.floor(Date.now() / 1000) + const ok = await reportPost(accountId, postId, reason, detail, now) + return NextResponse.json({ ok }, { status: ok ? 200 : 400 }) +} diff --git a/web-next/app/forum.css b/web-next/app/forum.css index eec4724..72ef227 100644 --- a/web-next/app/forum.css +++ b/web-next/app/forum.css @@ -338,3 +338,75 @@ a.nice_button:focus { .forum-charpick-name { font-weight: 700; font-size: 15px; } .forum-charpick-sub { color: #9a948a; font-size: 12px; } .forum-charpick-chk { color: #4ade5f; margin-left: auto; } + +/* ===== Barra de acciones de publicación ===== */ +.post_actions { display: inline-flex; align-items: center; gap: 4px; position: relative; } +.pa-btn { position: relative; display: inline-flex; align-items: center; gap: 5px; background: transparent; + border: 1px solid transparent; color: #8a847a; border-radius: 6px; padding: 5px 8px; cursor: pointer; + font-family: inherit; font-size: 14px; transition: color .12s, background .12s, border-color .12s; } +.pa-btn:hover { color: #e6ddc9; background: #201c16; } +.pa-btn.liked { color: #e0555f; } +.pa-btn.liked:hover { color: #ff6b74; } +.pa-btn.marked { color: #c8a24c; } +.pa-count { font-size: 13px; font-weight: 700; } +.pa-remind-dot { font-size: 9px; margin-left: 2px; color: #c8a24c; } +.pa-toast { position: absolute; bottom: calc(100% + 6px); left: 50%; transform: translateX(-50%); + background: #0d0b08; border: 1px solid #c8a24c; color: #e6b53c; font-size: 11px; padding: 3px 8px; + border-radius: 5px; white-space: nowrap; z-index: 20; } + +/* Popover de recordatorio */ +.pa-bookmark-wrap { position: relative; } +.pa-reminder-pop { position: absolute; top: calc(100% + 6px); right: 0; z-index: 40; width: 220px; + background: #14110c; border: 1px solid #c8a24c; border-radius: 10px; box-shadow: 0 10px 30px rgba(0,0,0,.6); + padding: 6px; display: flex; flex-direction: column; } +.pa-reminder-head { display: flex; align-items: center; gap: 7px; color: #4ade5f; font-weight: 700; font-size: 13px; padding: 8px 8px 6px; } +.pa-reminder-q { color: #e6ddc9; font-weight: 700; font-size: 13px; padding: 4px 8px 8px; border-bottom: 1px solid #2b2620; } +.pa-reminder-pop button { text-align: left; background: transparent; border: 0; color: #d9c9a0; font-family: inherit; + font-size: 13px; padding: 9px 8px; border-radius: 6px; cursor: pointer; } +.pa-reminder-pop button:hover { background: #241f19; color: #fff; } +.pa-reminder-more { color: #c8a24c !important; } + +/* Modales (denuncia / editar marcador) */ +.pa-overlay { position: fixed; inset: 0; z-index: 1000; background: rgba(0,0,0,.65); + display: flex; align-items: center; justify-content: center; padding: 20px; } +.pa-modal { width: 100%; max-width: 480px; max-height: 85vh; overflow-y: auto; background: #14110c; + border: 1px solid #c8a24c; border-radius: 12px; box-shadow: 0 12px 40px rgba(0,0,0,.6); padding: 18px 20px; } +.pa-modal-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; margin-bottom: 12px; } +.pa-modal-head h3 { margin: 0; font-size: 19px; font-weight: 800; color: #e6b53c; line-height: 1.25; } +.pa-close { background: #241f19; border: 1px solid #3a342b; color: #d9c9a0; width: 32px; height: 32px; flex: 0 0 auto; + border-radius: 6px; cursor: pointer; } +.pa-close:hover { border-color: #c8a24c; color: #fff; } +.pa-modal-intro { color: #9a948a; font-size: 13px; margin: 0 0 12px; } +.pa-reasons { display: flex; flex-direction: column; gap: 4px; } +.pa-reason { display: flex; gap: 10px; align-items: flex-start; padding: 9px 10px; border-radius: 8px; cursor: pointer; + border: 1px solid transparent; } +.pa-reason:hover { background: #1b1811; } +.pa-reason.active { background: #241f19; border-color: #c8a24c; } +.pa-reason input { margin-top: 4px; accent-color: #c8a24c; } +.pa-reason b { color: #e6ddc9; font-size: 14px; display: block; } +.pa-reason em { color: #8a847a; font-size: 12px; font-style: normal; display: block; margin-top: 1px; } +.pa-report-detail, .pa-note { width: 100%; background: #17140f; border: 1px solid #3a342b; color: #e6ddc9; + border-radius: 8px; padding: 9px 11px; font-family: inherit; font-size: 14px; margin-top: 12px; resize: vertical; } +.pa-report-detail:focus, .pa-note:focus { outline: none; border-color: #c8a24c; } +.pa-modal-foot { display: flex; align-items: center; gap: 10px; margin-top: 16px; } +.pa-primary { display: inline-flex; align-items: center; gap: 7px; background: #c8a24c; color: #1a1710; border: 0; + border-radius: 7px; padding: 9px 16px; font-weight: 700; font-family: inherit; cursor: pointer; } +.pa-primary:hover { background: #d9b45e; } +.pa-primary:disabled { opacity: .5; cursor: default; } +.pa-ghost { background: transparent; border: 0; color: #9a948a; font-family: inherit; cursor: pointer; padding: 9px 6px; } +.pa-ghost:hover { color: #e6ddc9; } +.pa-danger { margin-left: auto; background: #241f19; border: 1px solid #6b2f2f; color: #e0777a; width: 38px; height: 38px; + border-radius: 7px; cursor: pointer; } +.pa-danger:hover { border-color: #d9534f; color: #fff; } +.pa-sent { color: #4ade5f; font-size: 15px; padding: 16px 4px; display: flex; align-items: center; gap: 8px; } +.pa-reminder-label { color: #c8a24c; font-weight: 700; font-size: 13px; margin: 14px 0 8px; } +.pa-reminder-rows { display: flex; flex-direction: column; gap: 4px; } +.pa-reminder-rows button { display: flex; justify-content: space-between; align-items: center; gap: 10px; + background: #17140f; border: 1px solid #2b2620; color: #d9c9a0; border-radius: 7px; padding: 9px 12px; + font-family: inherit; font-size: 13px; cursor: pointer; } +.pa-reminder-rows button:hover { border-color: #c8a24c; color: #fff; } +.pa-reminder-rows button.active { border-color: #c8a24c; background: #241f19; } +.pa-reminder-rows button span { color: #8a847a; font-size: 12px; } +.pa-custom { display: flex; flex-direction: column; gap: 5px; color: #9a948a; font-size: 12px; margin-top: 10px; } +.pa-custom input { background: #17140f; border: 1px solid #3a342b; color: #e6ddc9; border-radius: 7px; padding: 8px 10px; font-family: inherit; } +.pa-remind-current { margin-top: 10px; color: #e6b53c; font-size: 13px; display: flex; align-items: center; gap: 7px; } diff --git a/web-next/components/PostActions.tsx b/web-next/components/PostActions.tsx new file mode 100644 index 0000000..659f6c0 --- /dev/null +++ b/web-next/components/PostActions.tsx @@ -0,0 +1,320 @@ +'use client' + +import { useState } from 'react' + +type Reason = { key: string; label: string; desc: string } + +export type PostActionLabels = { + like: string + copyLink: string + copied: string + report: string + bookmark: string + bookmarked: string + loginRequired: string + reportTitle: string + reportIntro: string + reportDetail: string + reportSubmit: string + reportSent: string + reminderTitle: string + savedBookmark: string + remind2h: string + remindTomorrow: string + remind3days: string + remindMore: string + editTitle: string + notePlaceholder: string + save: string + cancel: string + remove: string + reminderLabel: string + rNone: string + rTomorrow: string + rNextWeek: string + rNextMonth: string + rCustom: string +} + +const at8am = (daysAhead: number) => { + const d = new Date() + d.setDate(d.getDate() + daysAhead) + d.setHours(8, 0, 0, 0) + return Math.floor(d.getTime() / 1000) +} + +export function PostActions({ + postId, + permalink, + canAct, + loginHref, + initialLikes, + initialLiked, + initialBookmarked, + initialNote, + initialRemindAt, + locale, + reasons, + labels, +}: { + postId: number + permalink: string + canAct: boolean + loginHref: string + initialLikes: number + initialLiked: boolean + initialBookmarked: boolean + initialNote: string + initialRemindAt: number | null + locale: string + reasons: Reason[] + labels: PostActionLabels +}) { + const [likes, setLikes] = useState(initialLikes) + const [liked, setLiked] = useState(initialLiked) + const [bookmarked, setBookmarked] = useState(initialBookmarked) + const [note, setNote] = useState(initialNote) + const [remindAt, setRemindAt] = useState(initialRemindAt) + const [copied, setCopied] = useState(false) + const [busy, setBusy] = useState(false) + + const [reportOpen, setReportOpen] = useState(false) + const [reportReason, setReportReason] = useState('') + const [reportDetail, setReportDetail] = useState('') + const [reportSent, setReportSent] = useState(false) + + const [remindPop, setRemindPop] = useState(false) + const [editOpen, setEditOpen] = useState(false) + + const requireLogin = () => { + window.location.href = loginHref + } + + const toggleLike = async () => { + if (!canAct) return requireLogin() + if (busy) return + setBusy(true) + try { + const res = await fetch('/api/forum/like', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ postId }), + }) + const d = await res.json() + if (d.ok) { + setLiked(d.liked) + setLikes(d.likes) + } + } finally { + setBusy(false) + } + } + + const copyLink = async () => { + const url = `${window.location.origin}${permalink}` + try { + await navigator.clipboard.writeText(url) + } catch { + /* sin clipboard API */ + } + setCopied(true) + setTimeout(() => setCopied(false), 1500) + } + + const toggleBookmark = async () => { + if (!canAct) return requireLogin() + if (busy) return + setBusy(true) + try { + const res = await fetch('/api/forum/bookmark', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ postId }), + }) + const d = await res.json() + if (d.ok) { + setBookmarked(d.bookmarked) + if (d.bookmarked) setRemindPop(true) + else { + setNote('') + setRemindAt(null) + } + } + } finally { + setBusy(false) + } + } + + const saveReminder = async (when: number | null, closeAll = true) => { + setBusy(true) + try { + const res = await fetch('/api/forum/bookmark-reminder', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ postId, note, remindAt: when }), + }) + const d = await res.json() + if (d.ok) { + setBookmarked(true) + setRemindAt(when) + if (closeAll) { + setRemindPop(false) + setEditOpen(false) + } + } + } finally { + setBusy(false) + } + } + + const submitReport = async () => { + if (!reportReason || busy) return + setBusy(true) + try { + const res = await fetch('/api/forum/report', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ postId, reason: reportReason, detail: reportDetail }), + }) + const d = await res.json() + if (d.ok) { + setReportSent(true) + setTimeout(() => { + setReportOpen(false) + setReportSent(false) + setReportReason('') + setReportDetail('') + }, 1200) + } + } finally { + setBusy(false) + } + } + + const fmtRemind = (ts: number) => new Date(ts * 1000).toLocaleString(locale, { day: '2-digit', month: 'short', hour: '2-digit', minute: '2-digit' }) + + return ( +
    + + + + + + +
    + + + {remindPop && ( +
    +
    + {labels.savedBookmark} +
    +
    {labels.reminderTitle}
    + + + + +
    + )} +
    + + {/* Modal denuncia */} + {reportOpen && ( +
    setReportOpen(false)}> +
    e.stopPropagation()}> +
    +

    {labels.reportTitle}

    + +
    + {reportSent ? ( +

    {labels.reportSent}

    + ) : ( + <> +

    {labels.reportIntro}

    +
    + {reasons.map((r) => ( + + ))} +
    +