Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions lib/supabase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ export interface Database {
log_index: number;
block_timestamp: string | null;
indexed_at: string;
view_count: number;
};
Insert: {
id?: never;
Expand All @@ -77,6 +78,7 @@ export interface Database {
log_index: number;
block_timestamp?: string | null;
indexed_at?: string;
view_count?: number;
};
Update: {
id?: never;
Expand All @@ -94,6 +96,33 @@ export interface Database {
log_index?: number;
block_timestamp?: string | null;
indexed_at?: string;
view_count?: number;
};
};
page_views: {
Row: {
id: number;
storyline_id: number;
plot_index: number | null;
viewer_address: string | null;
session_id: string;
viewed_at: string;
};
Insert: {
id?: never;
storyline_id: number;
plot_index?: number | null;
viewer_address?: string | null;
session_id: string;
viewed_at?: string;
};
Update: {
id?: never;
storyline_id?: number;
plot_index?: number | null;
viewer_address?: string | null;
session_id?: string;
viewed_at?: string;
};
};
plots: {
Expand Down
111 changes: 111 additions & 0 deletions src/app/api/views/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import { NextRequest, NextResponse } from "next/server";
import { createServerClient, supabase } from "../../../../lib/supabase";

function error(message: string, status = 400) {
return NextResponse.json({ error: message }, { status });
}

// ---------------------------------------------------------------------------
// GET /api/views?storylineId=N
// ---------------------------------------------------------------------------

export async function GET(req: NextRequest) {
const storylineId = req.nextUrl.searchParams.get("storylineId");
if (!storylineId) return error("Missing storylineId");

const db = supabase;
if (!db) return error("Supabase not configured", 500);

const sid = Number(storylineId);
if (isNaN(sid) || sid <= 0) return error("Invalid storylineId");

// eslint-disable-next-line @typescript-eslint/no-explicit-any
const { data, error: dbError } = await (db.from("storylines") as any)
.select("view_count")
.eq("storyline_id", sid)
.single();

if (dbError) return error(`Database error: ${dbError.message}`, 500);
if (!data) return error("Storyline not found", 404);

return NextResponse.json({ storylineId: sid, viewCount: data.view_count ?? 0 });
}

// ---------------------------------------------------------------------------
// POST /api/views
// Body: { storylineId, plotIndex?, sessionId, viewerAddress? }
// Dedup: max 1 view per session per page per hour
// ---------------------------------------------------------------------------

interface ViewBody {
storylineId: number;
plotIndex?: number | null;
sessionId: string;
viewerAddress?: string | null;
}

export async function POST(req: NextRequest) {
let body: ViewBody;
try {
body = await req.json();
} catch {
return error("Invalid JSON body");
}

const { storylineId, plotIndex, sessionId, viewerAddress } = body;

if (!storylineId || typeof storylineId !== "number" || storylineId <= 0) {
return error("Missing or invalid storylineId");
}
if (!sessionId || typeof sessionId !== "string" || sessionId.length > 128) {
return error("Missing or invalid sessionId");
}

const serverClient = createServerClient();
if (!serverClient) return error("Supabase not configured", 500);

const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000).toISOString();
const plotVal = plotIndex ?? null;

// Dedup: check if this session already viewed this page in the last hour
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let dedupQuery = (serverClient.from("page_views") as any)
.select("id")
.eq("storyline_id", storylineId)
.eq("session_id", sessionId)
.gte("viewed_at", oneHourAgo)
.limit(1);

if (plotVal === null) {
dedupQuery = dedupQuery.is("plot_index", null);
} else {
dedupQuery = dedupQuery.eq("plot_index", plotVal);
}

const { data: existing } = await dedupQuery;

if (existing && existing.length > 0) {
return NextResponse.json({ success: true, deduplicated: true });
}

// Insert page view record
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const { error: insertError } = await (serverClient.from("page_views") as any).insert({
storyline_id: storylineId,
plot_index: plotVal,
viewer_address: viewerAddress?.toLowerCase() ?? null,
session_id: sessionId,
});

if (insertError) return error(`Database error: ${insertError.message}`, 500);

// Increment denormalized counter (storyline-level views only)
if (plotVal === null) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await (serverClient.rpc as any)("increment_view_count", { sid: storylineId }).catch(() => {
// Ignore — counter will be slightly behind but page_views table is authoritative
});
}

return NextResponse.json({ success: true, deduplicated: false });
}
15 changes: 14 additions & 1 deletion src/app/dashboard/writer/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,13 @@ import Link from "next/link";
import { ConnectWallet } from "../../../components/ConnectWallet";
import { type Address } from "viem";

function formatViewCountDashboard(n: number): string {
if (n < 1000) return String(n);
if (n < 10000) return `${(n / 1000).toFixed(1)}k`;
if (n < 1000000) return `${Math.round(n / 1000)}k`;
return `${(n / 1000000).toFixed(1)}M`;
}

