build share count
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -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<Meme> {
|
||||
return apiFetch(`/api/memes/${id}/collection`, {
|
||||
method: 'PUT',
|
||||
|
||||
@@ -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 && (
|
||||
<section>
|
||||
<h3 className="text-xs font-semibold text-zinc-500 uppercase tracking-wider mb-2">Share</h3>
|
||||
<SharePanel meme={displayMeme} />
|
||||
<SharePanel meme={displayMeme} onShared={refetch} />
|
||||
</section>
|
||||
)}
|
||||
|
||||
@@ -333,6 +333,13 @@ export function MemeDetail({ memeId, onClose }: Props) {
|
||||
<dt className="text-zinc-500">Type</dt>
|
||||
<dd className="text-zinc-300">{meme.mime_type.replace('image/', '').replace('video/', '')}</dd>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-zinc-500">Shared</dt>
|
||||
<dd className="text-zinc-300 flex items-center gap-1">
|
||||
<Share2 size={11} className="text-zinc-500" />
|
||||
{meme.share_count ?? 0} time{(meme.share_count ?? 0) !== 1 ? 's' : ''}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-zinc-500">Uploaded</dt>
|
||||
<dd className="text-zinc-300 text-right text-xs">{formatDate(meme.created_at)}</dd>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
<a
|
||||
href={smsUrl}
|
||||
onClick={recordShare}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-green-900/30 hover:bg-green-900/50 text-green-400 text-sm font-medium transition-colors"
|
||||
title="Share via SMS"
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user