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');
|
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
|
// Indexes that depend on migrated columns — created after columns are guaranteed to exist
|
||||||
db.exec(`
|
db.exec(`
|
||||||
CREATE INDEX IF NOT EXISTS idx_memes_collection_id ON memes(collection_id);
|
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)
|
// Delete meme (children cascade)
|
||||||
app.delete<{ Params: { id: string } }>(
|
app.delete<{ Params: { id: string } }>(
|
||||||
'/api/memes/:id',
|
'/api/memes/:id',
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export interface Meme {
|
|||||||
parent_id: string | null;
|
parent_id: string | null;
|
||||||
collection_id: number | null;
|
collection_id: number | null;
|
||||||
ocr_text: string | null;
|
ocr_text: string | null;
|
||||||
|
share_count: number;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
tags: string[];
|
tags: string[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export interface Meme {
|
|||||||
parent_id: string | null;
|
parent_id: string | null;
|
||||||
collection_id: number | null;
|
collection_id: number | null;
|
||||||
ocr_text: string | null;
|
ocr_text: string | null;
|
||||||
|
share_count: number;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
tags: string[];
|
tags: string[];
|
||||||
children?: Meme[];
|
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> {
|
move(id: string, collection_id: number): Promise<Meme> {
|
||||||
return apiFetch(`/api/memes/${id}/collection`, {
|
return apiFetch(`/api/memes/${id}/collection`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState } from 'react';
|
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 { useMeme, useDeleteMeme, useUpdateMeme, useMoveMeme, useCollections } from '../hooks/useMemes';
|
||||||
import { useAuth } from '../hooks/useAuth';
|
import { useAuth } from '../hooks/useAuth';
|
||||||
import { SharePanel } from './SharePanel';
|
import { SharePanel } from './SharePanel';
|
||||||
@@ -221,7 +221,7 @@ export function MemeDetail({ memeId, onClose }: Props) {
|
|||||||
{displayMeme && (
|
{displayMeme && (
|
||||||
<section>
|
<section>
|
||||||
<h3 className="text-xs font-semibold text-zinc-500 uppercase tracking-wider mb-2">Share</h3>
|
<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>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -333,6 +333,13 @@ export function MemeDetail({ memeId, onClose }: Props) {
|
|||||||
<dt className="text-zinc-500">Type</dt>
|
<dt className="text-zinc-500">Type</dt>
|
||||||
<dd className="text-zinc-300">{meme.mime_type.replace('image/', '').replace('video/', '')}</dd>
|
<dd className="text-zinc-300">{meme.mime_type.replace('image/', '').replace('video/', '')}</dd>
|
||||||
</div>
|
</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">
|
<div className="flex justify-between">
|
||||||
<dt className="text-zinc-500">Uploaded</dt>
|
<dt className="text-zinc-500">Uploaded</dt>
|
||||||
<dd className="text-zinc-300 text-right text-xs">{formatDate(meme.created_at)}</dd>
|
<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 {
|
interface Props {
|
||||||
meme: Meme;
|
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);
|
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 shareUrl = `${window.location.origin}/m/${meme.id}`;
|
||||||
const imageUrl = `${window.location.origin}${api.imageUrl(meme.file_path)}`;
|
const imageUrl = `${window.location.origin}${api.imageUrl(meme.file_path)}`;
|
||||||
|
|
||||||
|
function recordShare() {
|
||||||
|
api.memes.share(meme.id).then(() => onShared?.()).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
async function copyLink() {
|
async function copyLink() {
|
||||||
await navigator.clipboard.writeText(shareUrl);
|
await navigator.clipboard.writeText(shareUrl);
|
||||||
setCopied(true);
|
setCopied(true);
|
||||||
setTimeout(() => setCopied(false), 2000);
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
recordShare();
|
||||||
}
|
}
|
||||||
|
|
||||||
const telegramUrl = `https://t.me/share/url?url=${encodeURIComponent(shareUrl)}&text=${encodeURIComponent(meme.title)}`;
|
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}
|
href={telegramUrl}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
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"
|
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"
|
title="Share on Telegram"
|
||||||
>
|
>
|
||||||
@@ -51,6 +57,7 @@ export function SharePanel({ meme }: Props) {
|
|||||||
|
|
||||||
<a
|
<a
|
||||||
href={smsUrl}
|
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"
|
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"
|
title="Share via SMS"
|
||||||
>
|
>
|
||||||
|
|||||||
Reference in New Issue
Block a user