diff --git a/.claude/commands/do-test.md b/.claude/commands/do-test.md deleted file mode 100644 index fb65663e..00000000 --- a/.claude/commands/do-test.md +++ /dev/null @@ -1,52 +0,0 @@ ---- -description: Run development verification checks (lint, build, and optionally e2e tests) -argument-hint: "[e2e]" ---- - -## Development Test Suite - -Run comprehensive development verification checks for the Codu project. - -## Current Context - -Branch: !`git branch --show-current` -Status: !`git status --short | head -10` - -## Task - -Run the following verification steps in order: - -### 1. Lint Check -Run ESLint and verify there are **0 errors** (warnings are acceptable): -```bash -npm run lint -``` -Report the error/warning counts. - -### 2. TypeScript Compilation -Verify TypeScript compiles without errors: -```bash -npx tsc --noEmit -``` - -### 3. Build Check -Verify the Next.js build completes successfully: -```bash -npm run build -``` - -### 4. E2E Tests (if requested) -If `$ARGUMENTS` includes "e2e", also run E2E tests: -```bash -npm run test:e2e -``` - -## Output - -Provide a clear summary: -- Lint: PASS/FAIL (X errors, Y warnings) -- TypeScript: PASS/FAIL -- Build: PASS/FAIL -- E2E Tests: PASS/FAIL/SKIPPED - -If any check fails, provide details and suggest fixes. diff --git a/.vscode/settings.json b/.vscode/settings.json index 25fa6215..0630c4d4 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,3 +1,4 @@ { - "typescript.tsdk": "node_modules/typescript/lib" + "typescript.tsdk": "node_modules/typescript/lib", + "snyk.advanced.autoSelectOrganization": true } diff --git a/app/(app)/[username]/[slug]/_userLinkDetail.tsx b/app/(app)/[username]/[slug]/_userLinkDetail.tsx index 49e748a5..7d1ecc1e 100644 --- a/app/(app)/[username]/[slug]/_userLinkDetail.tsx +++ b/app/(app)/[username]/[slug]/_userLinkDetail.tsx @@ -21,19 +21,6 @@ type Props = { contentSlug: string; }; -// Get favicon URL from a website -const getFaviconUrl = ( - websiteUrl: string | null | undefined, -): string | null => { - if (!websiteUrl) return null; - try { - const url = new URL(websiteUrl); - return `https://www.google.com/s2/favicons?domain=${url.hostname}&sz=32`; - } catch { - return null; - } -}; - // Get hostname from URL const getHostname = (urlString: string): string => { try { @@ -189,7 +176,6 @@ const UserLinkDetail = ({ username, contentSlug }: Props) => { }) : null; - const faviconUrl = getFaviconUrl(externalUrl); const hostname = externalUrl ? getHostname(externalUrl) : null; const score = votes.upvotes - votes.downvotes; diff --git a/app/(app)/[username]/[slug]/page.tsx b/app/(app)/[username]/[slug]/page.tsx index 94664b55..931a3f1c 100644 --- a/app/(app)/[username]/[slug]/page.tsx +++ b/app/(app)/[username]/[slug]/page.tsx @@ -24,6 +24,12 @@ import { eq, and, lte } from "drizzle-orm"; import FeedArticleContent from "./_feedArticleContent"; import LinkContentDetail from "./_linkContentDetail"; import UserLinkDetail from "./_userLinkDetail"; +import { JsonLd } from "@/components/JsonLd"; +import { + getArticleSchema, + getBreadcrumbSchema, + getNewsArticleSchema, +} from "@/lib/structured-data"; type Props = { params: Promise<{ username: string; slug: string }> }; @@ -457,8 +463,40 @@ const UnifiedPostPage = async (props: Props) => { }) as unknown as string; } + // Prepare JSON-LD structured data + const articleSchema = getArticleSchema({ + title: userPost.title, + excerpt: userPost.excerpt, + slug: userPost.slug, + publishedAt: userPost.published, + updatedAt: userPost.updatedAt, + readingTime: userPost.readTimeMins, + canonicalUrl: userPost.canonicalUrl, + tags: userPost.tags.map((t) => ({ title: t.tag.title })), + author: { + name: userPost.user.name, + username: userPost.user.username, + image: userPost.user.image, + bio: userPost.user.bio, + }, + }); + + const breadcrumbSchema = getBreadcrumbSchema([ + { name: "Home", url: "https://www.codu.co" }, + { name: "Feed", url: "https://www.codu.co/feed" }, + { + name: userPost.user.name || "Author", + url: `https://www.codu.co/${userPost.user.username}`, + }, + { name: userPost.title }, + ]); + return ( <> + {/* JSON-LD Structured Data for SEO */} + + +
{/* Breadcrumb navigation */}
+ + + +
+

