From da138abe151c930ed25ac26728b2eb852c8241da Mon Sep 17 00:00:00 2001 From: victorSauceda Date: Thu, 18 Dec 2025 20:26:59 -0600 Subject: [PATCH 1/4] fix iframe usage of copy --- src/app/exicon/[entryId]/page.tsx | 2 +- src/app/exicon/page.tsx | 2 +- src/app/lexicon/[entryId]/page.tsx | 2 +- src/app/lexicon/page.tsx | 2 +- src/lib/clipboard.ts | 76 +++++++++++++++++++++++++----- src/lib/route-utils.ts | 2 +- 6 files changed, 70 insertions(+), 16 deletions(-) diff --git a/src/app/exicon/[entryId]/page.tsx b/src/app/exicon/[entryId]/page.tsx index 67dc167..89b1545 100644 --- a/src/app/exicon/[entryId]/page.tsx +++ b/src/app/exicon/[entryId]/page.tsx @@ -48,7 +48,7 @@ export async function generateMetadata({ const description = exiconEntry.description || `Learn about the ${exiconEntry.name} exercise in the F3 Exicon.`; - const url = `https://codex.f3nation.com/exicon/${entryId}`; + const url = `https://f3nation.com/exicon/${entryId}`; const tags = exiconEntry.tags?.map((tag) => tag.name).join(", ") || ""; return { diff --git a/src/app/exicon/page.tsx b/src/app/exicon/page.tsx index 884f38c..af8b677 100644 --- a/src/app/exicon/page.tsx +++ b/src/app/exicon/page.tsx @@ -27,7 +27,7 @@ export async function generateMetadata({ const description = entry.description || `Learn about the ${entry.name} exercise in the F3 Exicon.`; - const url = `https://codex.f3nation.com/exicon?entryId=${entryId}`; + const url = `https://f3nation.com/exicon?entryId=${entryId}`; const tags = entry.tags?.map((tag) => tag.name).join(", ") || ""; return { diff --git a/src/app/lexicon/[entryId]/page.tsx b/src/app/lexicon/[entryId]/page.tsx index 0c91d4a..90d1c5b 100644 --- a/src/app/lexicon/[entryId]/page.tsx +++ b/src/app/lexicon/[entryId]/page.tsx @@ -42,7 +42,7 @@ export async function generateMetadata({ const description = lexiconEntry.description || `Learn about ${lexiconEntry.name} in the F3 Lexicon.`; - const url = `https://codex.f3nation.com/lexicon/${entryId}`; + const url = `https://f3nation.com/lexicon/${entryId}`; return { title, diff --git a/src/app/lexicon/page.tsx b/src/app/lexicon/page.tsx index 4d37b04..dfdd108 100644 --- a/src/app/lexicon/page.tsx +++ b/src/app/lexicon/page.tsx @@ -27,7 +27,7 @@ export async function generateMetadata({ const title = `${entry.name} - F3 Lexicon`; const description = entry.description || `Learn about ${entry.name} in the F3 Lexicon.`; - const url = `https://codex.f3nation.com/lexicon?entryId=${entryId}`; + const url = `https://f3nation.com/lexicon?entryId=${entryId}`; return { title, diff --git a/src/lib/clipboard.ts b/src/lib/clipboard.ts index e48898a..15d37a9 100644 --- a/src/lib/clipboard.ts +++ b/src/lib/clipboard.ts @@ -9,14 +9,19 @@ export interface CopyResult { * for iframe and cross-origin contexts */ export async function copyToClipboard(text: string): Promise { + + // Strategy 1: Modern Clipboard API (works in most contexts) if (navigator.clipboard && window.isSecureContext) { try { await navigator.clipboard.writeText(text); + return { success: true, method: "clipboard" }; } catch (error) { - console.warn("Clipboard API failed:", error); + console.warn("[Clipboard] Clipboard API failed:", error); } + } else { + console.log("[Clipboard] Clipboard API not available or not secure context"); } // Strategy 2: Textarea fallback (works in iframes) @@ -41,10 +46,13 @@ export async function copyToClipboard(text: string): Promise { document.body.removeChild(textArea); if (successful) { + console.log("[Clipboard] Success via TextArea method"); return { success: true, method: "textArea" }; + } else { + console.warn("[Clipboard] TextArea execCommand returned false"); } } catch (error) { - console.warn("TextArea copy failed:", error); + console.warn("[Clipboard] TextArea copy failed:", error); } // Strategy 3: Selection API fallback @@ -73,15 +81,19 @@ export async function copyToClipboard(text: string): Promise { document.body.removeChild(span); if (successful) { + console.log("[Clipboard] Success via Selection API"); return { success: true, method: "selection" }; + } else { + console.warn("[Clipboard] Selection execCommand returned false"); } } document.body.removeChild(span); } catch (error) { - console.warn("Selection copy failed:", error); + console.warn("[Clipboard] Selection copy failed:", error); } + console.error("[Clipboard] All copy methods failed"); return { success: false, method: "fallback", @@ -109,13 +121,19 @@ export function showCopyPrompt(text: string): void { const overlay = document.createElement("div"); overlay.style.cssText = ` position: fixed; - inset: 0; + top: 0 !important; + left: 0 !important; + right: 0 !important; + bottom: 0 !important; + width: 100% !important; + height: 100% !important; background: rgba(0, 0, 0, 0.8); - z-index: 10000; + z-index: 999999; display: flex; align-items: center; justify-content: center; padding: 20px; + box-sizing: border-box; `; const modal = document.createElement("div"); @@ -125,28 +143,34 @@ export function showCopyPrompt(text: string): void { padding: 24px; max-width: 500px; width: 100%; + max-height: 90vh; + overflow-y: auto; box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25); + position: relative; + box-sizing: border-box; `; const textArea = document.createElement("textarea"); textArea.value = text; textArea.style.cssText = ` width: 100%; - height: 60px; + min-height: 80px; + max-height: 200px; margin: 16px 0; padding: 8px; border: 1px solid #ccc; border-radius: 4px; font-family: monospace; font-size: 14px; - resize: none; + resize: vertical; + box-sizing: border-box; `; textArea.readOnly = true; modal.innerHTML = ` -

Copy Link

+

Copy Content

- Please copy the link below manually: + Automatic copy failed. Please use the buttons below to copy manually:

`; @@ -158,11 +182,40 @@ export function showCopyPrompt(text: string): void { margin-top: 16px; `; + const copyButton = document.createElement("button"); + copyButton.textContent = "Copy"; + copyButton.style.cssText = ` + padding: 8px 16px; + background: #007bff; + color: white; + border: none; + border-radius: 4px; + cursor: pointer; + font-size: 14px; + `; + copyButton.onclick = () => { + textArea.select(); + textArea.setSelectionRange(0, textArea.value.length); + try { + const successful = document.execCommand("copy"); + if (successful) { + copyButton.textContent = "Copied!"; + copyButton.style.background = "#28a745"; + setTimeout(() => { + copyButton.textContent = "Copy"; + copyButton.style.background = "#007bff"; + }, 2000); + } + } catch (err) { + console.error("Copy failed:", err); + } + }; + const selectButton = document.createElement("button"); selectButton.textContent = "Select All"; selectButton.style.cssText = ` padding: 8px 16px; - background: #007bff; + background: #6c757d; color: white; border: none; border-radius: 4px; @@ -178,7 +231,7 @@ export function showCopyPrompt(text: string): void { closeButton.textContent = "Close"; closeButton.style.cssText = ` padding: 8px 16px; - background: #6c757d; + background: #dc3545; color: white; border: none; border-radius: 4px; @@ -189,6 +242,7 @@ export function showCopyPrompt(text: string): void { document.body.removeChild(overlay); }; + buttonContainer.appendChild(copyButton); buttonContainer.appendChild(selectButton); buttonContainer.appendChild(closeButton); modal.appendChild(textArea); diff --git a/src/lib/route-utils.ts b/src/lib/route-utils.ts index 12d6e82..9f643db 100644 --- a/src/lib/route-utils.ts +++ b/src/lib/route-utils.ts @@ -45,7 +45,7 @@ export function generateEntryUrl( ): string { const baseRoute = getEntryBaseUrl(entryType); const encodedId = encodeURIComponent(entryId); - return `https://codex.f3nation.com/${baseRoute}/${encodedId}`; + return `https://f3nation.com/${baseRoute}/${encodedId}`; } /** From 66ec0e8555fcaac2d0238bc960a58e135b0c9780 Mon Sep 17 00:00:00 2001 From: victorSauceda Date: Fri, 19 Dec 2025 20:05:54 -0600 Subject: [PATCH 2/4] fix copy button issue --- next.config.ts | 10 ++++++ src/components/shared/CopyEntryButton.tsx | 44 ++++++++++++++++++++--- 2 files changed, 50 insertions(+), 4 deletions(-) diff --git a/next.config.ts b/next.config.ts index 695141f..ca7989d 100644 --- a/next.config.ts +++ b/next.config.ts @@ -13,6 +13,16 @@ const nextConfig: NextConfig = { }, async headers() { return [ + { + // Allow clipboard access when embedded in iframes (e.g., on f3nation.com) + source: "/:path*", + headers: [ + { + key: "Permissions-Policy", + value: "clipboard-write=(self \"https://f3nation.com\" \"https://www.f3nation.com\")", + }, + ], + }, { source: "/callback/:path*", headers: [ diff --git a/src/components/shared/CopyEntryButton.tsx b/src/components/shared/CopyEntryButton.tsx index f62116f..766d803 100644 --- a/src/components/shared/CopyEntryButton.tsx +++ b/src/components/shared/CopyEntryButton.tsx @@ -114,11 +114,47 @@ ${cleanDescription}`; ? stripHtml(entry.description) : "No description available."; - const allContent = `${entry.name} + // Build comprehensive content with formatting + const parts: string[] = []; -${cleanDescription} + // Title + parts.push(`📖 ${entry.name}`); + parts.push(""); // Empty line -${url}`; + // Type + const typeLabel = entry.type === "exicon" ? "Exercise" : "Term"; + parts.push(`Type: ${typeLabel}`); + parts.push(""); // Empty line + + // Description + parts.push("Description:"); + parts.push(cleanDescription); + parts.push(""); // Empty line + + // Aliases + if (entry.aliases && entry.aliases.length > 0) { + const aliasNames = entry.aliases.map((a) => a.name).join(", "); + parts.push(`Also known as: ${aliasNames}`); + parts.push(""); // Empty line + } + + // Tags (only for exicon entries) + if (entry.type === "exicon" && entry.tags && entry.tags.length > 0) { + const tagNames = entry.tags.map((t) => t.name).join(", "); + parts.push(`Tags: ${tagNames}`); + parts.push(""); // Empty line + } + + // Video Link (only for exicon entries) + if (entry.type === "exicon" && entry.videoLink) { + parts.push(`Video: ${entry.videoLink}`); + parts.push(""); // Empty line + } + + // URL + parts.push(`Link: ${url}`); + + const allContent = parts.join("\n"); const result = await copyToClipboard(allContent); @@ -127,7 +163,7 @@ ${url}`; setTimeout(() => setCopied(false), 2000); toast({ title: "All Details Copied!", - description: `${entry.name} name, description, and URL copied to clipboard.`, + description: `${entry.name} complete information copied to clipboard.`, }); } else { if (isInIframeUtil()) { From 92c750f05d9b963752b26cb317c88f77da4e42fc Mon Sep 17 00:00:00 2001 From: victorSauceda Date: Fri, 19 Dec 2025 20:13:51 -0600 Subject: [PATCH 3/4] update route utils for ? queries --- src/lib/route-utils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/route-utils.ts b/src/lib/route-utils.ts index 9f643db..73fde90 100644 --- a/src/lib/route-utils.ts +++ b/src/lib/route-utils.ts @@ -45,7 +45,7 @@ export function generateEntryUrl( ): string { const baseRoute = getEntryBaseUrl(entryType); const encodedId = encodeURIComponent(entryId); - return `https://f3nation.com/${baseRoute}/${encodedId}`; + return `https://f3nation.com/${baseRoute}?entryId=${encodedId}`; } /** From 77fb1fb58a6fb29e510bb48b3e6a86aaa14fc6b2 Mon Sep 17 00:00:00 2001 From: victorSauceda Date: Fri, 19 Dec 2025 20:33:10 -0600 Subject: [PATCH 4/4] admin user submission UI updates --- src/app/admin/AdminPanel.tsx | 355 +++++++++++++++++++---------------- 1 file changed, 194 insertions(+), 161 deletions(-) diff --git a/src/app/admin/AdminPanel.tsx b/src/app/admin/AdminPanel.tsx index 7c3ab7e..e79acd8 100644 --- a/src/app/admin/AdminPanel.tsx +++ b/src/app/admin/AdminPanel.tsx @@ -82,6 +82,7 @@ import { import { getOAuthConfig } from "@/lib/auth"; import { TiptapEditor } from "@/components/shared/TiptapEditor"; import { searchEntriesByName } from "@/app/submit/actions"; +import { RichTextDisplay } from "@/components/shared/RichTextDisplay"; interface UserInfo { sub: string; @@ -137,6 +138,8 @@ export default function AdminPanel() { const [isRejectDialogOpen, setIsRejectDialogOpen] = useState(false); const [rejectionReason, setRejectionReason] = useState(""); const [submissionToReject, setSubmissionToReject] = useState(null); + const [adminNotes, setAdminNotes] = useState(""); + const [resolvedMentions, setResolvedMentions] = useState>({}); const [lexiconEntriesForDisplay, setLexiconEntriesForDisplay] = useState< AnyEntry[] @@ -469,6 +472,29 @@ export default function AdminPanel() { setEditedSubmissionData(JSON.parse(JSON.stringify(submission.data))); // Deep clone setIsEditingSubmission(false); setOriginalEntryForEditView(null); + + // Load mentioned entries for proper display + const mentionedEntryIds = submission.submissionType === "new" + ? (submission.data as NewEntrySuggestionData).mentionedEntries || [] + : (submission.data as EditEntrySuggestionData).changes.mentionedEntries || []; + + if (mentionedEntryIds.length > 0) { + const resolved: Record = {}; + for (const entryId of mentionedEntryIds) { + try { + const entry = await fetchEntryById(entryId); + if (entry) { + resolved[entryId] = entry; + } + } catch (error) { + console.error(`Error loading mentioned entry ${entryId}:`, error); + } + } + setResolvedMentions(resolved); + } else { + setResolvedMentions({}); + } + if (submission.submissionType === "edit") { setIsLoadingOriginalEntry(true); try { @@ -490,6 +516,9 @@ export default function AdminPanel() { }; const handleStartEditingSubmission = () => { + if (viewingSubmission) { + setAdminNotes(viewingSubmission.adminNotes || ""); + } setIsEditingSubmission(true); }; @@ -498,6 +527,7 @@ export default function AdminPanel() { setEditedSubmissionData( JSON.parse(JSON.stringify(viewingSubmission.data)), ); + setAdminNotes(viewingSubmission.adminNotes || ""); } setIsEditingSubmission(false); }; @@ -507,6 +537,7 @@ export default function AdminPanel() { setViewingSubmission({ ...viewingSubmission, data: editedSubmissionData, + adminNotes: adminNotes, }); } setIsEditingSubmission(false); @@ -1066,7 +1097,7 @@ export default function AdminPanel() { {isEditingSubmission - ? "Make any necessary changes before approving" + ? "Edit any field and optionally add a message for the user" : "Reviewing"}{" "} {viewingSubmission?.submissionType === "new" ? "new entry suggestion" @@ -1284,13 +1315,10 @@ export default function AdminPanel() { Description -
@@ -1396,131 +1424,123 @@ export default function AdminPanel() { <> {isEditingSubmission && editedSubmissionData ? (
- {(editedSubmissionData as EditEntrySuggestionData) - .changes.name !== undefined && ( -
- - - setEditedSubmissionData({ - ...editedSubmissionData, - changes: { - ...( - editedSubmissionData as EditEntrySuggestionData - ).changes, - name: e.target.value, - }, - } as EditEntrySuggestionData) - } - /> -
- )} - {(editedSubmissionData as EditEntrySuggestionData) - .changes.description !== undefined && ( -
- - - setEditedSubmissionData({ - ...editedSubmissionData, - changes: { - ...( - editedSubmissionData as EditEntrySuggestionData - ).changes, - description: html, - }, - } as EditEntrySuggestionData) - } - onMentionsChange={(mentions) => { - setEditedSubmissionData({ - ...editedSubmissionData, - changes: { - ...( - editedSubmissionData as EditEntrySuggestionData - ).changes, - mentionedEntries: mentions.map((m) => m.id), - }, - } as EditEntrySuggestionData); - }} - searchEntries={searchEntriesByName} - placeholder="Edit description..." - /> -
- )} - {(editedSubmissionData as EditEntrySuggestionData) - .changes.aliases !== undefined && ( +

+ You can edit any field below, not just the user's suggested changes. Fields marked with * were suggested by the user. +

+ + {/* Name - always shown */} +
+ + + setEditedSubmissionData({ + ...editedSubmissionData, + changes: { + ...(editedSubmissionData as EditEntrySuggestionData).changes, + name: e.target.value, + }, + } as EditEntrySuggestionData) + } + /> +
+ + {/* Description - always shown */} +
+ + + setEditedSubmissionData({ + ...editedSubmissionData, + changes: { + ...(editedSubmissionData as EditEntrySuggestionData).changes, + description: html, + }, + } as EditEntrySuggestionData) + } + onMentionsChange={(mentions) => { + setEditedSubmissionData({ + ...editedSubmissionData, + changes: { + ...(editedSubmissionData as EditEntrySuggestionData).changes, + mentionedEntries: mentions.map((m) => m.id), + }, + } as EditEntrySuggestionData); + }} + searchEntries={searchEntriesByName} + placeholder="Edit description..." + /> +
+ + {/* Aliases - always shown */} +
+ + a.name).join(", ") + } + onChange={(e) => + setEditedSubmissionData({ + ...editedSubmissionData, + changes: { + ...(editedSubmissionData as EditEntrySuggestionData).changes, + aliases: e.target.value + .split(",") + .map((a) => a.trim()) + .filter(Boolean), + }, + } as EditEntrySuggestionData) + } + /> +
+ + {/* Tags - for exicon only, always shown */} + {originalEntryForEditView.type === "exicon" && (
-
- )} - {originalEntryForEditView.type === "exicon" && - (editedSubmissionData as EditEntrySuggestionData) - .changes.tags !== undefined && ( -
- -
- {tags.map((tag) => ( +
+ {tags.map((tag) => { + const currentTags = (editedSubmissionData as EditEntrySuggestionData).changes.tags !== undefined + ? (editedSubmissionData as EditEntrySuggestionData).changes.tags || [] + : (originalEntryForEditView as ExiconEntry).tags.map(t => t.name); + + return (
{ - const currentTags = - ( - editedSubmissionData as EditEntrySuggestionData - ).changes.tags || []; const newTags = checked ? [...currentTags, tag.name] - : currentTags.filter( - (t) => t !== tag.name, - ); + : currentTags.filter((t) => t !== tag.name); setEditedSubmissionData({ ...editedSubmissionData, changes: { - ...( - editedSubmissionData as EditEntrySuggestionData - ).changes, + ...(editedSubmissionData as EditEntrySuggestionData).changes, tags: newTags, }, } as EditEntrySuggestionData); @@ -1533,39 +1553,57 @@ export default function AdminPanel() { {tag.name}
- ))} -
-
- )} - {originalEntryForEditView.type === "exicon" && - (editedSubmissionData as EditEntrySuggestionData) - .changes.videoLink !== undefined && ( -
- - - setEditedSubmissionData({ - ...editedSubmissionData, - changes: { - ...( - editedSubmissionData as EditEntrySuggestionData - ).changes, - videoLink: e.target.value, - }, - } as EditEntrySuggestionData) - } - placeholder="https://youtube.com/watch?v=..." - /> + ); + })}
- )} +
+ )} + + {/* Video Link - for exicon only, always shown */} + {originalEntryForEditView.type === "exicon" && ( +
+ + + setEditedSubmissionData({ + ...editedSubmissionData, + changes: { + ...(editedSubmissionData as EditEntrySuggestionData).changes, + videoLink: e.target.value, + }, + } as EditEntrySuggestionData) + } + placeholder="https://youtube.com/watch?v=..." + /> +
+ )} + + {/* Admin Notes */} + +
+ +

+ Add a message to communicate with the user about any additional changes you made or feedback on their submission. +

+