浏览代码

Merge branch 'feat/user-shared-posts'

jerryliao 2 天之前
父节点
当前提交
d54e59cd82
共有 7 个文件被更改,包括 245 次插入36 次删除
  1. 3 1
      components/form/Button.tsx
  2. 45 21
      islands/PostList.tsx
  3. 2 2
      routes/[id].tsx
  4. 9 1
      routes/index.tsx
  5. 91 0
      routes/user/[name].tsx
  6. 14 0
      tests/ui/button_test.tsx
  7. 81 11
      tests/ui/post_list_test.tsx

+ 3 - 1
components/form/Button.tsx

@@ -1,7 +1,7 @@
 import { JSX } from "preact";
 
 interface ButtonProps extends JSX.ButtonHTMLAttributes<HTMLButtonElement> {
-  variant?: "default" | "primary" | "danger";
+  variant?: "default" | "primary" | "danger" | "danger-outline";
   size?: "sm" | "md" | "lg";
   children: JSX.Element | string;
 }
@@ -20,6 +20,8 @@ export default function Button({
     default: "bg-white dark:bg-gray-700 text-gray-800 dark:text-gray-100",
     primary: "bg-blue-600 text-white border-blue-600",
     danger: "bg-red-600 text-white border-red-600",
+    "danger-outline":
+      "bg-transparent text-red-600 border-red-600 dark:text-red-400 dark:border-red-400",
   };
 
   const sizeClasses = {

+ 45 - 21
islands/PostList.tsx

@@ -1,7 +1,14 @@
 import Button from "../components/form/Button.tsx";
 
 interface PostListProps {
-  posts: { id: string; title: string; content: string; shared: boolean }[];
+  posts: {
+    id: string;
+    title: string;
+    content: string;
+    shared: boolean;
+    hasPassword?: boolean;
+  }[];
+  readOnly?: boolean;
 }
 
 export default function PostList(props: PostListProps) {
@@ -38,32 +45,49 @@ export default function PostList(props: PostListProps) {
   };
 
   return (
-    <div className="w-full grid grid-cols-4 gap-4 mt-4 pb-4 overflow-auto">
+    <div className="w-full grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4 mt-4 pb-4 overflow-auto">
       {props.posts.map((post) => (
         <div
-          className="w-full min-w-[180px] border border-gray-200 dark:border-gray-700 dark:bg-gray-800 rounded box-border relative p-4 text-base flex flex-col"
+          className="w-full border border-gray-200 dark:border-gray-700 dark:bg-gray-800 rounded box-border relative p-4 text-base flex flex-col"
           key={post.id}
         >
-          <span className="font-medium mb-2">{post.title || "Untitled"}</span>
-          <div className="absolute right-4 top-3 text-2xl z-[1]">
-            <i
-              className="bi bi-x cursor-pointer"
-              onClick={() => {
-                onDelete(post.id, post.title);
-              }}
-            />
+          <div className="flex items-center justify-between mb-2">
+            <span className="font-medium line-clamp-1">
+              <a href={`/${post.id}`}>{post.title || "Untitled"}</a>
+            </span>
+            <div className="flex items-center gap-1 shrink-0">
+              {!props.readOnly && post.shared && (
+                <i className="bi bi-share-fill text-gray-500 dark:text-gray-400 text-sm" />
+              )}
+              {post.hasPassword && (
+                <i className="bi bi-lock-fill text-gray-500 dark:text-gray-400 text-sm" />
+              )}
+            </div>
           </div>
-          <span className="overflow-hidden text-ellipsis whitespace-nowrap mb-2">
+          <p className="mb-2 line-clamp-2">
             {post.content || "No content"}
-          </span>
-          <Button
-            type="button"
-            onClick={() => {
-              onEdit(post.id);
-            }}
-          >
-            Edit
-          </Button>
+          </p>
+          {!props.readOnly && (
+            <div className="flex gap-2 mt-auto">
+              <Button
+                type="button"
+                onClick={() => {
+                  onEdit(post.id);
+                }}
+              >
+                Edit
+              </Button>
+              <Button
+                type="button"
+                variant="danger-outline"
+                onClick={() => {
+                  onDelete(post.id, post.title);
+                }}
+              >
+                Delete
+              </Button>
+            </div>
+          )}
         </div>
       ))}
     </div>

+ 2 - 2
routes/[id].tsx

@@ -74,7 +74,7 @@ export default function Post(props: PageProps<PostProps>) {
     return (
       <>
         <Head>
-          <title>{props.data.title}</title>
+          <title>{props.data.title || "Untitled"}</title>
         </Head>
         <PageContainer>
           <SharePasswordFrame id={props.data.id} title={props.data.title} />
@@ -86,7 +86,7 @@ export default function Post(props: PageProps<PostProps>) {
   return (
     <>
       <Head>
-        <title>{props.data.title}</title>
+        <title>{props.data.title || "Untitled"}</title>
       </Head>
       <PageContainer>
         <TopBar

+ 9 - 1
routes/index.tsx

@@ -10,7 +10,13 @@ import PageContainer from "../components/layout/PageContainer.tsx";
 
 interface HomeProps {
   name: string;
-  list: { id: string; title: string; content: string; shared: boolean }[];
+  list: {
+    id: string;
+    title: string;
+    content: string;
+    shared: boolean;
+    hasPassword: boolean;
+  }[];
 }
 
 export const handler = define.handlers({
@@ -28,6 +34,7 @@ export const handler = define.handlers({
           "title",
           "content",
           "shared",
+          "share_password",
         ]);
         return page({
           name: user[0]["name"] as string,
@@ -36,6 +43,7 @@ export const handler = define.handlers({
             title: post["title"] as string,
             content: post["content"] as string,
             shared: post["shared"] === 1,
+            hasPassword: Boolean(post["share_password"]),
           })),
         });
       }

+ 91 - 0
routes/user/[name].tsx

@@ -0,0 +1,91 @@
+import { Head } from "fresh/runtime";
+import { page, type PageProps } from "fresh";
+import { find } from "utils/db.ts";
+import { define } from "utils/state.ts";
+import PostList from "../../islands/PostList.tsx";
+import ThemeToggle from "../../islands/ThemeToggle.tsx";
+import PageContainer from "../../components/layout/PageContainer.tsx";
+
+interface UserPageProps {
+  userName: string;
+  posts: {
+    id: string;
+    title: string;
+    content: string;
+    shared: boolean;
+    hasPassword: boolean;
+  }[];
+  notFound: boolean;
+}
+
+export const handler = define.handlers({
+  GET(ctx) {
+    const name = ctx.params.name;
+    const user = find("User", { name }, ["id"]);
+    if (user.length === 0) {
+      return page({ userName: name, posts: [], notFound: true });
+    }
+
+    const userId = user[0]["id"] as number;
+    const posts = find("Post", { user_id: userId, shared: 1 }, [
+      "id",
+      "title",
+      "content",
+      "shared",
+      "share_password",
+    ]);
+
+    if (posts.length === 0) {
+      return page({ userName: name, posts: [], notFound: true });
+    }
+
+    return page({
+      userName: name,
+      posts: posts.map((post) => ({
+        id: post["id"] as string,
+        title: post["title"] as string,
+        content: post["content"] as string,
+        shared: post["shared"] === 1,
+        hasPassword: Boolean(post["share_password"]),
+      })),
+      notFound: false,
+    });
+  },
+});
+
+export default define.page((props: PageProps<UserPageProps>) => {
+  const { userName, posts, notFound } = props.data;
+
+  if (notFound) {
+    return (
+      <>
+        <Head>
+          <title>Not Found</title>
+        </Head>
+        <PageContainer centered>
+          <div className="absolute top-3 right-3">
+            <ThemeToggle />
+          </div>
+          <span className="text-xl text-gray-500 dark:text-gray-400">
+            Page not found
+          </span>
+        </PageContainer>
+      </>
+    );
+  }
+
+  return (
+    <>
+      <Head>
+        <title>{userName}'s Posts</title>
+      </Head>
+      <PageContainer>
+        <div className="absolute top-3 right-3">
+          <ThemeToggle />
+        </div>
+        <h1 className="text-2xl font-bold mt-2">{userName}'s Posts</h1>
+        <PostList posts={posts} readOnly />
+      </PageContainer>
+    </>
+  );
+});

+ 14 - 0
tests/ui/button_test.tsx

@@ -53,6 +53,20 @@ Deno.test({
   sanitizeOps: false,
 });
 
+Deno.test({
+  name: "Button - applies danger-outline variant classes",
+  fn() {
+    render(<Button variant="danger-outline">Danger Outline</Button>);
+    const btn = screen.getByText("Danger Outline");
+    assertEquals(btn.className.includes("bg-transparent"), true);
+    assertEquals(btn.className.includes("text-red-600"), true);
+    assertEquals(btn.className.includes("border-red-600"), true);
+    cleanup();
+  },
+  sanitizeResources: false,
+  sanitizeOps: false,
+});
+
 Deno.test({
   name: "Button - applies sm size classes",
   fn() {

+ 81 - 11
tests/ui/post_list_test.tsx

@@ -2,9 +2,31 @@ import { assertEquals, cleanup, fireEvent, render, screen } from "./setup.ts";
 import PostList from "../../islands/PostList.tsx";
 
 const mockPosts = [
-  { id: "1", title: "First Post", content: "Hello world", shared: false },
-  { id: "2", title: "Second Post", content: "Another post", shared: true },
-  { id: "3", title: "", content: "", shared: false },
+  {
+    id: "1",
+    title: "First Post",
+    content: "Hello world",
+    shared: false,
+    hasPassword: false,
+  },
+  {
+    id: "2",
+    title: "Second Post",
+    content: "Another post",
+    shared: true,
+    hasPassword: false,
+  },
+  { id: "3", title: "", content: "", shared: false, hasPassword: false },
+];
+
+const mockPostWithPassword = [
+  {
+    id: "4",
+    title: "Protected Post",
+    content: "Secret",
+    shared: true,
+    hasPassword: true,
+  },
 ];
 
 Deno.test({
@@ -78,11 +100,11 @@ Deno.test({
 });
 
 Deno.test({
-  name: "PostList - each card has a delete icon",
+  name: "PostList - each card has a Delete button",
   fn() {
-    const { container } = render(<PostList posts={mockPosts} />);
-    const deleteIcons = container.querySelectorAll(".bi-x");
-    assertEquals(deleteIcons.length, 3);
+    render(<PostList posts={mockPosts} />);
+    const deleteButtons = screen.getAllByText("Delete");
+    assertEquals(deleteButtons.length, 3);
     cleanup();
   },
   sanitizeResources: false,
@@ -90,7 +112,7 @@ Deno.test({
 });
 
 Deno.test({
-  name: "PostList - delete icon triggers $modal.show",
+  name: "PostList - Delete button triggers $modal.show",
   fn() {
     let modalTitle = "";
     let modalContent = "";
@@ -102,9 +124,9 @@ Deno.test({
       hide: () => {},
     };
 
-    const { container } = render(<PostList posts={[mockPosts[0]]} />);
-    const deleteIcon = container.querySelector(".bi-x")!;
-    fireEvent.click(deleteIcon);
+    render(<PostList posts={[mockPosts[0]]} />);
+    const deleteButton = screen.getByText("Delete");
+    fireEvent.click(deleteButton);
 
     assertEquals(modalTitle, "Confirm delete");
     assertEquals(modalContent.includes("First Post"), true);
@@ -116,6 +138,54 @@ Deno.test({
   sanitizeOps: false,
 });
 
+Deno.test({
+  name: "PostList - shows shared icon for shared posts",
+  fn() {
+    const { container } = render(<PostList posts={mockPosts} />);
+    const sharedIcons = container.querySelectorAll(".bi-share-fill");
+    assertEquals(sharedIcons.length, 1);
+    cleanup();
+  },
+  sanitizeResources: false,
+  sanitizeOps: false,
+});
+
+Deno.test({
+  name: "PostList - shows lock icon for password-protected posts",
+  fn() {
+    const { container } = render(<PostList posts={mockPostWithPassword} />);
+    const lockIcons = container.querySelectorAll(".bi-lock-fill");
+    assertEquals(lockIcons.length, 1);
+    cleanup();
+  },
+  sanitizeResources: false,
+  sanitizeOps: false,
+});
+
+Deno.test({
+  name: "PostList - hides shared icon in readOnly mode",
+  fn() {
+    const { container } = render(<PostList posts={mockPosts} readOnly />);
+    const sharedIcons = container.querySelectorAll(".bi-share-fill");
+    assertEquals(sharedIcons.length, 0);
+    cleanup();
+  },
+  sanitizeResources: false,
+  sanitizeOps: false,
+});
+
+Deno.test({
+  name: "PostList - hides Edit and Delete buttons in readOnly mode",
+  fn() {
+    render(<PostList posts={mockPosts} readOnly />);
+    assertEquals(screen.queryByText("Edit"), null);
+    assertEquals(screen.queryByText("Delete"), null);
+    cleanup();
+  },
+  sanitizeResources: false,
+  sanitizeOps: false,
+});
+
 Deno.test({
   name: "PostList - renders empty grid with no posts",
   fn() {