+ Tag Management +

+

+ Merge, curate, and manage tags +

+
+ diff --git a/app/(app)/admin/moderation/_client.tsx b/app/(app)/admin/moderation/_client.tsx index 0352831c..be584cd7 100644 --- a/app/(app)/admin/moderation/_client.tsx +++ b/app/(app)/admin/moderation/_client.tsx @@ -4,7 +4,6 @@ import { useState } from "react"; import Link from "next/link"; import { FlagIcon, - CheckCircleIcon, XCircleIcon, ExclamationTriangleIcon, ArrowLeftIcon, diff --git a/app/(app)/admin/sources/_client.tsx b/app/(app)/admin/sources/_client.tsx index afa25e73..976b4a5c 100644 --- a/app/(app)/admin/sources/_client.tsx +++ b/app/(app)/admin/sources/_client.tsx @@ -1,8 +1,9 @@ "use client"; -import { useState } from "react"; +import { useState, useRef } from "react"; import { api } from "@/server/trpc/react"; import { toast } from "sonner"; +import { uploadFile } from "@/utils/s3helpers"; import { PlusIcon, CheckCircleIcon, @@ -11,25 +12,109 @@ import { TrashIcon, ArrowPathIcon, CloudArrowDownIcon, + PencilSquareIcon, + XMarkIcon, + PhotoIcon, + ExclamationTriangleIcon, } from "@heroicons/react/20/solid"; const statusColors = { - ACTIVE: "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300", - PAUSED: + active: "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300", + paused: "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-300", - ERROR: "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300", + error: "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300", }; const statusIcons = { - ACTIVE: CheckCircleIcon, - PAUSED: PauseCircleIcon, - ERROR: XCircleIcon, + active: CheckCircleIcon, + paused: PauseCircleIcon, + error: XCircleIcon, +}; + +// Component for logo with fallback +const LogoWithFallback = ({ + logoUrl, + name, + size = "sm", +}: { + logoUrl: string | null; + name: string; + size?: "sm" | "md"; +}) => { + const [imageError, setImageError] = useState(false); + const initial = name.charAt(0).toUpperCase(); + const sizeClass = size === "md" ? "h-10 w-10" : "h-8 w-8"; + const textSize = size === "md" ? "text-base" : "text-sm"; + + // If we have a logoUrl and it hasn't errored, show the image + if (logoUrl && !imageError) { + return ( + {`${name} setImageError(true)} + /> + ); + } + + // Fallback to initial letter + return ( + + {initial} + + ); +}; + +// Helper to check which fields are missing for data completeness +const getMissingFields = (source: { + logoUrl: string | null; + websiteUrl: string | null; + category: string | null; + description: string | null; +}): string[] => { + const missing: string[] = []; + if (!source.logoUrl) missing.push("Logo"); + if (!source.websiteUrl) missing.push("Website URL"); + if (!source.category) missing.push("Category"); + if (!source.description) missing.push("Description"); + return missing; +}; + +// Data completeness badge component +const DataCompletenessBadge = ({ + missingFields, +}: { + missingFields: string[]; +}) => { + if (missingFields.length === 0) return null; + + return ( + + + + Missing: {missingFields.join(", ")} + + + ); }; const AdminSourcesPage = () => { const [showAddForm, setShowAddForm] = useState(false); const [syncingAll, setSyncingAll] = useState(false); const [syncingSourceId, setSyncingSourceId] = useState(null); + const [uploadingLogo, setUploadingLogo] = useState(false); + const [editingSource, setEditingSource] = useState<{ + id: number; + name: string; + url: string; + websiteUrl: string; + logoUrl: string; + category: string; + description: string; + } | null>(null); const [formData, setFormData] = useState({ name: "", url: "", @@ -37,8 +122,7 @@ const AdminSourcesPage = () => { logoUrl: "", category: "", }); - - const utils = api.useUtils(); + const logoInputRef = useRef(null); // Sync all sources const handleSyncAll = async () => { @@ -119,6 +203,7 @@ const AdminSourcesPage = () => { const updateSource = api.feed.updateSource.useMutation({ onSuccess: () => { toast.success("Feed source updated"); + setEditingSource(null); refetch(); }, onError: (error) => { @@ -136,6 +221,50 @@ const AdminSourcesPage = () => { }, }); + const { mutate: getUploadUrl } = api.feed.getSourceUploadUrl.useMutation(); + + // Handle logo image upload + const handleLogoUpload = async (e: React.ChangeEvent) => { + if (!e.target.files || e.target.files.length === 0 || !editingSource) + return; + + const file = e.target.files[0]; + const { size, type } = file; + + setUploadingLogo(true); + + await getUploadUrl( + { size, type }, + { + onError(error) { + setUploadingLogo(false); + if (error) return toast.error(error.message); + return toast.error("Failed to upload logo, please try again."); + }, + async onSuccess(signedUrl) { + try { + const response = await uploadFile(signedUrl, file); + const { fileLocation } = response; + setEditingSource({ + ...editingSource, + logoUrl: fileLocation, + }); + toast.success("Logo uploaded successfully"); + } catch { + toast.error("Failed to upload logo, please try again."); + } finally { + setUploadingLogo(false); + } + }, + }, + ); + + // Reset the input so the same file can be selected again + if (logoInputRef.current) { + logoInputRef.current.value = ""; + } + }; + const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); createSource.mutate({ @@ -147,8 +276,20 @@ const AdminSourcesPage = () => { }); }; + const handleEditSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (!editingSource) return; + updateSource.mutate({ + id: editingSource.id, + name: editingSource.name, + websiteUrl: editingSource.websiteUrl || undefined, + logoUrl: editingSource.logoUrl || undefined, + category: editingSource.category || undefined, + description: editingSource.description || undefined, + }); + }; + const handleStatusToggle = (id: number, currentStatus: string) => { - // Status is now lowercase in the new schema, but UpdateFeedSourceSchema still expects uppercase const newStatus = currentStatus === "active" ? "PAUSED" : "ACTIVE"; updateSource.mutate({ id, @@ -166,6 +307,26 @@ const AdminSourcesPage = () => { } }; + const openEditModal = (source: { + sourceId: number; + sourceName: string; + url: string | null; + websiteUrl: string | null; + logoUrl: string | null; + category: string | null; + description: string | null; + }) => { + setEditingSource({ + id: source.sourceId, + name: source.sourceName, + url: source.url || "", + websiteUrl: source.websiteUrl || "", + logoUrl: source.logoUrl || "", + category: source.category || "", + description: source.description || "", + }); + }; + return (
@@ -297,6 +458,189 @@ const AdminSourcesPage = () => {
)} + {/* Edit Modal */} + {editingSource && ( +
+
+
+

+ Edit Feed Source +

+ +
+
+
+ + + setEditingSource({ ...editingSource, name: e.target.value }) + } + className="w-full rounded-lg border border-neutral-300 px-3 py-2 dark:border-neutral-600 dark:bg-neutral-700" + /> +
+
+ + +
+
+ + + setEditingSource({ + ...editingSource, + websiteUrl: e.target.value, + }) + } + className="w-full rounded-lg border border-neutral-300 px-3 py-2 dark:border-neutral-600 dark:bg-neutral-700" + placeholder="https://example.com" + /> +
+
+ +
+ {/* Logo preview */} +
+ {editingSource.logoUrl ? ( + {`${editingSource.name} { + // Hide broken images + (e.target as HTMLImageElement).style.display = "none"; + }} + /> + ) : ( +
+ +
+ )} +
+ {/* Upload controls */} +
+ + +

+ PNG, JPG, GIF or WEBP. Max 5MB. +

+ {/* URL input as fallback */} + + setEditingSource({ + ...editingSource, + logoUrl: e.target.value, + }) + } + className="w-full rounded-lg border border-neutral-300 px-3 py-2 text-sm dark:border-neutral-600 dark:bg-neutral-700" + placeholder="Or paste image URL..." + /> +
+
+
+
+ + + setEditingSource({ + ...editingSource, + category: e.target.value, + }) + } + className="w-full rounded-lg border border-neutral-300 px-3 py-2 dark:border-neutral-600 dark:bg-neutral-700" + placeholder="e.g., frontend, react, career" + /> +
+
+ +