async function fetchWriterStorylines(
address: string,
): Promise<Storyline[]> {
Expand Down Expand Up @@ -101,13 +108,19 @@ function StorylineDetail({ storyline, writerAddress }: { storyline: Storyline; w
)}
</div>

<div className="text-muted mt-3 grid grid-cols-3 gap-2 text-xs">
<div className="text-muted mt-3 grid grid-cols-4 gap-2 text-xs">
<div>
<span className="block text-[10px] uppercase tracking-wider">
Plots
</span>
<span className="text-foreground">{storyline.plot_count}</span>
</div>
<div>
<span className="block text-[10px] uppercase tracking-wider">
Views
</span>
<span className="text-foreground">{formatViewCountDashboard(storyline.view_count)}</span>
</div>
<div>
<span className="block text-[10px] uppercase tracking-wider">
Created
Expand Down
4 changes: 4 additions & 0 deletions src/app/story/[storylineId]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { type Address } from "viem";
import { truncateAddress } from "../../../../lib/utils";
import { AgentBadge } from "../../../components/AgentBadge";
import { WriterIdentity } from "../../../components/WriterIdentity";
import { ViewCount, ViewTracker } from "../../../components/ViewCount";

type Params = Promise<{ storylineId: string }>;

Expand Down Expand Up @@ -123,6 +124,7 @@ export default async function StoryPage({ params }: { params: Params }) {

return (
<div className="mx-auto max-w-5xl px-6 py-10">
<ViewTracker storylineId={id} />
<StoryHeader storyline={storyline} priceInfo={priceInfo} />

<div className="mt-8 grid grid-cols-1 gap-10 lg:grid-cols-[1fr_320px]">
Expand Down Expand Up @@ -186,6 +188,7 @@ function StoryHeader({
<span>
{storyline.plot_count} {storyline.plot_count === 1 ? "plot" : "plots"}
</span>
<ViewCount storylineId={storyline.storyline_id} initialCount={storyline.view_count} />
{storyline.writer_type === 1 && <AgentBadge />}
<RatingSummary storylineId={storyline.storyline_id} />
</div>
Expand Down Expand Up @@ -227,6 +230,7 @@ function StoryHeader({
function PlotEntry({ plot }: { plot: Plot }) {
return (
<article className="border-border border-b pb-8 last:border-b-0">
<ViewTracker storylineId={plot.storyline_id} plotIndex={plot.plot_index} />
<div className="text-muted mb-3 flex items-baseline gap-3 text-xs">
<span className="text-accent-dim font-medium">
{plot.plot_index === 0 ? "Genesis" : `Plot #${plot.plot_index}`}
Expand Down
2 changes: 2 additions & 0 deletions src/components/StoryCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { truncateAddress } from "../../lib/utils";
import { AgentBadge } from "./AgentBadge";
import { RatingSummary } from "./RatingSummary";
import { StoryCardStats } from "./StoryCardStats";
import { ViewCount } from "./ViewCount";

export function StoryCard({
storyline,
Expand Down Expand Up @@ -44,6 +45,7 @@ export function StoryCard({
<span>
{storyline.plot_count} {storyline.plot_count === 1 ? "plot" : "plots"}
</span>
<ViewCount storylineId={storyline.storyline_id} initialCount={storyline.view_count} />
{dateStr && <span>{dateStr}</span>}
{genre && (
<span className="border-border rounded border px-1.5 py-0.5 text-[10px]">
Expand Down
126 changes: 126 additions & 0 deletions src/components/ViewCount.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
"use client";

import { useEffect } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useAccount } from "wagmi";

// ---------------------------------------------------------------------------
// SVG eye icon matching the design system
// ---------------------------------------------------------------------------

function EyeIcon({ className }: { className?: string }) {
return (
<svg
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
className={className}
aria-hidden="true"
>
<path d="M1.5 8s2.5-4.5 6.5-4.5S14.5 8 14.5 8s-2.5 4.5-6.5 4.5S1.5 8 1.5 8z" />
<circle cx="8" cy="8" r="2" />
</svg>
);
}

// ---------------------------------------------------------------------------
// Compact number formatting: 1234 → "1.2k", 1000000 → "1M"
// ---------------------------------------------------------------------------

function formatViewCount(n: number): string {
if (n < 1000) return String(n);
if (n < 10000) return `${(n / 1000).toFixed(1)}k`;
if (n < 1000000) return `${Math.round(n / 1000)}k`;
if (n < 10000000) return `${(n / 1000000).toFixed(1)}M`;
return `${Math.round(n / 1000000)}M`;
}

// ---------------------------------------------------------------------------
// ViewCount — displays eye icon + count (fetches from server)
// ---------------------------------------------------------------------------

export function ViewCount({
storylineId,
initialCount,
}: {
storylineId: number;
initialCount?: number;
}) {
const { data } = useQuery({
queryKey: ["view-count", storylineId],
queryFn: async () => {
const res = await fetch(`/api/views?storylineId=${storylineId}`);
if (!res.ok) return initialCount ?? 0;
const json = await res.json();
return json.viewCount as number;
},
initialData: initialCount,
staleTime: 120000,
});

const count = data ?? initialCount ?? 0;

return (
<span className="text-muted inline-flex items-center gap-1 text-xs">
<EyeIcon className="h-3 w-3" />
<span>{formatViewCount(count)}</span>
</span>
);
}

// ---------------------------------------------------------------------------
// ViewTracker — fire-and-forget POST on mount to record a view
// ---------------------------------------------------------------------------

function getSessionId(): string {
if (typeof window === "undefined") return "";
const key = "plotlink-session-id";
let id = sessionStorage.getItem(key);
if (!id) {
id = crypto.randomUUID();
sessionStorage.setItem(key, id);
}
return id;
}

export function ViewTracker({
storylineId,
plotIndex,
}: {
storylineId: number;
plotIndex?: number | null;
}) {
const { address } = useAccount();
const queryClient = useQueryClient();

useEffect(() => {
const sessionId = getSessionId();
if (!sessionId) return;

fetch("/api/views", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
storylineId,
plotIndex: plotIndex ?? null,
sessionId,
viewerAddress: address ?? null,
}),
})
.then((res) => res.json())
.then((data) => {
if (data.success && !data.deduplicated) {
// Invalidate the cached count so it refetches
queryClient.invalidateQueries({ queryKey: ["view-count", storylineId] });
}
})
.catch(() => {
// Silently ignore — view tracking is best-effort
});
}, [storylineId, plotIndex, address, queryClient]);

return null;
}
Loading
Loading