From d3fb42e8ed9637d3356db87c266b8d268c6b7343 Mon Sep 17 00:00:00 2001 From: jason Date: Sat, 28 Mar 2026 22:02:37 -0500 Subject: [PATCH] build share count --- backend/src/db.ts | 4 ++++ backend/src/routes/memes.ts | 8 ++++++++ backend/src/types.ts | 1 + frontend/src/api/client.ts | 5 +++++ frontend/src/components/MemeDetail.tsx | 11 +++++++++-- frontend/src/components/SharePanel.tsx | 11 +++++++++-- 6 files changed, 36 insertions(+), 4 deletions(-) diff --git a/backend/src/db.ts b/backend/src/db.ts index cfefa09..3a8068f 100644 --- a/backend/src/db.ts +++ b/backend/src/db.ts @@ -66,6 +66,10 @@ if (!memesCols.find((c) => c.name === 'ocr_text')) { db.exec('ALTER TABLE memes ADD COLUMN ocr_text TEXT'); } +if (!memesCols.find((c) => c.name === 'share_count')) { + db.exec('ALTER TABLE memes ADD COLUMN share_count INTEGER NOT NULL DEFAULT 0'); +} + // Indexes that depend on migrated columns — created after columns are guaranteed to exist db.exec(` CREATE INDEX IF NOT EXISTS idx_memes_collection_id ON memes(collection_id); diff --git a/backend/src/routes/memes.ts b/backend/src/routes/memes.ts index 403a2c4..454a5d0 100644 --- a/backend/src/routes/memes.ts +++ b/backend/src/routes/memes.ts @@ -214,6 +214,14 @@ export async function memesRoutes(app: FastifyInstance) { } ); + // Record a share — no auth required, public action + app.post<{ Params: { id: string } }>('/api/memes/:id/share', async (req, reply) => { + const meme = getMemeById(req.params.id); + if (!meme) return reply.status(404).send({ error: 'Not found' }); + db.prepare('UPDATE memes SET share_count = share_count + 1 WHERE id = ?').run(meme.id); + return { share_count: (meme.share_count ?? 0) + 1 }; + }); + // Delete meme (children cascade) app.delete<{ Params: { id: string } }>( '/api/memes/:id', diff --git a/backend/src/types.ts b/backend/src/types.ts index e7bb107..6c370a0 100644 --- a/backend/src/types.ts +++ b/backend/src/types.ts @@ -11,6 +11,7 @@ export interface Meme { parent_id: string | null; collection_id: number | null; ocr_text: string | null; + share_count: number; created_at: string; tags: string[]; } diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index aeb1f45..7ddcfc4 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -11,6 +11,7 @@ export interface Meme { parent_id: string | null; collection_id: number | null; ocr_text: string | null; + share_count: number; created_at: string; tags: string[]; children?: Meme[]; @@ -96,6 +97,10 @@ export const api = { }); }, + share(id: string): Promise<{ share_count: number }> { + return apiFetch(`/api/memes/${id}/share`, { method: 'POST' }); + }, + move(id: string, collection_id: number): Promise { return apiFetch(`/api/memes/${id}/collection`, { method: 'PUT', diff --git a/frontend/src/components/MemeDetail.tsx b/frontend/src/components/MemeDetail.tsx index e5c0f54..569f2c3 100644 --- a/frontend/src/components/MemeDetail.tsx +++ b/frontend/src/components/MemeDetail.tsx @@ -1,5 +1,5 @@ import { useState } from 'react'; -import { X, Minimize2, Trash2, Edit2, Check, Layers, FolderOpen, Inbox, ScanText, ChevronDown, ChevronUp, ExternalLink, Image, Info } from 'lucide-react'; +import { X, Minimize2, Trash2, Edit2, Check, Layers, FolderOpen, Inbox, ScanText, ChevronDown, ChevronUp, ExternalLink, Image, Info, Share2 } from 'lucide-react'; import { useMeme, useDeleteMeme, useUpdateMeme, useMoveMeme, useCollections } from '../hooks/useMemes'; import { useAuth } from '../hooks/useAuth'; import { SharePanel } from './SharePanel'; @@ -221,7 +221,7 @@ export function MemeDetail({ memeId, onClose }: Props) { {displayMeme && (

Share

- +
)} @@ -333,6 +333,13 @@ export function MemeDetail({ memeId, onClose }: Props) {
Type
{meme.mime_type.replace('image/', '').replace('video/', '')}
+
+
Shared
+
+ + {meme.share_count ?? 0} time{(meme.share_count ?? 0) !== 1 ? 's' : ''} +
+
Uploaded
{formatDate(meme.created_at)}
diff --git a/frontend/src/components/SharePanel.tsx b/frontend/src/components/SharePanel.tsx index 635ce76..a92d179 100644 --- a/frontend/src/components/SharePanel.tsx +++ b/frontend/src/components/SharePanel.tsx @@ -5,19 +5,24 @@ import { api } from '../api/client'; interface Props { meme: Meme; + onShared?: () => void; // called after a share is recorded so parent can refetch } -export function SharePanel({ meme }: Props) { +export function SharePanel({ meme, onShared }: Props) { const [copied, setCopied] = useState(false); - // /m/:id gives crawlers OG meta tags so SMS/Telegram show a rich preview card const shareUrl = `${window.location.origin}/m/${meme.id}`; const imageUrl = `${window.location.origin}${api.imageUrl(meme.file_path)}`; + function recordShare() { + api.memes.share(meme.id).then(() => onShared?.()).catch(() => {}); + } + async function copyLink() { await navigator.clipboard.writeText(shareUrl); setCopied(true); setTimeout(() => setCopied(false), 2000); + recordShare(); } const telegramUrl = `https://t.me/share/url?url=${encodeURIComponent(shareUrl)}&text=${encodeURIComponent(meme.title)}`; @@ -42,6 +47,7 @@ export function SharePanel({ meme }: Props) { href={telegramUrl} target="_blank" rel="noopener noreferrer" + onClick={recordShare} className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-[#229ED9]/20 hover:bg-[#229ED9]/30 text-[#229ED9] text-sm font-medium transition-colors" title="Share on Telegram" > @@ -51,6 +57,7 @@ export function SharePanel({ meme }: Props) {