diff --git a/deno.json b/deno.json index 4c48d45..b157e15 100644 --- a/deno.json +++ b/deno.json @@ -7,35 +7,28 @@ "preview": "deno run -A main.ts", "update": "deno run -A -r https://fresh.deno.dev/update ." }, - "lint": { - "rules": { - "tags": [ - "fresh", - "recommended" - ] - } - }, - "exclude": [ - "**/_fresh/*" - ], - "nodeModulesDir": true, + "lint": { "rules": { "tags": ["fresh", "recommended"] } }, + "exclude": ["**/_fresh/*"], + "nodeModulesDir": "auto", "imports": { - "$fresh/": "https://deno.land/x/fresh@1.6.8/", - "zod": "https://deno.land/x/zod@v3.22.4/mod.ts", - "preact": "https://esm.sh/preact@10.19.6", - "preact/": "https://esm.sh/preact@10.19.6/", - "preact-render-to-string": "https://esm.sh/*preact-render-to-string@6.2.2", + "$fresh/": "https://deno.land/x/fresh@1.7.2/", + "$std/": "https://deno.land/std@0.193.0/", + "@deno/gfm": "jsr:@deno/gfm@^0.9.0", "@preact/signals": "https://esm.sh/*@preact/signals@1.2.2", "@preact/signals-core": "https://esm.sh/*@preact/signals-core@1.5.1", + "preact": "https://esm.sh/preact@10.22.0", + "preact-render-to-string": "https://esm.sh/*preact-render-to-string@6.2.2", + "preact/": "https://esm.sh/preact@10.22.0/", "tabler_icons/": "https://deno.land/x/tabler_icons_tsx@0.0.3/tsx/", - "$std/": "https://deno.land/std@0.193.0/", - "$gfm": "https://deno.land/x/gfm@0.2.3/mod.ts", "tailwindcss": "npm:tailwindcss@3.4.1", "tailwindcss/": "npm:/tailwindcss@3.4.1/", - "tailwindcss/plugin": "npm:/tailwindcss@3.4.1/plugin.js" + "tailwindcss/plugin": "npm:/tailwindcss@3.4.1/plugin.js", + "zod": "https://deno.land/x/zod@v3.22.4/mod.ts" }, "compilerOptions": { "jsx": "react-jsx", - "jsxImportSource": "preact" - } -} \ No newline at end of file + "jsxImportSource": "preact", + "types": ["preact"] + }, + "unstable": ["kv"] +} diff --git a/fresh.gen.ts b/fresh.gen.ts index 27fbc39..effe549 100644 --- a/fresh.gen.ts +++ b/fresh.gen.ts @@ -5,23 +5,28 @@ import * as $_404 from "./routes/_404.tsx"; import * as $_app from "./routes/_app.tsx"; import * as $about from "./routes/about.tsx"; +import * as $api_upvote from "./routes/api/upvote.ts"; import * as $conduct from "./routes/conduct.tsx"; import * as $index from "./routes/index.tsx"; +import * as $learn from "./routes/learn.tsx"; import * as $projects from "./routes/projects.tsx"; import * as $report from "./routes/report.ts"; import * as $support from "./routes/support.tsx"; import * as $Footer from "./islands/Footer.tsx"; import * as $Header from "./islands/Header.tsx"; import * as $IssuesList from "./islands/IssuesList.tsx"; -import { type Manifest } from "$fresh/server.ts"; +import * as $TopicItem from "./islands/TopicItem.tsx"; +import type { Manifest } from "$fresh/server.ts"; const manifest = { routes: { "./routes/_404.tsx": $_404, "./routes/_app.tsx": $_app, "./routes/about.tsx": $about, + "./routes/api/upvote.ts": $api_upvote, "./routes/conduct.tsx": $conduct, "./routes/index.tsx": $index, + "./routes/learn.tsx": $learn, "./routes/projects.tsx": $projects, "./routes/report.ts": $report, "./routes/support.tsx": $support, @@ -30,6 +35,7 @@ const manifest = { "./islands/Footer.tsx": $Footer, "./islands/Header.tsx": $Header, "./islands/IssuesList.tsx": $IssuesList, + "./islands/TopicItem.tsx": $TopicItem, }, baseUrl: import.meta.url, } satisfies Manifest; diff --git a/islands/TopicItem.tsx b/islands/TopicItem.tsx new file mode 100644 index 0000000..c1c8f5b --- /dev/null +++ b/islands/TopicItem.tsx @@ -0,0 +1,55 @@ +import { useState } from "preact/hooks"; +import { Topic } from "../lib/topics.ts"; + +interface TopicItemProps { + topic: Topic; + hasVoted: boolean; +} + +export default function TopicItem( + { topic, hasVoted: initialHasVoted }: TopicItemProps, +) { + const [hasVoted, setHasVoted] = useState(initialHasVoted); + const [votes, setVotes] = useState(topic.votes); + + const handleVote = async () => { + if (hasVoted) return; + + const response = await fetch("/api/upvote", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ topic: topic.name }), + }); + + if (response.ok) { + setVotes(votes + 1); + setHasVoted(true); + } else { + const error = await response.json(); + console.error("Error voting:", error); + // Optionally, you can show an error message to the user here + } + }; + + return ( +
+ {topic.name} +
+ {votes} votes + +
+
+ ); +} diff --git a/lib/datastore.ts b/lib/datastore.ts new file mode 100644 index 0000000..2d728ed --- /dev/null +++ b/lib/datastore.ts @@ -0,0 +1 @@ +export const KV = await Deno.openKv(); diff --git a/lib/topics.ts b/lib/topics.ts new file mode 100644 index 0000000..ac85fe9 --- /dev/null +++ b/lib/topics.ts @@ -0,0 +1,68 @@ +import { KV } from "./datastore.ts"; + +export interface Topic { + name: string; + votes: number; +} + +export const Topics = { + /// Save a new topic, idempotent because the key is derived from the topic name. + /// + /// @param topic The topic to save. + /// @returns The saved topic. + /// @throws If the topic could not be saved. + async add(topic: Omit): Promise { + const newTopic = { ...topic, votes: 0 }; + await KV.set(topicKey(newTopic), newTopic); + return newTopic; + }, + + /// Get a list of all topics. + /// + /// @returns A list of all topics. + /// @throws If the topics could not be listed. + /// @example + /// const topics = await Topics.list(); + /// console.log(topics); + /// => [{ name: "Learn", votes: 0 }, { name: "Projects", votes: 0 }] + async list(): Promise { + const keys = KV.list({ prefix: ["topics"] }); + + const topics: Topic[] = []; + for await (const { value } of keys) { + topics.push(value as Topic); + } + + return topics; + }, + + /// Increment the vote count for a topic + /// + /// @param topicName The name of the topic to upvote + /// @returns The updated topic + /// @throws If the topic could not be found or updated + async upvote(topicName: string): Promise { + return await this.updateVotes(topicName, 1); + }, + + /// Helper method to update votes + async updateVotes(topicName: string, increment: number): Promise { + const key = topicKey({ name: topicName, votes: 0 }); + const result = await KV.get(key); + const topic = result?.value as Topic | null; + + if (!topic) { + throw new Error(`Topic "${topicName}" not found`); + } + + topic.votes += increment; + await KV.set(key, topic); + + return topic; + }, +}; + +function topicKey(topic: Topic): string[] { + const hashedName = btoa(topic.name.toLowerCase().replace(/[ -_.]/g, "")); + return ["topics", hashedName]; +} diff --git a/lib/users.ts b/lib/users.ts new file mode 100644 index 0000000..108d02a --- /dev/null +++ b/lib/users.ts @@ -0,0 +1,28 @@ +import { KV } from "./datastore.ts"; + +export const Users = { + getOrCreateUserId(request: Request): string { + const cookies = request.headers.get("cookie"); + const userIdCookie = cookies?.split(";").find((c) => + c.trim().startsWith("userId=") + ); + let userId = userIdCookie?.split("=")[1]; + + if (!userId) { + userId = crypto.randomUUID(); + } + + return userId; + }, + + async hasVoted(userId: string, topicName: string): Promise { + const key = ["votes", userId, topicName]; + const result = await KV.get(key); + return result.value === true; + }, + + async recordVote(userId: string, topicName: string): Promise { + const key = ["votes", userId, topicName]; + await KV.set(key, true); + }, +}; diff --git a/routes/api/upvote.ts b/routes/api/upvote.ts new file mode 100644 index 0000000..e46f9fc --- /dev/null +++ b/routes/api/upvote.ts @@ -0,0 +1,53 @@ +import { Handlers } from "$fresh/server.ts"; +import { Topics } from "../../lib/topics.ts"; +import { Users } from "../../lib/users.ts"; + +export const handler: Handlers = { + async POST(req) { + const body = await req.json(); + const { topic } = body; + + if (typeof topic !== "string" || topic.trim() === "") { + return new Response(JSON.stringify({ error: "Invalid topic" }), { + status: 400, + headers: { "Content-Type": "application/json" }, + }); + } + + try { + const userId = await Users.getOrCreateUserId(req); + const hasVoted = await Users.hasVoted(userId, topic); + + if (hasVoted) { + return new Response(JSON.stringify({ error: "Already voted" }), { + status: 400, + headers: { "Content-Type": "application/json" }, + }); + } + + const updatedTopic = await Topics.upvote(topic.trim()); + await Users.recordVote(userId, topic); + + const response = new Response(JSON.stringify(updatedTopic), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + + // Set the userId cookie if it's a new user + if (!req.headers.get("cookie")?.includes("userId=")) { + response.headers.set( + "Set-Cookie", + `userId=${userId}; Path=/; HttpOnly; SameSite=Strict; Max-Age=31536000`, + ); + } + + return response; + } catch (error) { + console.error("Error upvoting topic:", error); + return new Response(JSON.stringify({ error: "Server error" }), { + status: 500, + headers: { "Content-Type": "application/json" }, + }); + } + }, +}; diff --git a/routes/conduct.tsx b/routes/conduct.tsx index 95b5cdb..a62c248 100644 --- a/routes/conduct.tsx +++ b/routes/conduct.tsx @@ -1,4 +1,4 @@ -import { CSS, render } from "$gfm"; +import { CSS, render } from "@deno/gfm"; import { Head } from "$fresh/runtime.ts"; import ConductReportForm from "../components/ConductReportForm.tsx"; import { title } from "../lib/title.ts"; diff --git a/routes/learn.tsx b/routes/learn.tsx new file mode 100644 index 0000000..41f0e4d --- /dev/null +++ b/routes/learn.tsx @@ -0,0 +1,106 @@ +import { Head } from "$fresh/runtime.ts"; +import { Handlers, PageProps } from "$fresh/server.ts"; +import { title } from "../lib/title.ts"; +import { Topic, Topics } from "../lib/topics.ts"; +import { Users } from "../lib/users.ts"; +import TopicItem from "../islands/TopicItem.tsx"; + +// Add this interface to define the shape of the data prop +interface LearnProps { + topics: Topic[]; + votedTopics: string[]; + error?: ErrorCode; +} + +// Update the Learn component to accept props +export default function Learn({ data }: PageProps) { + const { topics, votedTopics, error } = data; + + return ( + <> + + {title("Learn")} + +
+

+ What do you want to learn? +

+
+ +
+ {topics.map((topic) => ( + + ))} +
+
+
+ + ); +} + +// Update NewTopicForm to accept error prop +function NewTopicForm({ error }: { error?: ErrorCode }) { + const err = error ? errors[error] : null; + return ( +
+ {err &&
{err}
} +
+ + +
+
+ ); +} + +export const handler: Handlers = { + async GET(req, ctx) { + const url = new URL(req.url); + const error = url.searchParams.get("error") as ErrorCode | null; + const topics = await Topics.list(); + const userId = await Users.getOrCreateUserId(req); + + const votedTopics = await Promise.all( + topics.map(async (topic) => { + const hasVoted = await Users.hasVoted(userId, topic.name); + return hasVoted ? topic.name : null; + }), + ); + + const response = await ctx.render({ + topics, + votedTopics: votedTopics.filter((t): t is string => t !== null), + error: error || undefined, + }); + + // Set the userId cookie if it's a new user + if (!req.headers.get("cookie")?.includes("userId=")) { + response.headers.set( + "Set-Cookie", + `userId=${userId}; Path=/; HttpOnly; SameSite=Strict; Max-Age=31536000`, + ); + } + + return response; + }, +}; + +type ErrorCode = "invalid-topic" | "server-error" | "already-voted"; +const errors: Record = { + "invalid-topic": "Invalid topic. Please enter a non-empty topic.", + "server-error": "An error occurred while adding the topic. Please try again.", + "already-voted": "You have already voted for this topic.", +};