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 (
+
+ );
+}
+
+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.",
+};