From d0b18859f8794857edf202d415e8acb8f2276e8a Mon Sep 17 00:00:00 2001 From: Arjun Komath Date: Sat, 25 Oct 2025 18:01:19 +1100 Subject: [PATCH 1/7] feat: Posts --- .../projects/[projectId]/posts/page.tsx | 106 ++ bun.lock | 97 +- components/core/search-panel.tsx | 25 +- components/form/post.tsx | 183 +++ components/layout/navbar.tsx | 7 + components/project/posts/posts-list.tsx | 237 ++++ components/ui/tabs.tsx | 55 + drizzle/0008_soft_serpent_society.sql | 16 + drizzle/meta/0008_snapshot.json | 1164 +++++++++++++++++ drizzle/meta/_journal.json | 7 + drizzle/schema.ts | 30 + lib/search/helpers.ts | 24 + lib/search/index.ts | 57 +- package.json | 3 +- scripts/migrate-all-tenants.ts | 23 +- scripts/post-upgrade-maintenance.ts | 23 +- trpc/routers/_app.ts | 2 + trpc/routers/posts.ts | 356 +++++ trpc/routers/search.ts | 28 +- 19 files changed, 2374 insertions(+), 69 deletions(-) create mode 100644 app/(dashboard)/[tenant]/projects/[projectId]/posts/page.tsx create mode 100644 components/form/post.tsx create mode 100644 components/project/posts/posts-list.tsx create mode 100644 components/ui/tabs.tsx create mode 100644 drizzle/0008_soft_serpent_society.sql create mode 100644 drizzle/meta/0008_snapshot.json create mode 100644 trpc/routers/posts.ts diff --git a/app/(dashboard)/[tenant]/projects/[projectId]/posts/page.tsx b/app/(dashboard)/[tenant]/projects/[projectId]/posts/page.tsx new file mode 100644 index 0000000..92d2226 --- /dev/null +++ b/app/(dashboard)/[tenant]/projects/[projectId]/posts/page.tsx @@ -0,0 +1,106 @@ +"use client"; + +import { Title } from "@radix-ui/react-dialog"; +import { useQuery } from "@tanstack/react-query"; +import Link from "next/link"; +import { useParams } from "next/navigation"; +import { parseAsBoolean, useQueryState } from "nuqs"; +import EmptyState from "@/components/core/empty-state"; +import { Panel } from "@/components/core/panel"; +import PageSection from "@/components/core/section"; +import PostForm from "@/components/form/post"; +import PageTitle from "@/components/layout/page-title"; +import PostsList from "@/components/project/posts/posts-list"; +import { Button, buttonVariants } from "@/components/ui/button"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { useTRPC } from "@/trpc/client"; +import { useState } from "react"; + +export default function Posts() { + const { projectId, tenant } = useParams(); + const [create, setCreate] = useQueryState( + "create", + parseAsBoolean.withDefault(false), + ); + const [activeTab, setActiveTab] = useState("published"); + + const trpc = useTRPC(); + + const { data: project } = useQuery( + trpc.projects.getProjectById.queryOptions({ + id: +projectId!, + }), + ); + + const { data: publishedPosts = [] } = useQuery({ + ...trpc.posts.list.queryOptions({ + projectId: +projectId!, + }), + enabled: activeTab === "published", + }); + + const { data: myDrafts = [] } = useQuery({ + ...trpc.posts.myDrafts.queryOptions({ + projectId: +projectId!, + }), + enabled: activeTab === "drafts", + }); + + return ( + <> + + New + + ) : undefined + } + /> + + + + + Published + My Drafts + + + + {publishedPosts.length ? ( + + ) : ( + + )} + + + + {myDrafts.length ? ( + + ) : ( +
+ No draft posts +
+ )} +
+
+
+ + {project?.canEdit && ( + + + <PageTitle title="New Post" compact /> + + + + )} + + ); +} diff --git a/bun.lock b/bun.lock index 0e7895f..3725915 100644 --- a/bun.lock +++ b/bun.lock @@ -28,6 +28,7 @@ "@radix-ui/react-separator": "^1.1.4", "@radix-ui/react-slot": "^1.2.0", "@radix-ui/react-switch": "^1.1.0", + "@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tooltip": "^1.2.3", "@react-email/components": "^0.1.1", "@sentry/nextjs": "^10.21.0", @@ -70,7 +71,7 @@ "react-dom": "19.2.0", "react-email": "^4.0.17", "react-markdown": "^10.1.0", - "resend": "^4.1.2", + "resend": "6.3.0-canary.4", "rrule": "^2.8.1", "sharp": "^0.33.4", "slugify": "^1.6.6", @@ -495,8 +496,6 @@ "@nolyfill/is-core-module": ["@nolyfill/is-core-module@1.0.39", "", {}, "sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA=="], - "@one-ini/wasm": ["@one-ini/wasm@0.1.1", "", {}, "sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw=="], - "@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="], "@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.204.0", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-DqxY8yoAaiBPivoJD4UtgrMS8gEmzZ5lnaxzPojzLVHBGqPxgWm4zcuvcUHZiqQ6kRX2Klel2r9y8cA2HAtqpw=="], @@ -615,7 +614,7 @@ "@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.0.2", "", { "dependencies": { "@radix-ui/react-slot": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Ec/0d38EIuvDF+GZjcMU/Ze6MxntVJYO/fRlCPhCaVUyPY9WTalHJw54tp9sXeJo3tlShWpy41vQRgLRGOuz+w=="], - "@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.2", "", { "dependencies": { "@radix-ui/primitive": "1.1.1", "@radix-ui/react-collection": "1.1.2", "@radix-ui/react-compose-refs": "1.1.1", "@radix-ui/react-context": "1.1.1", "@radix-ui/react-direction": "1.1.0", "@radix-ui/react-id": "1.1.0", "@radix-ui/react-primitive": "2.0.2", "@radix-ui/react-use-callback-ref": "1.1.0", "@radix-ui/react-use-controllable-state": "1.1.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-zgMQWkNO169GtGqRvYrzb0Zf8NhMHS2DuEB/TiEmVnpr5OqPU3i8lfbxaAmC2J/KYuIQxyoQQ6DxepyXp61/xw=="], + "@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA=="], "@radix-ui/react-scroll-area": ["@radix-ui/react-scroll-area@1.2.3", "", { "dependencies": { "@radix-ui/number": "1.1.0", "@radix-ui/primitive": "1.1.1", "@radix-ui/react-compose-refs": "1.1.1", "@radix-ui/react-context": "1.1.1", "@radix-ui/react-direction": "1.1.0", "@radix-ui/react-presence": "1.1.2", "@radix-ui/react-primitive": "2.0.2", "@radix-ui/react-use-callback-ref": "1.1.0", "@radix-ui/react-use-layout-effect": "1.1.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-l7+NNBfBYYJa9tNqVcP2AGvxdE3lmE6kFTBXdvHgUaZuy+4wGCL1Cl2AfaR7RKyimj7lZURGLwFO59k4eBnDJQ=="], @@ -627,6 +626,8 @@ "@radix-ui/react-switch": ["@radix-ui/react-switch@1.1.3", "", { "dependencies": { "@radix-ui/primitive": "1.1.1", "@radix-ui/react-compose-refs": "1.1.1", "@radix-ui/react-context": "1.1.1", "@radix-ui/react-primitive": "2.0.2", "@radix-ui/react-use-controllable-state": "1.1.0", "@radix-ui/react-use-previous": "1.1.0", "@radix-ui/react-use-size": "1.1.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-1nc+vjEOQkJVsJtWPSiISGT6OKm4SiOdjMo+/icLxo2G4vxz1GntC5MzfL4v8ey9OEfw787QCD1y3mUv0NiFEQ=="], + "@radix-ui/react-tabs": ["@radix-ui/react-tabs@1.1.13", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A=="], + "@radix-ui/react-tooltip": ["@radix-ui/react-tooltip@1.2.3", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.4", "@radix-ui/react-portal": "1.1.6", "@radix-ui/react-presence": "1.1.3", "@radix-ui/react-primitive": "2.1.0", "@radix-ui/react-slot": "1.2.0", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-visually-hidden": "1.2.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-0KX7jUYFA02np01Y11NWkk6Ip6TqMNmD4ijLelYAzeIndl2aVeltjJFJ2gwjNa1P8U/dgjQ+8cr9Y3Ni+ZNoRA=="], "@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.0", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-CasTfvsy+frcFkbXtSJ2Zu9JHpN8TYKxkgJGWbjiZhFivxaeW7rMeZt7QELGVLaYVfFMsKHjb7Ak0nMEe+2Vfw=="], @@ -1127,8 +1128,6 @@ "@xtuc/long": ["@xtuc/long@4.2.2", "", {}, "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ=="], - "abbrev": ["abbrev@2.0.0", "", {}, "sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ=="], - "accepts": ["accepts@1.3.8", "", { "dependencies": { "mime-types": "~2.1.34", "negotiator": "0.6.3" } }, "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw=="], "acorn": ["acorn@8.14.1", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg=="], @@ -1291,8 +1290,6 @@ "concat-stream": ["concat-stream@2.0.0", "", { "dependencies": { "buffer-from": "^1.0.0", "inherits": "^2.0.3", "readable-stream": "^3.0.2", "typedarray": "^0.0.6" } }, "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A=="], - "config-chain": ["config-chain@1.1.13", "", { "dependencies": { "ini": "^1.3.4", "proto-list": "~1.2.1" } }, "sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ=="], - "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], "cookie": ["cookie@1.0.2", "", {}, "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA=="], @@ -1379,8 +1376,6 @@ "eastasianwidth": ["eastasianwidth@0.2.0", "", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="], - "editorconfig": ["editorconfig@1.0.4", "", { "dependencies": { "@one-ini/wasm": "0.1.1", "commander": "^10.0.0", "minimatch": "9.0.1", "semver": "^7.5.3" }, "bin": { "editorconfig": "bin/editorconfig" } }, "sha512-L9Qe08KWTlqYMVvMcTIvMAdl1cDUubzRNYL+WfA4bLDMHe4nemKkpmYzkznE1FwLKu0EEmy6obgQKzMJrg4x9Q=="], - "electron-to-chromium": ["electron-to-chromium@1.5.120", "", {}, "sha512-oTUp3gfX1gZI+xfD2djr2rzQdHCwHzPQrrK0CD7WpTdF0nPdQ/INcRVjWgLdCT4a9W3jFObR9DAfsuyFQnI8CQ=="], "emoji-mart": ["emoji-mart@5.6.0", "", {}, "sha512-eJp3QRe79pjwa+duv+n7+5YsNhRcMl812EcFVwrnRvYKoNPoQb5qxU8DG6Bgwji0akHdp6D4Ln6tYLG58MFSow=="], @@ -1635,8 +1630,6 @@ "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], - "ini": ["ini@1.3.8", "", {}, "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew=="], - "inline-style-parser": ["inline-style-parser@0.2.4", "", {}, "sha512-0aO8FkhNZlj/ZIbNi7Lxxr12obT7cL1moPfE4tg1LkX7LlLfC6DeX4l2ZEud1ukP9jNQyNnfzQVqwbwmAATY4Q=="], "internal-slot": ["internal-slot@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "hasown": "^2.0.2", "side-channel": "^1.1.0" } }, "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw=="], @@ -1735,8 +1728,6 @@ "jose": ["jose@5.10.0", "", {}, "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg=="], - "js-beautify": ["js-beautify@1.15.4", "", { "dependencies": { "config-chain": "^1.1.13", "editorconfig": "^1.0.4", "glob": "^10.4.2", "js-cookie": "^3.0.5", "nopt": "^7.2.1" }, "bin": { "css-beautify": "js/bin/css-beautify.js", "html-beautify": "js/bin/html-beautify.js", "js-beautify": "js/bin/js-beautify.js" } }, "sha512-9/KXeZUKKJwqCXUdBxFJ3vPh467OCckSBmYDwSK/EtV090K+iMJ7zx2S3HLVDIWFQdqMIsZWbnaGiba18aWhaA=="], - "js-cookie": ["js-cookie@3.0.5", "", {}, "sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw=="], "js-sdsl": ["js-sdsl@4.3.0", "", {}, "sha512-mifzlm2+5nZ+lEcLJMoBK0/IH/bDg8XnJfd/Wq6IP+xoCjLZsTOnV2QpxlVbX9bMnkl5PdEjNtBJ9Cj1NjifhQ=="], @@ -1963,8 +1954,6 @@ "node-releases": ["node-releases@2.0.19", "", {}, "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw=="], - "nopt": ["nopt@7.2.1", "", { "dependencies": { "abbrev": "^2.0.0" }, "bin": { "nopt": "bin/nopt.js" } }, "sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w=="], - "normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="], "normalize-range": ["normalize-range@0.1.2", "", {}, "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA=="], @@ -2139,8 +2128,6 @@ "prosemirror-view": ["prosemirror-view@1.39.1", "", { "dependencies": { "prosemirror-model": "^1.20.0", "prosemirror-state": "^1.0.0", "prosemirror-transform": "^1.1.0" } }, "sha512-GhLxH1xwnqa5VjhJ29LfcQITNDp+f1jzmMPXQfGW9oNrF0lfjPzKvV5y/bjIQkyKpwCX3Fp+GA4dBpMMk8g+ZQ=="], - "proto-list": ["proto-list@1.2.4", "", {}, "sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA=="], - "proxy-from-env": ["proxy-from-env@1.1.0", "", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="], "pump": ["pump@3.0.2", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-tUPXtzlGM8FE3P0ZL6DVs/3P58k9nk8/jZeQCurTJylQA8qFYzHFfhBJkuqyE0FifOsQ0uKWekiZ5g8wtr28cw=="], @@ -2219,7 +2206,7 @@ "requires-port": ["requires-port@1.0.0", "", {}, "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ=="], - "resend": ["resend@4.1.2", "", { "dependencies": { "@react-email/render": "1.0.1" } }, "sha512-km0btrAj/BqIaRlS+SoLNMaCAUUWEgcEvZpycfVvoXEwAHCxU+vp/ikxPgKRkyKyiR2iDcdUq5uIBTDK9oSSSQ=="], + "resend": ["resend@6.3.0-canary.4", "", { "dependencies": { "svix": "1.76.1" }, "peerDependencies": { "@react-email/render": "*" }, "optionalPeers": ["@react-email/render"] }, "sha512-jjP8k98qUEIU04uQXNf6+NoBETEYuVROlsVX0N7V/mYfz4Hetb2zDqiNHysYYLbMVTWKbX4H/CeclYNTQ/FIMA=="], "resolve": ["resolve@1.22.8", "", { "dependencies": { "is-core-module": "^2.13.0", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw=="], @@ -2645,6 +2632,8 @@ "@radix-ui/react-menu/@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.4", "", { "dependencies": { "@radix-ui/react-primitive": "2.0.2", "@radix-ui/react-use-layout-effect": "1.1.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-sn2O9k1rPFYVyKd5LAJfo96JlSGVFpa1fS6UuBJfrZadudiw5tAmru+n1x7aMRQ84qDM71Zh1+SzK5QwU0tJfA=="], + "@radix-ui/react-menu/@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.2", "", { "dependencies": { "@radix-ui/primitive": "1.1.1", "@radix-ui/react-collection": "1.1.2", "@radix-ui/react-compose-refs": "1.1.1", "@radix-ui/react-context": "1.1.1", "@radix-ui/react-direction": "1.1.0", "@radix-ui/react-id": "1.1.0", "@radix-ui/react-primitive": "2.0.2", "@radix-ui/react-use-callback-ref": "1.1.0", "@radix-ui/react-use-controllable-state": "1.1.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-zgMQWkNO169GtGqRvYrzb0Zf8NhMHS2DuEB/TiEmVnpr5OqPU3i8lfbxaAmC2J/KYuIQxyoQQ6DxepyXp61/xw=="], + "@radix-ui/react-menu/@radix-ui/react-slot": ["@radix-ui/react-slot@1.1.2", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ=="], "@radix-ui/react-popover/@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.5", "", { "dependencies": { "@radix-ui/primitive": "1.1.1", "@radix-ui/react-compose-refs": "1.1.1", "@radix-ui/react-primitive": "2.0.2", "@radix-ui/react-use-callback-ref": "1.1.0", "@radix-ui/react-use-escape-keydown": "1.1.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-E4TywXY6UsXNRhFrECa5HAvE5/4BFcGyfTyK36gP+pAW1ed7UTK4vKwdr53gAJYwqbfCWC6ATvJa3J3R/9+Qrg=="], @@ -2663,6 +2652,24 @@ "@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.1.2", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ=="], + "@radix-ui/react-roving-focus/@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="], + + "@radix-ui/react-roving-focus/@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw=="], + + "@radix-ui/react-roving-focus/@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg=="], + + "@radix-ui/react-roving-focus/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], + + "@radix-ui/react-roving-focus/@radix-ui/react-direction": ["@radix-ui/react-direction@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw=="], + + "@radix-ui/react-roving-focus/@radix-ui/react-id": ["@radix-ui/react-id@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg=="], + + "@radix-ui/react-roving-focus/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + + "@radix-ui/react-roving-focus/@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg=="], + + "@radix-ui/react-roving-focus/@radix-ui/react-use-controllable-state": ["@radix-ui/react-use-controllable-state@1.2.2", "", { "dependencies": { "@radix-ui/react-use-effect-event": "0.0.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg=="], + "@radix-ui/react-select/@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.5", "", { "dependencies": { "@radix-ui/primitive": "1.1.1", "@radix-ui/react-compose-refs": "1.1.1", "@radix-ui/react-primitive": "2.0.2", "@radix-ui/react-use-callback-ref": "1.1.0", "@radix-ui/react-use-escape-keydown": "1.1.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-E4TywXY6UsXNRhFrECa5HAvE5/4BFcGyfTyK36gP+pAW1ed7UTK4vKwdr53gAJYwqbfCWC6ATvJa3J3R/9+Qrg=="], "@radix-ui/react-select/@radix-ui/react-focus-guards": ["@radix-ui/react-focus-guards@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-pSIwfrT1a6sIoDASCSpFwOasEwKTZWDw/iBdtnqKO7v6FeOzYJ7U53cPzYFVR3geGGXgVHaH+CdngrrAzqUGxg=="], @@ -2677,6 +2684,20 @@ "@radix-ui/react-slot/@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg=="], + "@radix-ui/react-tabs/@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="], + + "@radix-ui/react-tabs/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], + + "@radix-ui/react-tabs/@radix-ui/react-direction": ["@radix-ui/react-direction@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw=="], + + "@radix-ui/react-tabs/@radix-ui/react-id": ["@radix-ui/react-id@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg=="], + + "@radix-ui/react-tabs/@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.5", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ=="], + + "@radix-ui/react-tabs/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + + "@radix-ui/react-tabs/@radix-ui/react-use-controllable-state": ["@radix-ui/react-use-controllable-state@1.2.2", "", { "dependencies": { "@radix-ui/react-use-effect-event": "0.0.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg=="], + "@radix-ui/react-tooltip/@radix-ui/primitive": ["@radix-ui/primitive@1.1.2", "", {}, "sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA=="], "@radix-ui/react-tooltip/@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg=="], @@ -2771,10 +2792,6 @@ "concat-stream/readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], - "editorconfig/commander": ["commander@10.0.1", "", {}, "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug=="], - - "editorconfig/minimatch": ["minimatch@9.0.1", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-0jWhJpD/MdhPXwPuiRkCbfYfSKp2qnn2eOc279qI7f+osl/l+prKSrvhg157zSYvx/1nmgn2NqdT6k2Z7zSH9w=="], - "engine.io/@types/node": ["@types/node@22.14.0", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-Kmpl+z84ILoG+3T/zQFyAJsU6EPTmOCj8/2+83fSN6djd6I4o7uOuGIH6vq3PrjY5BGitSbFuMN18j3iknubbA=="], "engine.io/cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], @@ -2825,8 +2842,6 @@ "jest-worker/supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="], - "js-beautify/glob": ["glob@10.4.5", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg=="], - "jsondiffpatch/chalk": ["chalk@5.4.1", "", {}, "sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w=="], "jsx-ast-utils/array-includes": ["array-includes@3.1.8", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-abstract": "^1.23.2", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.4", "is-string": "^1.0.7" } }, "sha512-itaWrbYbqpGXkGhZPGUulwnhVf5Hpy1xiCFsGqyIGglbBxmG5vSjxQen3/WGOjPpNEv1RtBLKxbmVXm8HpJStQ=="], @@ -2885,7 +2900,7 @@ "require-in-the-middle/resolve": ["resolve@1.22.10", "", { "dependencies": { "is-core-module": "^2.16.0", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w=="], - "resend/@react-email/render": ["@react-email/render@1.0.1", "", { "dependencies": { "html-to-text": "9.0.5", "js-beautify": "^1.14.11", "react-promise-suspense": "0.3.4" }, "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-W3gTrcmLOVYnG80QuUp22ReIT/xfLsVJ+n7ghSlG2BITB8evNABn1AO2rGQoXuK84zKtDAlxCdm3hRyIpZdGSA=="], + "resend/svix": ["svix@1.76.1", "", { "dependencies": { "@stablelib/base64": "^1.0.0", "@types/node": "^22.7.5", "es6-promise": "^4.2.8", "fast-sha256": "^1.3.0", "url-parse": "^1.5.10", "uuid": "^10.0.0" } }, "sha512-CRuDWBTgYfDnBLRaZdKp9VuoPcNUq9An14c/k+4YJ15Qc5Grvf66vp0jvTltd4t7OIRj+8lM1DAgvSgvf7hdLw=="], "schema-utils/ajv": ["ajv@8.17.1", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g=="], @@ -3021,8 +3036,26 @@ "@radix-ui/react-popover/@radix-ui/react-dismissable-layer/@radix-ui/react-use-escape-keydown": ["@radix-ui/react-use-escape-keydown@1.1.0", "", { "dependencies": { "@radix-ui/react-use-callback-ref": "1.1.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-L7vwWlR1kTTQ3oh7g1O0CBF3YCyyTj8NmhLR+phShpyA50HCfBFKVJTpshm9PzLiKmehsrQzTYTpX9HvmC9rhw=="], + "@radix-ui/react-roving-focus/@radix-ui/react-collection/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + + "@radix-ui/react-roving-focus/@radix-ui/react-id/@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ=="], + + "@radix-ui/react-roving-focus/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + + "@radix-ui/react-roving-focus/@radix-ui/react-use-controllable-state/@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ=="], + "@radix-ui/react-select/@radix-ui/react-dismissable-layer/@radix-ui/react-use-escape-keydown": ["@radix-ui/react-use-escape-keydown@1.1.0", "", { "dependencies": { "@radix-ui/react-use-callback-ref": "1.1.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-L7vwWlR1kTTQ3oh7g1O0CBF3YCyyTj8NmhLR+phShpyA50HCfBFKVJTpshm9PzLiKmehsrQzTYTpX9HvmC9rhw=="], + "@radix-ui/react-tabs/@radix-ui/react-id/@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ=="], + + "@radix-ui/react-tabs/@radix-ui/react-presence/@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg=="], + + "@radix-ui/react-tabs/@radix-ui/react-presence/@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ=="], + + "@radix-ui/react-tabs/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + + "@radix-ui/react-tabs/@radix-ui/react-use-controllable-state/@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ=="], + "@radix-ui/react-tooltip/@radix-ui/react-id/@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ=="], "@radix-ui/react-tooltip/@radix-ui/react-popper/@radix-ui/react-arrow": ["@radix-ui/react-arrow@1.1.4", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-qz+fxrqgNxG0dYew5l7qR3c7wdgRu1XVUHGnGYX7rg5HM4p9SWaRmJwfgR3J0SgyUKayLmzQIun+N6rWRgiRKw=="], @@ -3091,10 +3124,6 @@ "help-me/readable-stream/string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="], - "js-beautify/glob/jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="], - - "js-beautify/glob/path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="], - "jsx-ast-utils/array-includes/es-abstract": ["es-abstract@1.23.9", "", { "dependencies": { "array-buffer-byte-length": "^1.0.2", "arraybuffer.prototype.slice": "^1.0.4", "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "call-bound": "^1.0.3", "data-view-buffer": "^1.0.2", "data-view-byte-length": "^1.0.2", "data-view-byte-offset": "^1.0.1", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "es-set-tostringtag": "^2.1.0", "es-to-primitive": "^1.3.0", "function.prototype.name": "^1.1.8", "get-intrinsic": "^1.2.7", "get-proto": "^1.0.0", "get-symbol-description": "^1.1.0", "globalthis": "^1.0.4", "gopd": "^1.2.0", "has-property-descriptors": "^1.0.2", "has-proto": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "internal-slot": "^1.1.0", "is-array-buffer": "^3.0.5", "is-callable": "^1.2.7", "is-data-view": "^1.0.2", "is-regex": "^1.2.1", "is-shared-array-buffer": "^1.0.4", "is-string": "^1.1.1", "is-typed-array": "^1.1.15", "is-weakref": "^1.1.0", "math-intrinsics": "^1.1.0", "object-inspect": "^1.13.3", "object-keys": "^1.1.1", "object.assign": "^4.1.7", "own-keys": "^1.0.1", "regexp.prototype.flags": "^1.5.3", "safe-array-concat": "^1.1.3", "safe-push-apply": "^1.0.0", "safe-regex-test": "^1.1.0", "set-proto": "^1.0.0", "string.prototype.trim": "^1.2.10", "string.prototype.trimend": "^1.0.9", "string.prototype.trimstart": "^1.0.8", "typed-array-buffer": "^1.0.3", "typed-array-byte-length": "^1.0.3", "typed-array-byte-offset": "^1.0.4", "typed-array-length": "^1.0.7", "unbox-primitive": "^1.1.0", "which-typed-array": "^1.1.18" } }, "sha512-py07lI0wjxAC/DcfK1S6G7iANonniZwTISvdPzk9hzeH0IZIshbuuFxLIU96OyF89Yb9hiqWn8M/bY83KY5vzA=="], "mqtt/readable-stream/string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="], @@ -3169,6 +3198,10 @@ "react-email/next/sharp": ["sharp@0.34.1", "", { "dependencies": { "color": "^4.2.3", "detect-libc": "^2.0.3", "semver": "^7.7.1" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.1", "@img/sharp-darwin-x64": "0.34.1", "@img/sharp-libvips-darwin-arm64": "1.1.0", "@img/sharp-libvips-darwin-x64": "1.1.0", "@img/sharp-libvips-linux-arm": "1.1.0", "@img/sharp-libvips-linux-arm64": "1.1.0", "@img/sharp-libvips-linux-ppc64": "1.1.0", "@img/sharp-libvips-linux-s390x": "1.1.0", "@img/sharp-libvips-linux-x64": "1.1.0", "@img/sharp-libvips-linuxmusl-arm64": "1.1.0", "@img/sharp-libvips-linuxmusl-x64": "1.1.0", "@img/sharp-linux-arm": "0.34.1", "@img/sharp-linux-arm64": "0.34.1", "@img/sharp-linux-s390x": "0.34.1", "@img/sharp-linux-x64": "0.34.1", "@img/sharp-linuxmusl-arm64": "0.34.1", "@img/sharp-linuxmusl-x64": "0.34.1", "@img/sharp-wasm32": "0.34.1", "@img/sharp-win32-ia32": "0.34.1", "@img/sharp-win32-x64": "0.34.1" } }, "sha512-1j0w61+eVxu7DawFJtnfYcvSv6qPFvfTaqzTQ2BLknVhHTwGS8sc63ZBF4rzkWMBVKybo4S5OBtDdZahh2A1xg=="], + "resend/svix/@types/node": ["@types/node@22.18.10", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-anNG/V/Efn/YZY4pRzbACnKxNKoBng2VTFydVu8RRs5hQjikP8CQfaeAV59VFSCzKNp90mXiVXW2QzV56rwMrg=="], + + "resend/svix/uuid": ["uuid@10.0.0", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ=="], + "schema-utils/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], "string-width-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], @@ -3197,6 +3230,8 @@ "@aws-crypto/util/@smithy/util-utf8/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="], + "@radix-ui/react-tabs/@radix-ui/react-primitive/@radix-ui/react-slot/@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg=="], + "@sentry/bundler-plugin-core/glob/path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], "@sentry/bundler-plugin-core/glob/path-scurry/minipass": ["minipass@7.1.2", "", {}, "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw=="], @@ -3221,8 +3256,6 @@ "help-me/glob/minimatch/brace-expansion": ["brace-expansion@1.1.11", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA=="], - "js-beautify/glob/path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], - "next/sharp/@img/sharp-wasm32/@emnapi/runtime": ["@emnapi/runtime@1.6.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-obtUmAHTMjll499P+D9A3axeJFlhdjOWdKUNs/U6QIGT7V5RjcUW1xToAzjvmgTSQhDbYn/NwfTRoJcQ2rNBxA=="], "react-email/next/sharp/@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.1", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.1.0" }, "os": "darwin", "cpu": "arm64" }, "sha512-pn44xgBtgpEbZsu+lWf2KNb6OAf70X68k+yk69Ic2Xz11zHR/w24/U49XT7AeRwJ0Px+mhALhU5LPci1Aymk7A=="], diff --git a/components/core/search-panel.tsx b/components/core/search-panel.tsx index 8fa29e4..a761d8c 100644 --- a/components/core/search-panel.tsx +++ b/components/core/search-panel.tsx @@ -10,6 +10,7 @@ import { FileText, Filter, FolderOpen, + MessagesSquare, RefreshCw, Search, X, @@ -36,7 +37,7 @@ interface SearchResult { id: string; title: string; description?: string; - type: "project" | "task" | "tasklist" | "event"; + type: "project" | "task" | "tasklist" | "event" | "post"; status?: string; projectName?: string; url: string; @@ -56,6 +57,8 @@ const getTypeIcon = (type: string) => { return ; case "event": return ; + case "post": + return ; default: return ; } @@ -71,6 +74,8 @@ const getTypeLabel = (type: string) => { return "Task List"; case "event": return "Event"; + case "post": + return "Post"; default: return type; } @@ -105,7 +110,7 @@ export function SearchSheet({ open, onOpenChange }: SearchSheetProps) { const [query, setQuery] = useState(""); const [typeFilter, setTypeFilter] = useState< - "project" | "task" | "tasklist" | "event" | undefined + "project" | "task" | "tasklist" | "event" | "post" | undefined >(); const [projectFilter, setProjectFilter] = useState(); const [statusFilter, setStatusFilter] = useState(); @@ -146,7 +151,7 @@ export function SearchSheet({ open, onOpenChange }: SearchSheetProps) { try { const result = await indexAllMutation.mutateAsync(); toast.success("Content reindexed successfully!", { - description: `Indexed ${result.indexed.projects} projects, ${result.indexed.taskLists} task lists, ${result.indexed.tasks} tasks, and ${result.indexed.events} events.`, + description: `Indexed ${result.indexed.projects} projects, ${result.indexed.taskLists} task lists, ${result.indexed.tasks} tasks, ${result.indexed.events} events, and ${result.indexed.posts} posts.`, }); } catch (err) { console.error("Error reindexing content:", err); @@ -185,7 +190,7 @@ export function SearchSheet({ open, onOpenChange }: SearchSheetProps) { const groupedResults = groupBy< SearchResult, - "project" | "task" | "tasklist" | "event" + "project" | "task" | "tasklist" | "event" | "post" >(searchResults, (item) => item.type); // Reset search when sheet closes @@ -203,7 +208,7 @@ export function SearchSheet({ open, onOpenChange }: SearchSheetProps) {

Search

- Search across all your projects, tasks, events, and more + Search across all your projects, tasks, events, posts, and more

+ + + + + ); + }, +); + +export default PostForm; diff --git a/components/layout/navbar.tsx b/components/layout/navbar.tsx index dea945b..23fa1c8 100644 --- a/components/layout/navbar.tsx +++ b/components/layout/navbar.tsx @@ -94,6 +94,13 @@ export function Navbar({ notificationsWire }: { notificationsWire: string }) { `/${tenant}/projects/${projectId}/events`, ), }, + { + href: `/${tenant}/projects/${projectId}/posts`, + label: "Posts", + active: pathname.startsWith( + `/${tenant}/projects/${projectId}/posts`, + ), + }, { href: `/${tenant}/projects/${projectId}/activity`, label: "Activity", diff --git a/components/project/posts/posts-list.tsx b/components/project/posts/posts-list.tsx new file mode 100644 index 0000000..b2675a6 --- /dev/null +++ b/components/project/posts/posts-list.tsx @@ -0,0 +1,237 @@ +"use client"; + +import { useUser } from "@clerk/nextjs"; +import { Title } from "@radix-ui/react-dialog"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { CircleEllipsisIcon } from "lucide-react"; +import { useParams } from "next/navigation"; +import { useState } from "react"; +import { HtmlPreview } from "@/components/core/html-view"; +import { Panel } from "@/components/core/panel"; +import { UserAvatar } from "@/components/core/user-avatar"; +import { DeleteButton } from "@/components/form/button"; +import PostForm from "@/components/form/post"; +import PageTitle from "@/components/layout/page-title"; +import { CommentsSection } from "@/components/project/comment/comments-section"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { displayMutationError } from "@/lib/utils/error"; +import { useTRPC } from "@/trpc/client"; +import { formatDistanceToNow } from "date-fns"; + +const categoryColors = { + announcement: "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200", + fyi: "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200", + question: "bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200", +}; + +const formatCategory = (category: string) => { + if (category === "fyi") return "FYI"; + return category.charAt(0).toUpperCase() + category.slice(1); +}; + +interface Post { + id: number; + title: string; + content: string | null; + metadata: any; + category: string; + isDraft: boolean; + publishedAt: Date | null; + updatedAt: Date; + creator: { + id: string; + firstName: string | null; + lastName: string | null; + imageUrl: string | null; + }; +} + +export default function PostsList({ + posts, + projectId, + isDraft = false, +}: { + posts: Post[]; + projectId: number; + isDraft?: boolean; +}) { + const { user } = useUser(); + const [editing, setEditing] = useState(null); + const [viewing, setViewing] = useState(null); + + const trpc = useTRPC(); + const queryClient = useQueryClient(); + + const { data: viewingPost } = useQuery({ + ...trpc.posts.get.queryOptions({ + id: viewing!, + }), + enabled: !!viewing, + }); + + const deletePost = useMutation( + trpc.posts.delete.mutationOptions({ + onSuccess: () => { + setViewing(null); + setEditing(null); + queryClient.invalidateQueries({ + queryKey: trpc.posts.list.queryKey({ + projectId, + }), + }); + queryClient.invalidateQueries({ + queryKey: trpc.posts.myDrafts.queryKey({ + projectId, + }), + }); + }, + onError: displayMutationError, + }), + ); + + return ( +
+ {posts.map((post) => ( +
+
setViewing(post.id)} + > +
+ +
+

{post.title}

+
+ + {post.publishedAt + ? formatDistanceToNow(new Date(post.publishedAt), { + addSuffix: true, + }) + : formatDistanceToNow(new Date(post.updatedAt), { + addSuffix: true, + })} + +
+
+
+ + {formatCategory(post.category)} + + {post.isDraft && ( + Draft + )} +
+
+
+ + setViewing(null)}> + + <PageTitle + title={viewingPost?.title || ""} + actions={ + <div className="flex items-center gap-2"> + {viewingPost?.createdByUser === user?.id && ( + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button variant="ghost" size="icon"> + <CircleEllipsisIcon className="h-5 w-5" /> + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent align="end"> + <DropdownMenuItem className="w-full p-0"> + <Button + variant="ghost" + className="w-full" + size="sm" + onClick={() => { + setViewing(null); + setEditing(post.id); + }} + > + Edit + </Button> + </DropdownMenuItem> + <DropdownMenuItem className="w-full p-0"> + <form + action={async () => { + await deletePost.mutateAsync({ + id: post.id, + }); + }} + className="w-full" + > + <DeleteButton + action="Delete" + className="w-full" + compact + /> + </form> + </DropdownMenuItem> + </DropdownMenuContent> + </DropdownMenu> + )} + <Button variant="outline" onClick={() => setViewing(null)}> + Close + </Button> + </div> + } + compact + /> + + {viewingPost && ( +
+
+ +
+
+ {viewingPost.creator.firstName} {viewingPost.creator.lastName} +
+
+ {viewingPost.publishedAt + ? formatDistanceToNow(new Date(viewingPost.publishedAt), { + addSuffix: true, + }) + : formatDistanceToNow(new Date(viewingPost.updatedAt), { + addSuffix: true, + })} +
+
+ + {formatCategory(viewingPost.category)} + + {viewingPost.isDraft && ( + Draft + )} +
+ + {viewingPost.content && ( +
+ +
+ )} + +
+ +
+
+ )} +
+ + + + <PageTitle title="Edit Post" compact /> + + + +
+ ))} +
+ ); +} diff --git a/components/ui/tabs.tsx b/components/ui/tabs.tsx new file mode 100644 index 0000000..0f4caeb --- /dev/null +++ b/components/ui/tabs.tsx @@ -0,0 +1,55 @@ +"use client" + +import * as React from "react" +import * as TabsPrimitive from "@radix-ui/react-tabs" + +import { cn } from "@/lib/utils" + +const Tabs = TabsPrimitive.Root + +const TabsList = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +TabsList.displayName = TabsPrimitive.List.displayName + +const TabsTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +TabsTrigger.displayName = TabsPrimitive.Trigger.displayName + +const TabsContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +TabsContent.displayName = TabsPrimitive.Content.displayName + +export { Tabs, TabsList, TabsTrigger, TabsContent } diff --git a/drizzle/0008_soft_serpent_society.sql b/drizzle/0008_soft_serpent_society.sql new file mode 100644 index 0000000..de550dc --- /dev/null +++ b/drizzle/0008_soft_serpent_society.sql @@ -0,0 +1,16 @@ +CREATE TABLE "Post" ( + "id" integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "Post_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START WITH 1 CACHE 1), + "title" text NOT NULL, + "content" text, + "metadata" jsonb, + "category" text NOT NULL, + "isDraft" boolean DEFAULT true NOT NULL, + "publishedAt" timestamp, + "createdAt" timestamp DEFAULT now() NOT NULL, + "updatedAt" timestamp DEFAULT now() NOT NULL, + "projectId" integer NOT NULL, + "createdByUser" text NOT NULL +); +--> statement-breakpoint +ALTER TABLE "Post" ADD CONSTRAINT "Post_projectId_Project_id_fk" FOREIGN KEY ("projectId") REFERENCES "public"."Project"("id") ON DELETE cascade ON UPDATE cascade;--> statement-breakpoint +ALTER TABLE "Post" ADD CONSTRAINT "Post_createdByUser_User_id_fk" FOREIGN KEY ("createdByUser") REFERENCES "public"."User"("id") ON DELETE cascade ON UPDATE cascade; \ No newline at end of file diff --git a/drizzle/meta/0008_snapshot.json b/drizzle/meta/0008_snapshot.json new file mode 100644 index 0000000..1bbf68b --- /dev/null +++ b/drizzle/meta/0008_snapshot.json @@ -0,0 +1,1164 @@ +{ + "id": "24025b71-65e9-4cb9-ac35-5886d0a47b32", + "prevId": "fbcb328a-d085-45e1-a8ec-41d0e0ec9df3", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.Activity": { + "name": "Activity", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "Activity_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "oldValue": { + "name": "oldValue", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "newValue": { + "name": "newValue", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "target": { + "name": "target", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "projectId": { + "name": "projectId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "Activity_projectId_Project_id_fk": { + "name": "Activity_projectId_Project_id_fk", + "tableFrom": "Activity", + "tableTo": "Project", + "columnsFrom": [ + "projectId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "Activity_userId_User_id_fk": { + "name": "Activity_userId_User_id_fk", + "tableFrom": "Activity", + "tableTo": "User", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.Blob": { + "name": "Blob", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "blockId": { + "name": "blockId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "contentType": { + "name": "contentType", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "contentSize": { + "name": "contentSize", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "createdByUser": { + "name": "createdByUser", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "Blob_createdByUser_User_id_fk": { + "name": "Blob_createdByUser_User_id_fk", + "tableFrom": "Blob", + "tableTo": "User", + "columnsFrom": [ + "createdByUser" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "Blob_key_unique": { + "name": "Blob_key_unique", + "nullsNotDistinct": false, + "columns": [ + "key" + ] + }, + "Blob_blockId_unique": { + "name": "Blob_blockId_unique", + "nullsNotDistinct": false, + "columns": [ + "blockId" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.Event": { + "name": "Event", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "Event_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "start": { + "name": "start", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "end": { + "name": "end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "allDay": { + "name": "allDay", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "repeatRule": { + "name": "repeatRule", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "projectId": { + "name": "projectId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "createdByUser": { + "name": "createdByUser", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "Event_projectId_Project_id_fk": { + "name": "Event_projectId_Project_id_fk", + "tableFrom": "Event", + "tableTo": "Project", + "columnsFrom": [ + "projectId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "Event_createdByUser_User_id_fk": { + "name": "Event_createdByUser_User_id_fk", + "tableFrom": "Event", + "tableTo": "User", + "columnsFrom": [ + "createdByUser" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.Comment": { + "name": "Comment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "Comment_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "roomId": { + "name": "roomId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "createdByUser": { + "name": "createdByUser", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "Comment_createdByUser_User_id_fk": { + "name": "Comment_createdByUser_User_id_fk", + "tableFrom": "Comment", + "tableTo": "User", + "columnsFrom": [ + "createdByUser" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.Notification": { + "name": "Notification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "Notification_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target": { + "name": "target", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "read": { + "name": "read", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "fromUser": { + "name": "fromUser", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "toUser": { + "name": "toUser", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "Notification_fromUser_User_id_fk": { + "name": "Notification_fromUser_User_id_fk", + "tableFrom": "Notification", + "tableTo": "User", + "columnsFrom": [ + "fromUser" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "Notification_toUser_User_id_fk": { + "name": "Notification_toUser_User_id_fk", + "tableFrom": "Notification", + "tableTo": "User", + "columnsFrom": [ + "toUser" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.Post": { + "name": "Post", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "Post_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "isDraft": { + "name": "isDraft", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "publishedAt": { + "name": "publishedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "projectId": { + "name": "projectId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "createdByUser": { + "name": "createdByUser", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "Post_projectId_Project_id_fk": { + "name": "Post_projectId_Project_id_fk", + "tableFrom": "Post", + "tableTo": "Project", + "columnsFrom": [ + "projectId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "Post_createdByUser_User_id_fk": { + "name": "Post_createdByUser_User_id_fk", + "tableFrom": "Post", + "tableTo": "User", + "columnsFrom": [ + "createdByUser" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.Project": { + "name": "Project", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "Project_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "dueDate": { + "name": "dueDate", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "createdByUser": { + "name": "createdByUser", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "Project_createdByUser_User_id_fk": { + "name": "Project_createdByUser_User_id_fk", + "tableFrom": "Project", + "tableTo": "User", + "columnsFrom": [ + "createdByUser" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ProjectPermission": { + "name": "ProjectPermission", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "ProjectPermission_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "projectId": { + "name": "projectId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "createdByUser": { + "name": "createdByUser", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "ProjectPermission_projectId_Project_id_fk": { + "name": "ProjectPermission_projectId_Project_id_fk", + "tableFrom": "ProjectPermission", + "tableTo": "Project", + "columnsFrom": [ + "projectId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "ProjectPermission_userId_User_id_fk": { + "name": "ProjectPermission_userId_User_id_fk", + "tableFrom": "ProjectPermission", + "tableTo": "User", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "ProjectPermission_createdByUser_User_id_fk": { + "name": "ProjectPermission_createdByUser_User_id_fk", + "tableFrom": "ProjectPermission", + "tableTo": "User", + "columnsFrom": [ + "createdByUser" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.Task": { + "name": "Task", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "Task_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "taskListId": { + "name": "taskListId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "dueDate": { + "name": "dueDate", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "position": { + "name": "position", + "type": "real", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "assignedToUser": { + "name": "assignedToUser", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "createdByUser": { + "name": "createdByUser", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "Task_taskListId_TaskList_id_fk": { + "name": "Task_taskListId_TaskList_id_fk", + "tableFrom": "Task", + "tableTo": "TaskList", + "columnsFrom": [ + "taskListId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "Task_assignedToUser_User_id_fk": { + "name": "Task_assignedToUser_User_id_fk", + "tableFrom": "Task", + "tableTo": "User", + "columnsFrom": [ + "assignedToUser" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "Task_createdByUser_User_id_fk": { + "name": "Task_createdByUser_User_id_fk", + "tableFrom": "Task", + "tableTo": "User", + "columnsFrom": [ + "createdByUser" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.TaskList": { + "name": "TaskList", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "TaskList_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "dueDate": { + "name": "dueDate", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "projectId": { + "name": "projectId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "createdByUser": { + "name": "createdByUser", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "TaskList_projectId_Project_id_fk": { + "name": "TaskList_projectId_Project_id_fk", + "tableFrom": "TaskList", + "tableTo": "Project", + "columnsFrom": [ + "projectId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "TaskList_createdByUser_User_id_fk": { + "name": "TaskList_createdByUser_User_id_fk", + "tableFrom": "TaskList", + "tableTo": "User", + "columnsFrom": [ + "createdByUser" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.User": { + "name": "User", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "firstName": { + "name": "firstName", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lastName": { + "name": "lastName", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "imageUrl": { + "name": "imageUrl", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "timeZone": { + "name": "timeZone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rawData": { + "name": "rawData", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "lastActiveAt": { + "name": "lastActiveAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "User_email_unique": { + "name": "User_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index fb8aa92..0e85407 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -57,6 +57,13 @@ "when": 1753692074530, "tag": "0007_brainy_freak", "breakpoints": true + }, + { + "idx": 8, + "version": "7", + "when": 1761372492707, + "tag": "0008_soft_serpent_society", + "breakpoints": true } ] } \ No newline at end of file diff --git a/drizzle/schema.ts b/drizzle/schema.ts index b194d12..f4bce3c 100644 --- a/drizzle/schema.ts +++ b/drizzle/schema.ts @@ -52,6 +52,7 @@ export const projectRelations = relations(project, ({ many, one }) => ({ taskLists: many(taskList), events: many(calendarEvent), permissions: many(projectPermission), + posts: many(post), })); export const task = pgTable("Task", { @@ -280,3 +281,32 @@ export const projectPermissionRelations = relations( }), }), ); + +export const post = pgTable("Post", { + id: integer("id").primaryKey().generatedAlwaysAsIdentity(), + title: text("title").notNull(), + content: text("content"), + metadata: jsonb("metadata"), + category: text("category").notNull(), + isDraft: boolean("isDraft").notNull().default(true), + publishedAt: timestamp("publishedAt"), + createdAt: timestamp().notNull().defaultNow(), + updatedAt: timestamp().notNull().defaultNow(), + projectId: integer("projectId") + .notNull() + .references(() => project.id, { onDelete: "cascade", onUpdate: "cascade" }), + createdByUser: text("createdByUser") + .notNull() + .references(() => user.id, { onDelete: "cascade", onUpdate: "cascade" }), +}); + +export const postRelations = relations(post, ({ one }) => ({ + creator: one(user, { + fields: [post.createdByUser], + references: [user.id], + }), + project: one(project, { + fields: [post.projectId], + references: [project.id], + }), +})); diff --git a/lib/search/helpers.ts b/lib/search/helpers.ts index 83218b6..1e6c381 100644 --- a/lib/search/helpers.ts +++ b/lib/search/helpers.ts @@ -1,5 +1,6 @@ import { type calendarEvent, + post, project, type task, taskList, @@ -143,3 +144,26 @@ export async function indexEventWithProjectFetch( } }); } + +export async function indexPost( + search: SearchService, + postData: typeof post.$inferSelect, + projectData: typeof project.$inferSelect, +) { + await runAndLogError("indexing post for search", async () => { + await search.indexPost(postData, projectData); + }); +} + +export async function indexPostWithProjectFetch( + db: Database, + search: SearchService, + postData: typeof post.$inferSelect, +) { + await runAndLogError("indexing post for search", async () => { + const projectData = await getProjectForIndexing(db, postData.projectId); + if (projectData) { + await search.indexPost(postData, projectData); + } + }); +} diff --git a/lib/search/index.ts b/lib/search/index.ts index fa84e52..9595f78 100644 --- a/lib/search/index.ts +++ b/lib/search/index.ts @@ -1,11 +1,11 @@ import { Search } from "@upstash/search"; -import type { calendarEvent, project, task, taskList } from "@/drizzle/schema"; +import type { calendarEvent, post, project, task, taskList } from "@/drizzle/schema"; const client = Search.fromEnv(); export interface SearchableItem { id: string; - type: "project" | "task" | "tasklist" | "event"; + type: "project" | "task" | "tasklist" | "event" | "post"; title: string; description?: string; projectId?: number; @@ -237,10 +237,56 @@ export class SearchService { }); } + async indexPost( + postData: typeof post.$inferSelect, + projectData: typeof project.$inferSelect, + ) { + const index = this.index; + const searchableItem: SearchableItem = { + id: `post-${postData.id}`, + type: "post", + title: postData.title, + description: postData.content || undefined, + projectId: projectData.id, + projectName: projectData.name, + url: `/${this.orgSlug}/projects/${projectData.id}/posts`, + createdAt: postData.createdAt, + metadata: { + projectName: projectData.name, + category: postData.category, + isDraft: postData.isDraft, + publishedAt: postData.publishedAt?.toISOString(), + createdByUser: postData.createdByUser, + }, + }; + + const content = this.truncateContentObject({ + title: searchableItem.title, + description: searchableItem.description || "", + type: searchableItem.type, + category: postData.category, + projectName: searchableItem.projectName || "", + projectId: searchableItem.projectId, + }); + + await index.upsert({ + id: searchableItem.id, + content, + metadata: { + url: searchableItem.url, + createdAt: searchableItem.createdAt.toISOString(), + category: searchableItem.metadata?.category, + isDraft: searchableItem.metadata?.isDraft, + publishedAt: searchableItem.metadata?.publishedAt, + createdByUser: searchableItem.metadata?.createdByUser, + }, + }); + } + async search( query: string, options?: { - type?: "project" | "task" | "tasklist" | "event"; + type?: "project" | "task" | "tasklist" | "event" | "post"; projectId?: number; status?: string; limit?: number; @@ -260,7 +306,7 @@ export class SearchService { const filters: string[] = []; if (options?.type) { - const allowedTypes = ["project", "task", "tasklist", "event"] as const; + const allowedTypes = ["project", "task", "tasklist", "event", "post"] as const; if (allowedTypes.includes(options.type)) { filters.push(`type = '${options.type}'`); } @@ -309,7 +355,8 @@ export class SearchService { | "project" | "task" | "tasklist" - | "event", + | "event" + | "post", status: result.content?.status || "", projectName: result.content?.projectName || "", url: result.metadata?.url || "", diff --git a/package.json b/package.json index 2ea3fb2..0fd2eea 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "@radix-ui/react-separator": "^1.1.4", "@radix-ui/react-slot": "^1.2.0", "@radix-ui/react-switch": "^1.1.0", + "@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tooltip": "^1.2.3", "@react-email/components": "^0.1.1", "@sentry/nextjs": "^10.21.0", @@ -82,7 +83,7 @@ "react-dom": "19.2.0", "react-email": "^4.0.17", "react-markdown": "^10.1.0", - "resend": "^4.1.2", + "resend": "6.3.0-canary.4", "rrule": "^2.8.1", "sharp": "^0.33.4", "slugify": "^1.6.6", diff --git a/scripts/migrate-all-tenants.ts b/scripts/migrate-all-tenants.ts index 95e3066..6a2c618 100644 --- a/scripts/migrate-all-tenants.ts +++ b/scripts/migrate-all-tenants.ts @@ -56,14 +56,16 @@ async function migrateTenantDatabase(ownerId: string): Promise<{ }> { try { const databaseName = getDatabaseName(ownerId); - const sslMode = - process.env.DATABASE_SSL === "true" ? "?sslmode=require" : ""; - const ownerDb = drizzle(`${process.env.DATABASE_URL}/manage${sslMode}`, { - schema, + const opsDb = drizzle({ + connection: { + url: `${process.env.DATABASE_URL}/manage`, + ssl: process.env.DATABASE_SSL === "true", + }, + schema: opsSchema, }); - const checkDb = await ownerDb.execute( + const checkDb = await opsDb.execute( sql`SELECT 1 FROM pg_database WHERE datname = ${databaseName}`, ); @@ -71,10 +73,13 @@ async function migrateTenantDatabase(ownerId: string): Promise<{ return { success: true, skipped: true }; } - const tenantDb = drizzle( - `${process.env.DATABASE_URL}/${databaseName}${sslMode}`, - { schema }, - ); + const tenantDb = drizzle({ + connection: { + url: `${process.env.DATABASE_URL}/${databaseName}`, + ssl: process.env.DATABASE_SSL === "true", + }, + schema, + }); const migrationsFolder = path.resolve(process.cwd(), "drizzle"); await migrate(tenantDb, { migrationsFolder }); diff --git a/scripts/post-upgrade-maintenance.ts b/scripts/post-upgrade-maintenance.ts index 476d79e..d853e75 100644 --- a/scripts/post-upgrade-maintenance.ts +++ b/scripts/post-upgrade-maintenance.ts @@ -54,14 +54,16 @@ async function performPostUpgradeMaintenance(ownerId: string): Promise<{ }> { try { const databaseName = getDatabaseName(ownerId); - const sslMode = - process.env.DATABASE_SSL === "true" ? "?sslmode=require" : ""; - const ownerDb = drizzle(`${process.env.DATABASE_URL}/manage${sslMode}`, { - schema, + const opsDb = drizzle({ + connection: { + url: `${process.env.DATABASE_URL}/manage`, + ssl: process.env.DATABASE_SSL === "true", + }, + schema: opsSchema, }); - const checkDb = await ownerDb.execute( + const checkDb = await opsDb.execute( sql`SELECT 1 FROM pg_database WHERE datname = ${databaseName}`, ); @@ -69,10 +71,13 @@ async function performPostUpgradeMaintenance(ownerId: string): Promise<{ return { success: true, skipped: true }; } - const tenantDb = drizzle( - `${process.env.DATABASE_URL}/${databaseName}${sslMode}`, - { schema }, - ); + const tenantDb = drizzle({ + connection: { + url: `${process.env.DATABASE_URL}/${databaseName}`, + ssl: process.env.DATABASE_SSL === "true", + }, + schema, + }); await tenantDb.execute( sql.raw(`REINDEX DATABASE CONCURRENTLY ${databaseName}`), diff --git a/trpc/routers/_app.ts b/trpc/routers/_app.ts index 6d50be7..741d622 100644 --- a/trpc/routers/_app.ts +++ b/trpc/routers/_app.ts @@ -2,6 +2,7 @@ import type { inferRouterOutputs } from "@trpc/server"; import { createTRPCRouter } from "../init"; import { eventsRouter } from "./events"; import { permissionsRouter } from "./permissions"; +import { postsRouter } from "./posts"; import { projectsRouter } from "./projects"; import { searchRouter } from "./search"; import { settingsRouter } from "./settings"; @@ -14,6 +15,7 @@ export const appRouter = createTRPCRouter({ projects: projectsRouter, tasks: tasksRouter, events: eventsRouter, + posts: postsRouter, search: searchRouter, permissions: permissionsRouter, }); diff --git a/trpc/routers/posts.ts b/trpc/routers/posts.ts new file mode 100644 index 0000000..57b0628 --- /dev/null +++ b/trpc/routers/posts.ts @@ -0,0 +1,356 @@ +import { TRPCError } from "@trpc/server"; +import { and, desc, eq } from "drizzle-orm"; +import { z } from "zod"; +import { post } from "@/drizzle/schema"; +import { logActivity } from "@/lib/activity"; +import { canEditProject, canViewProject } from "@/lib/permissions"; +import { + deleteSearchItem, + indexPostWithProjectFetch, +} from "@/lib/search/helpers"; +import { sendMentionNotifications } from "@/lib/utils/mentionNotifications"; +import { createTRPCRouter, protectedProcedure } from "../init"; + +export const postsRouter = createTRPCRouter({ + list: protectedProcedure + .input( + z.object({ + projectId: z.number(), + }), + ) + .query(async ({ ctx, input }) => { + const { projectId } = input; + + const hasAccess = await canViewProject(ctx, projectId); + if (!hasAccess) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "Project access denied", + }); + } + + const posts = await ctx.db.query.post.findMany({ + where: and( + eq(post.projectId, projectId), + eq(post.isDraft, false), + ), + orderBy: [desc(post.publishedAt)], + with: { + creator: { + columns: { + id: true, + firstName: true, + lastName: true, + imageUrl: true, + }, + }, + }, + }); + + return posts; + }), + listAll: protectedProcedure + .input( + z.object({ + projectId: z.number(), + }), + ) + .query(async ({ ctx, input }) => { + const { projectId } = input; + + const canEdit = await canEditProject(ctx, projectId); + if (!canEdit) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "Project edit access denied", + }); + } + + const posts = await ctx.db.query.post.findMany({ + where: eq(post.projectId, projectId), + orderBy: [desc(post.updatedAt)], + with: { + creator: { + columns: { + id: true, + firstName: true, + lastName: true, + imageUrl: true, + }, + }, + }, + }); + + return posts; + }), + myDrafts: protectedProcedure + .input( + z.object({ + projectId: z.number(), + }), + ) + .query(async ({ ctx, input }) => { + const { projectId } = input; + + const hasAccess = await canViewProject(ctx, projectId); + if (!hasAccess) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "Project access denied", + }); + } + + const drafts = await ctx.db.query.post.findMany({ + where: and( + eq(post.projectId, projectId), + eq(post.isDraft, true), + eq(post.createdByUser, ctx.userId), + ), + orderBy: [desc(post.updatedAt)], + with: { + creator: { + columns: { + id: true, + firstName: true, + lastName: true, + imageUrl: true, + }, + }, + }, + }); + + return drafts; + }), + get: protectedProcedure + .input( + z.object({ + id: z.number(), + }), + ) + .query(async ({ ctx, input }) => { + const { id } = input; + + const postData = await ctx.db.query.post.findFirst({ + where: eq(post.id, id), + with: { + creator: { + columns: { + id: true, + firstName: true, + lastName: true, + imageUrl: true, + }, + }, + project: true, + }, + }); + + if (!postData) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Post not found", + }); + } + + const hasAccess = await canViewProject(ctx, postData.projectId); + if (!hasAccess) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "Project access denied", + }); + } + + if (postData.isDraft && postData.createdByUser !== ctx.userId) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "Draft posts are only visible to their authors", + }); + } + + return postData; + }), + delete: protectedProcedure + .input( + z.object({ + id: z.number(), + }), + ) + .mutation(async ({ ctx, input }) => { + const { id } = input; + + const existingPost = await ctx.db.query.post.findFirst({ + where: eq(post.id, id), + }); + + if (!existingPost) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Post not found", + }); + } + + const canEdit = await canEditProject(ctx, existingPost.projectId); + if (!canEdit) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "Project edit access denied", + }); + } + + const deletedPost = await ctx.db + .delete(post) + .where(eq(post.id, id)) + .returning(); + + if (deletedPost.length) { + const { metadata: _, ...oldValue } = deletedPost[0]; + + await logActivity({ + action: "deleted", + type: "post", + projectId: deletedPost[0].projectId, + oldValue, + }); + + await deleteSearchItem(ctx.search, `post-${id}`, "post"); + } + + return deletedPost[0]; + }), + upsert: protectedProcedure + .input( + z.object({ + id: z.number().optional(), + projectId: z.number(), + title: z + .string() + .min(1, { message: "Title must be at least 1 character" }), + content: z.string().optional(), + metadata: z.any().optional(), + category: z.enum(["announcement", "fyi", "question"]), + isDraft: z.boolean().default(true), + }), + ) + .mutation(async ({ ctx, input }) => { + const { id, projectId, title, content, metadata, category, isDraft } = input; + + if (id) { + const existing = await ctx.db.query.post.findFirst({ + where: eq(post.id, id), + }); + if (!existing) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Post not found", + }); + } + const canEditSource = await canEditProject(ctx, existing.projectId); + const canEditTarget = + existing.projectId !== projectId + ? await canEditProject(ctx, projectId) + : true; + if (!canEditSource || !canEditTarget) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "Project edit access denied", + }); + } + } else { + const canEditTarget = await canEditProject(ctx, projectId); + if (!canEditTarget) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "Project edit access denied", + }); + } + } + + const postData = { + title, + content, + metadata, + category, + isDraft, + projectId, + publishedAt: isDraft ? null : new Date(), + }; + + let postId: number; + + if (id) { + postId = id; + const oldPost = await ctx.db.query.post.findFirst({ + where: eq(post.id, id), + }); + + const updatedPost = await ctx.db + .update(post) + .set(postData) + .where(eq(post.id, id)) + .returning(); + + const { metadata: oldMeta, ...oldValue } = oldPost || {}; + const { metadata: newMeta, ...newValue } = updatedPost[0] || {}; + + await logActivity({ + action: "updated", + type: "post", + projectId, + oldValue, + newValue, + }); + + if (updatedPost?.[0] && !isDraft) { + await indexPostWithProjectFetch(ctx.db, ctx.search, updatedPost[0]); + + if (content) { + await sendMentionNotifications(content, { + type: "post", + entityName: title, + entityId: id, + projectId, + orgSlug: ctx.orgSlug, + fromUserId: ctx.userId, + }); + } + } + } else { + const newPost = await ctx.db + .insert(post) + .values({ + ...postData, + createdByUser: ctx.userId, + }) + .returning() + .execute(); + + postId = newPost[0].id; + + const { metadata: _, ...newValue } = newPost[0]; + + await logActivity({ + action: "created", + type: "post", + projectId, + newValue, + }); + + if (newPost?.[0] && !isDraft) { + await indexPostWithProjectFetch(ctx.db, ctx.search, newPost[0]); + + if (content) { + await sendMentionNotifications(content, { + type: "post", + entityName: title, + entityId: postId, + projectId, + orgSlug: ctx.orgSlug, + fromUserId: ctx.userId, + }); + } + } + } + + return { id: postId, ...postData }; + }), +}); diff --git a/trpc/routers/search.ts b/trpc/routers/search.ts index 5080d71..2c6a762 100644 --- a/trpc/routers/search.ts +++ b/trpc/routers/search.ts @@ -1,16 +1,18 @@ +import { eq } from "drizzle-orm"; import { z } from "zod"; -import { createTRPCRouter, protectedProcedure } from "../init"; +import { post, project } from "@/drizzle/schema"; import { runAndLogError } from "@/lib/error"; import { getUserProjectIds } from "@/lib/permissions"; -import { eq } from "drizzle-orm"; -import { project } from "@/drizzle/schema"; +import { createTRPCRouter, protectedProcedure } from "../init"; export const searchRouter = createTRPCRouter({ searchQuery: protectedProcedure .input( z.object({ query: z.string().min(1), - type: z.enum(["project", "task", "tasklist", "event"]).optional(), + type: z + .enum(["project", "task", "tasklist", "event", "post"]) + .optional(), projectId: z.number().optional(), status: z.string().optional(), limit: z.number().min(1).max(50).default(20), @@ -51,7 +53,7 @@ export const searchRouter = createTRPCRouter({ return accessibleProjectIds.includes(result.projectId); } - // For tasks, tasklists, and events, check if their project is accessible + // For tasks, tasklists, events, and posts, check if their project is accessible return ( result.projectId && accessibleProjectIds.includes(result.projectId) ); @@ -128,6 +130,21 @@ export const searchRouter = createTRPCRouter({ ), ); + const posts = await ctx.db.query.post.findMany({ + where: eq(post.isDraft, false), + with: { + project: true, + }, + }); + + await Promise.allSettled( + posts.map((postItem) => + runAndLogError(`indexing post ${postItem.id}`, async () => { + await ctx.search.indexPost(postItem, postItem.project); + }), + ), + ); + return { success: true, indexed: { @@ -135,6 +152,7 @@ export const searchRouter = createTRPCRouter({ taskLists: taskLists.length, tasks: tasks.length, events: events.length, + posts: posts.length, }, }; }), From 2e460a8b3f62abd3673d188171a05964e486bb19 Mon Sep 17 00:00:00 2001 From: Arjun Komath Date: Sat, 25 Oct 2025 18:10:28 +1100 Subject: [PATCH 2/7] Fix build --- .../projects/[projectId]/posts/page.tsx | 6 +- components/project/posts/posts-list.tsx | 61 +++++++++---------- drizzle/types.ts | 6 ++ lib/activity/index.ts | 2 +- lib/utils/mentionNotifications.ts | 2 +- 5 files changed, 39 insertions(+), 38 deletions(-) diff --git a/app/(dashboard)/[tenant]/projects/[projectId]/posts/page.tsx b/app/(dashboard)/[tenant]/projects/[projectId]/posts/page.tsx index 92d2226..b40a61c 100644 --- a/app/(dashboard)/[tenant]/projects/[projectId]/posts/page.tsx +++ b/app/(dashboard)/[tenant]/projects/[projectId]/posts/page.tsx @@ -5,16 +5,16 @@ import { useQuery } from "@tanstack/react-query"; import Link from "next/link"; import { useParams } from "next/navigation"; import { parseAsBoolean, useQueryState } from "nuqs"; +import { useState } from "react"; import EmptyState from "@/components/core/empty-state"; import { Panel } from "@/components/core/panel"; import PageSection from "@/components/core/section"; import PostForm from "@/components/form/post"; import PageTitle from "@/components/layout/page-title"; import PostsList from "@/components/project/posts/posts-list"; -import { Button, buttonVariants } from "@/components/ui/button"; +import { buttonVariants } from "@/components/ui/button"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { useTRPC } from "@/trpc/client"; -import { useState } from "react"; export default function Posts() { const { projectId, tenant } = useParams(); @@ -83,7 +83,7 @@ export default function Posts() { {myDrafts.length ? ( - + ) : (
No draft posts diff --git a/components/project/posts/posts-list.tsx b/components/project/posts/posts-list.tsx index b2675a6..9a4cf14 100644 --- a/components/project/posts/posts-list.tsx +++ b/components/project/posts/posts-list.tsx @@ -3,8 +3,8 @@ import { useUser } from "@clerk/nextjs"; import { Title } from "@radix-ui/react-dialog"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { formatDistanceToNow } from "date-fns"; import { CircleEllipsisIcon } from "lucide-react"; -import { useParams } from "next/navigation"; import { useState } from "react"; import { HtmlPreview } from "@/components/core/html-view"; import { Panel } from "@/components/core/panel"; @@ -21,14 +21,15 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; +import type { PostWithCreator } from "@/drizzle/types"; import { displayMutationError } from "@/lib/utils/error"; import { useTRPC } from "@/trpc/client"; -import { formatDistanceToNow } from "date-fns"; const categoryColors = { announcement: "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200", fyi: "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200", - question: "bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200", + question: + "bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200", }; const formatCategory = (category: string) => { @@ -36,31 +37,12 @@ const formatCategory = (category: string) => { return category.charAt(0).toUpperCase() + category.slice(1); }; -interface Post { - id: number; - title: string; - content: string | null; - metadata: any; - category: string; - isDraft: boolean; - publishedAt: Date | null; - updatedAt: Date; - creator: { - id: string; - firstName: string | null; - lastName: string | null; - imageUrl: string | null; - }; -} - export default function PostsList({ posts, projectId, - isDraft = false, }: { - posts: Post[]; + posts: PostWithCreator[]; projectId: number; - isDraft?: boolean; }) { const { user } = useUser(); const [editing, setEditing] = useState(null); @@ -105,7 +87,7 @@ export default function PostsList({ onClick={() => setViewing(post.id)} >
- +

{post.title}

@@ -121,12 +103,15 @@ export default function PostsList({
- + {formatCategory(post.category)} - {post.isDraft && ( - Draft - )} + {post.isDraft && Draft}
@@ -191,19 +176,29 @@ export default function PostsList({
- {viewingPost.creator.firstName} {viewingPost.creator.lastName} + {viewingPost.creator.firstName}{" "} + {viewingPost.creator.lastName}
{viewingPost.publishedAt - ? formatDistanceToNow(new Date(viewingPost.publishedAt), { - addSuffix: true, - }) + ? formatDistanceToNow( + new Date(viewingPost.publishedAt), + { + addSuffix: true, + }, + ) : formatDistanceToNow(new Date(viewingPost.updatedAt), { addSuffix: true, })}
- + {formatCategory(viewingPost.category)} {viewingPost.isDraft && ( diff --git a/drizzle/types.ts b/drizzle/types.ts index 6ceb577..aa51e35 100644 --- a/drizzle/types.ts +++ b/drizzle/types.ts @@ -6,6 +6,7 @@ import type { blob, calendarEvent, notification, + post, project, task, taskList, @@ -22,6 +23,7 @@ export type Blob = InferSelectModel; export type CalendarEvent = InferSelectModel; export type Activity = InferSelectModel; export type Notification = InferSelectModel; +export type Post = InferSelectModel; export enum TaskListStatus { ACTIVE = "active", @@ -67,3 +69,7 @@ export type NotificationWithUser = Notification & { fromUser: Pick; toUser: Pick; }; + +export type PostWithCreator = Post & { + creator: Pick; +}; diff --git a/lib/activity/index.ts b/lib/activity/index.ts index 2098417..0ce502e 100644 --- a/lib/activity/index.ts +++ b/lib/activity/index.ts @@ -23,7 +23,7 @@ export async function logActivity({ projectId, }: { action: "created" | "updated" | "deleted"; - type: "tasklist" | "task" | "project" | "blob" | "event" | "comment"; + type: "tasklist" | "task" | "project" | "blob" | "event" | "comment" | "post"; oldValue?: GenericObject; newValue?: GenericObject; target?: string; diff --git a/lib/utils/mentionNotifications.ts b/lib/utils/mentionNotifications.ts index 957eb98..4772338 100644 --- a/lib/utils/mentionNotifications.ts +++ b/lib/utils/mentionNotifications.ts @@ -5,7 +5,7 @@ import { database } from "./useDatabase"; import { notifyUser } from "./useNotification"; interface MentionContext { - type: "project" | "task" | "tasklist" | "event" | "comment"; + type: "project" | "task" | "tasklist" | "event" | "comment" | "post"; entityName: string; entityId: number; projectId?: number; // For linking back to project From 34da2f3da166c53d35d8188e86052b0332f6c93e Mon Sep 17 00:00:00 2001 From: Arjun Komath Date: Sat, 25 Oct 2025 20:25:17 +1100 Subject: [PATCH 3/7] UI fixes --- components/form/post.tsx | 41 ++++++++++++++++--------- components/project/posts/posts-list.tsx | 15 +++++---- lib/utils/mentionNotifications.ts | 5 +++ trpc/routers/posts.ts | 12 +++----- 4 files changed, 45 insertions(+), 28 deletions(-) diff --git a/components/form/post.tsx b/components/form/post.tsx index 9fb0ac8..42e1a24 100644 --- a/components/form/post.tsx +++ b/components/form/post.tsx @@ -1,5 +1,15 @@ "use client"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useParams } from "next/navigation"; +import { parseAsBoolean, useQueryState } from "nuqs"; +import { + type Dispatch, + memo, + type SetStateAction, + useRef, + useState, +} from "react"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { @@ -11,19 +21,14 @@ import { } from "@/components/ui/select"; import { displayMutationError } from "@/lib/utils/error"; import { useTRPC } from "@/trpc/client"; -import { useMutation, useQueryClient } from "@tanstack/react-query"; -import { useParams } from "next/navigation"; -import { parseAsBoolean, useQueryState } from "nuqs"; -import { type Dispatch, type SetStateAction, memo, useState } from "react"; import Editor from "../editor"; import { Button } from "../ui/button"; -import { SaveButton } from "./button"; interface Post { id: number; title: string; content: string | null; - metadata: any; + metadata: unknown; category: string; isDraft: boolean; } @@ -73,11 +78,15 @@ const PostForm = memo( ); const [title, setTitle] = useState(item?.title ?? ""); - const [category, setCategory] = useState<"announcement" | "fyi" | "question">( + const [category, setCategory] = useState< + "announcement" | "fyi" | "question" + >( (item?.category as "announcement" | "fyi" | "question") ?? "announcement", ); - const handleSubmit = (isDraft: boolean) => (formData: FormData) => { + const formRef = useRef(null); + + const savePost = (formData: FormData, isDraft = false) => { const content = formData.get("content") as string; const metadata = formData.get("metadata"); @@ -93,7 +102,7 @@ const PostForm = memo( }; return ( -
+
@@ -130,7 +139,9 @@ const PostForm = memo( 0 + {...(item?.metadata && + Array.isArray(item.metadata) && + item.metadata.length > 0 ? { metadata: item.metadata } : {})} name="content" @@ -153,10 +164,10 @@ const PostForm = memo( type="button" variant="outline" onClick={() => { - const form = document.querySelector('form'); + const form = formRef.current; if (form) { const formData = new FormData(form); - handleSubmit(true)(formData); + savePost(formData, true); } }} > @@ -165,14 +176,14 @@ const PostForm = memo(
diff --git a/components/project/posts/posts-list.tsx b/components/project/posts/posts-list.tsx index 9a4cf14..de4ad99 100644 --- a/components/project/posts/posts-list.tsx +++ b/components/project/posts/posts-list.tsx @@ -22,6 +22,7 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import type { PostWithCreator } from "@/drizzle/types"; +import { cn } from "@/lib/utils"; import { displayMutationError } from "@/lib/utils/error"; import { useTRPC } from "@/trpc/client"; @@ -82,8 +83,9 @@ export default function PostsList({
{posts.map((post) => (
-
setViewing(post.id)} >
@@ -114,7 +116,7 @@ export default function PostsList({ {post.isDraft && Draft}
-
+ setViewing(null)}> @@ -193,11 +195,12 @@ export default function PostsList({ </div> </div> <Badge - className={ + className={cn( + "ml-auto", categoryColors[ viewingPost.category as keyof typeof categoryColors - ] - } + ], + )} > {formatCategory(viewingPost.category)} </Badge> diff --git a/lib/utils/mentionNotifications.ts b/lib/utils/mentionNotifications.ts index 4772338..a4143e0 100644 --- a/lib/utils/mentionNotifications.ts +++ b/lib/utils/mentionNotifications.ts @@ -65,6 +65,11 @@ export async function sendMentionNotifications( target = `/${context.orgSlug}/projects/${context.projectId}`; break; + case "post": + message = `${fromUserName} mentioned you in post "${context.entityName}"`; + target = `/${context.orgSlug}/projects/${context.projectId}/posts`; + break; + default: message = `${fromUserName} mentioned you`; target = `/${context.orgSlug}`; diff --git a/trpc/routers/posts.ts b/trpc/routers/posts.ts index 57b0628..96f09e2 100644 --- a/trpc/routers/posts.ts +++ b/trpc/routers/posts.ts @@ -30,10 +30,7 @@ export const postsRouter = createTRPCRouter({ } const posts = await ctx.db.query.post.findMany({ - where: and( - eq(post.projectId, projectId), - eq(post.isDraft, false), - ), + where: and(eq(post.projectId, projectId), eq(post.isDraft, false)), orderBy: [desc(post.publishedAt)], with: { creator: { @@ -232,7 +229,8 @@ export const postsRouter = createTRPCRouter({ }), ) .mutation(async ({ ctx, input }) => { - const { id, projectId, title, content, metadata, category, isDraft } = input; + const { id, projectId, title, content, metadata, category, isDraft } = + input; if (id) { const existing = await ctx.db.query.post.findFirst({ @@ -289,8 +287,8 @@ export const postsRouter = createTRPCRouter({ .where(eq(post.id, id)) .returning(); - const { metadata: oldMeta, ...oldValue } = oldPost || {}; - const { metadata: newMeta, ...newValue } = updatedPost[0] || {}; + const { metadata: _, ...oldValue } = oldPost || {}; + const { metadata: __, ...newValue } = updatedPost[0] || {}; await logActivity({ action: "updated", From 4d44fb846362e8bef14b6c4c5e3c10d0aa841ded Mon Sep 17 00:00:00 2001 From: Arjun Komath <arjun@hey.com> Date: Sat, 25 Oct 2025 22:07:03 +1100 Subject: [PATCH 4/7] Update posts list page --- components/project/posts/posts-list.tsx | 168 +++++++----------------- 1 file changed, 49 insertions(+), 119 deletions(-) diff --git a/components/project/posts/posts-list.tsx b/components/project/posts/posts-list.tsx index de4ad99..3e3acb7 100644 --- a/components/project/posts/posts-list.tsx +++ b/components/project/posts/posts-list.tsx @@ -2,7 +2,7 @@ import { useUser } from "@clerk/nextjs"; import { Title } from "@radix-ui/react-dialog"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; import { formatDistanceToNow } from "date-fns"; import { CircleEllipsisIcon } from "lucide-react"; import { useState } from "react"; @@ -22,7 +22,6 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import type { PostWithCreator } from "@/drizzle/types"; -import { cn } from "@/lib/utils"; import { displayMutationError } from "@/lib/utils/error"; import { useTRPC } from "@/trpc/client"; @@ -47,22 +46,13 @@ export default function PostsList({ }) { const { user } = useUser(); const [editing, setEditing] = useState<number | null>(null); - const [viewing, setViewing] = useState<number | null>(null); const trpc = useTRPC(); const queryClient = useQueryClient(); - const { data: viewingPost } = useQuery({ - ...trpc.posts.get.queryOptions({ - id: viewing!, - }), - enabled: !!viewing, - }); - const deletePost = useMutation( trpc.posts.delete.mutationOptions({ onSuccess: () => { - setViewing(null); setEditing(null); queryClient.invalidateQueries({ queryKey: trpc.posts.list.queryKey({ @@ -80,14 +70,10 @@ export default function PostsList({ ); return ( - <div className="w-full space-y-2"> + <div className="w-full space-y-6"> {posts.map((post) => ( <div key={post.id}> - <button - type="button" - className="relative flex items-center justify-between p-3 bg-muted rounded-lg hover:bg-muted/80 cursor-pointer transition-colors w-full text-left" - onClick={() => setViewing(post.id)} - > + <div className="relative flex flex-col justify-between p-4 bg-muted rounded-lg transition-colors w-full text-left"> <div className="flex items-center gap-3 flex-1"> <UserAvatar user={post.creator} /> <div className="flex-1 min-w-0"> @@ -115,112 +101,56 @@ export default function PostsList({ </Badge> {post.isDraft && <Badge variant="outline">Draft</Badge>} </div> - </div> - </button> - <Panel open={viewing === post.id} setOpen={() => setViewing(null)}> - <Title> - <PageTitle - title={viewingPost?.title || ""} - actions={ - <div className="flex items-center gap-2"> - {viewingPost?.createdByUser === user?.id && ( - <DropdownMenu> - <DropdownMenuTrigger asChild> - <Button variant="ghost" size="icon"> - <CircleEllipsisIcon className="h-5 w-5" /> - </Button> - </DropdownMenuTrigger> - <DropdownMenuContent align="end"> - <DropdownMenuItem className="w-full p-0"> - <Button - variant="ghost" - className="w-full" - size="sm" - onClick={() => { - setViewing(null); - setEditing(post.id); - }} - > - Edit - </Button> - </DropdownMenuItem> - <DropdownMenuItem className="w-full p-0"> - <form - action={async () => { - await deletePost.mutateAsync({ - id: post.id, - }); - }} - className="w-full" - > - <DeleteButton - action="Delete" - className="w-full" - compact - /> - </form> - </DropdownMenuItem> - </DropdownMenuContent> - </DropdownMenu> - )} - <Button variant="outline" onClick={() => setViewing(null)}> - Close + {user?.id === post.createdByUser ? ( + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button variant="ghost" size="icon"> + <CircleEllipsisIcon className="h-5 w-5" /> </Button> - </div> - } - compact - /> - - {viewingPost && ( -
-
- -
-
- {viewingPost.creator.firstName}{" "} - {viewingPost.creator.lastName} -
-
- {viewingPost.publishedAt - ? formatDistanceToNow( - new Date(viewingPost.publishedAt), - { - addSuffix: true, - }, - ) - : formatDistanceToNow(new Date(viewingPost.updatedAt), { - addSuffix: true, - })} -
-
- - {formatCategory(viewingPost.category)} - - {viewingPost.isDraft && ( - Draft - )} -
+ + + + + + +
{ + await deletePost.mutateAsync({ + id: post.id, + }); + }} + className="w-full" + > + + +
+
+ + ) : null} +
- {viewingPost.content && ( -
- -
- )} +
+ +
+
-
- -
-
- )} - +
+ +
From 4a985e2109b6ca864dda7d33000746b3f55e7669 Mon Sep 17 00:00:00 2001 From: Arjun Komath <arjun@hey.com> Date: Sat, 25 Oct 2025 22:14:08 +1100 Subject: [PATCH 5/7] Add post form loading --- README.md | 2 +- components/form/post.tsx | 62 ++++++++++++++++++++++++---------------- 2 files changed, 38 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index 698634d..bcc16cc 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ Manage is an open-source project management platform. With its intuitive interfa - [x] Search - [x] Permissions - [x] Notifications -- [ ] Posts & files +- [x] Posts ## Development diff --git a/components/form/post.tsx b/components/form/post.tsx index 42e1a24..edfd7fb 100644 --- a/components/form/post.tsx +++ b/components/form/post.tsx @@ -21,6 +21,7 @@ import { } from "@/components/ui/select"; import { displayMutationError } from "@/lib/utils/error"; import { useTRPC } from "@/trpc/client"; +import { Spinner } from "../core/loaders"; import Editor from "../editor"; import { Button } from "../ui/button"; @@ -160,31 +161,42 @@ const PostForm = memo( > Cancel </Button> - <Button - type="button" - variant="outline" - onClick={() => { - const form = formRef.current; - if (form) { - const formData = new FormData(form); - savePost(formData, true); - } - }} - > - Save as Draft - </Button> - <Button - type="button" - onClick={() => { - const form = formRef.current; - if (form) { - const formData = new FormData(form); - savePost(formData); - } - }} - > - Publish - </Button> + + <div className="ml-auto space-x-4"> + {upsertPost.isPending ? ( + <Spinner /> + ) : ( + <> + <Button + type="button" + disabled={upsertPost.isPending} + variant="outline" + onClick={() => { + const form = formRef.current; + if (form) { + const formData = new FormData(form); + savePost(formData, true); + } + }} + > + Save draft + </Button> + <Button + type="button" + disabled={upsertPost.isPending} + onClick={() => { + const form = formRef.current; + if (form) { + const formData = new FormData(form); + savePost(formData); + } + }} + > + Publish + </Button> + </> + )} + </div> </div> </form> ); From d8a000dd6c105ac2654002477b3ff36446ac82c1 Mon Sep 17 00:00:00 2001 From: Arjun Komath <arjun@hey.com> Date: Sun, 26 Oct 2025 07:58:55 +1100 Subject: [PATCH 6/7] Add filters for posts, paginate --- .../projects/[projectId]/posts/page.tsx | 300 ++++++++++++++++-- components/form/post.tsx | 35 +- components/project/activity/activity-feed.tsx | 87 +++-- components/project/posts/posts-list.tsx | 13 + drizzle/types.ts | 2 + trpc/client.tsx | 3 +- trpc/routers/posts.ts | 40 +-- 7 files changed, 354 insertions(+), 126 deletions(-) diff --git a/app/(dashboard)/[tenant]/projects/[projectId]/posts/page.tsx b/app/(dashboard)/[tenant]/projects/[projectId]/posts/page.tsx index b40a61c..b01384e 100644 --- a/app/(dashboard)/[tenant]/projects/[projectId]/posts/page.tsx +++ b/app/(dashboard)/[tenant]/projects/[projectId]/posts/page.tsx @@ -1,11 +1,12 @@ "use client"; import { Title } from "@radix-ui/react-dialog"; -import { useQuery } from "@tanstack/react-query"; +import { useInfiniteQuery, useQuery } from "@tanstack/react-query"; +import { format, isSameDay, startOfDay } from "date-fns"; import Link from "next/link"; import { useParams } from "next/navigation"; -import { parseAsBoolean, useQueryState } from "nuqs"; -import { useState } from "react"; +import { parseAsBoolean, parseAsString, useQueryState } from "nuqs"; +import { useMemo, useState } from "react"; import EmptyState from "@/components/core/empty-state"; import { Panel } from "@/components/core/panel"; import PageSection from "@/components/core/section"; @@ -13,8 +14,24 @@ import PostForm from "@/components/form/post"; import PageTitle from "@/components/layout/page-title"; import PostsList from "@/components/project/posts/posts-list"; import { buttonVariants } from "@/components/ui/button"; +import { Calendar } from "@/components/ui/calendar"; +import { Label } from "@/components/ui/label"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { useTRPC } from "@/trpc/client"; +import { useTRPC, useTRPCClient } from "@/trpc/client"; + +const POSTS_LIMIT = 5; export default function Posts() { const { projectId, tenant } = useParams(); @@ -23,8 +40,21 @@ export default function Posts() { parseAsBoolean.withDefault(false), ); const [activeTab, setActiveTab] = useState("published"); + const [categoryFilter, setCategoryFilter] = useQueryState( + "category", + parseAsString.withDefault("all"), + ); + const [authorFilter, setAuthorFilter] = useQueryState( + "author", + parseAsString.withDefault("all"), + ); + const [dateFilter, setDateFilter] = useQueryState( + "date", + parseAsString.withDefault(""), + ); const trpc = useTRPC(); + const trpcClient = useTRPCClient(); const { data: project } = useQuery( trpc.projects.getProjectById.queryOptions({ @@ -32,13 +62,33 @@ export default function Posts() { }), ); - const { data: publishedPosts = [] } = useQuery({ - ...trpc.posts.list.queryOptions({ - projectId: +projectId!, - }), + const { + data: publishedData, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + } = useInfiniteQuery({ + queryKey: [ + ["posts", "list"], + { input: { projectId: +projectId!, limit: POSTS_LIMIT }, type: "query" }, + ], + queryFn: async ({ pageParam }) => { + return await trpcClient.posts.list.query({ + projectId: +projectId!, + limit: POSTS_LIMIT, + offset: pageParam, + }); + }, + initialPageParam: 0, + getNextPageParam: (lastPage, allPages) => { + if (lastPage.length < POSTS_LIMIT) return undefined; + return allPages.length * POSTS_LIMIT; + }, enabled: activeTab === "published", }); + const allPublishedPosts = publishedData?.pages.flat() ?? []; + const { data: myDrafts = [] } = useQuery({ ...trpc.posts.myDrafts.queryOptions({ projectId: +projectId!, @@ -46,6 +96,48 @@ export default function Posts() { enabled: activeTab === "drafts", }); + const uniqueAuthors = useMemo(() => { + const posts = activeTab === "published" ? allPublishedPosts : myDrafts; + const authorsMap = new Map(); + posts.forEach((post) => { + if (!authorsMap.has(post.createdByUser)) { + authorsMap.set(post.createdByUser, { + id: post.creator.id, + name: `${post.creator.firstName || ""} ${post.creator.lastName || ""}`.trim(), + }); + } + }); + return Array.from(authorsMap.values()); + }, [allPublishedPosts, myDrafts, activeTab]); + + const displayedPosts = useMemo(() => { + const posts = activeTab === "published" ? allPublishedPosts : myDrafts; + return posts.filter((post) => { + const categoryMatch = + categoryFilter === "all" || post.category === categoryFilter; + const authorMatch = + authorFilter === "all" || post.createdByUser === authorFilter; + + let dateMatch = true; + if (dateFilter) { + const filterDate = startOfDay(new Date(dateFilter)); + const postDate = startOfDay( + new Date(post.publishedAt || post.updatedAt), + ); + dateMatch = isSameDay(filterDate, postDate); + } + + return categoryMatch && authorMatch && dateMatch; + }); + }, [ + allPublishedPosts, + myDrafts, + activeTab, + categoryFilter, + authorFilter, + dateFilter, + ]); + return ( <> <PageTitle @@ -69,27 +161,179 @@ export default function Posts() { <TabsTrigger value="drafts">My Drafts</TabsTrigger> </TabsList> - <TabsContent value="published"> - {publishedPosts.length ? ( - <PostsList posts={publishedPosts} projectId={+projectId!} /> - ) : ( - <EmptyState - show={!publishedPosts.length} - label="post" - createLink={`/${tenant}/projects/${projectId}/posts?create=true`} - /> - )} - </TabsContent> - - <TabsContent value="drafts"> - {myDrafts.length ? ( - <PostsList posts={myDrafts} projectId={+projectId!} /> - ) : ( - <div className="text-center text-muted-foreground py-8"> - No draft posts + <div className="flex flex-col lg:flex-row gap-6"> + <div className="flex-1 lg:max-w-4xl"> + <TabsContent value="published" className="mt-0 space-y-4"> + {displayedPosts.length ? ( + <> + <PostsList posts={displayedPosts} projectId={+projectId!} /> + {!isFetchingNextPage && ( + <div className="flex justify-center pt-4"> + {hasNextPage ? ( + <button + type="button" + onClick={() => fetchNextPage()} + className={buttonVariants({ variant: "outline" })} + > + Load More + </button> + ) : ( + <div className="text-center text-muted-foreground text-sm"> + No more posts + </div> + )} + </div> + )} + {isFetchingNextPage && ( + <div className="flex justify-center py-4"> + <span className="text-sm text-muted-foreground"> + Loading... + </span> + </div> + )} + </> + ) : ( + <EmptyState + show={!displayedPosts.length} + label="post" + createLink={`/${tenant}/projects/${projectId}/posts?create=true`} + /> + )} + </TabsContent> + + <TabsContent value="drafts" className="mt-0 space-y-4"> + {displayedPosts.length ? ( + <PostsList posts={displayedPosts} projectId={+projectId!} /> + ) : ( + <div className="text-center text-muted-foreground py-8"> + No draft posts + </div> + )} + </TabsContent> + </div> + + <aside className="hidden lg:block lg:w-80 space-y-4"> + <h3 className="font-semibold text-sm">Filters</h3> + + <div className="space-y-2"> + <Label htmlFor="category-filter" className="text-xs"> + Category + </Label> + <Select + value={categoryFilter} + onValueChange={(value) => setCategoryFilter(value)} + > + <SelectTrigger id="category-filter" className="h-9"> + <SelectValue /> + </SelectTrigger> + <SelectContent> + <SelectItem value="all">All Categories</SelectItem> + <SelectItem value="announcement">Announcement</SelectItem> + <SelectItem value="fyi">FYI</SelectItem> + <SelectItem value="question">Question</SelectItem> + </SelectContent> + </Select> </div> - )} - </TabsContent> + + <div className="space-y-2"> + <Label htmlFor="author-filter" className="text-xs"> + Author + </Label> + <Select + value={authorFilter} + onValueChange={(value) => setAuthorFilter(value)} + > + <SelectTrigger id="author-filter" className="h-9"> + <SelectValue /> + </SelectTrigger> + <SelectContent> + <SelectItem value="all">All Authors</SelectItem> + {uniqueAuthors.map((author) => ( + <SelectItem key={author.id} value={author.id}> + {author.name || "Unknown"} + </SelectItem> + ))} + </SelectContent> + </Select> + </div> + + <div className="space-y-2"> + <Label htmlFor="date-filter" className="text-xs"> + Date + </Label> + <Popover> + <PopoverTrigger asChild> + <button + type="button" + id="date-filter" + className="w-full h-9 px-3 py-2 text-sm rounded-md border border-input bg-background hover:bg-accent hover:text-accent-foreground text-left flex items-center justify-between" + > + <span> + {dateFilter + ? format(new Date(dateFilter), "MMM dd, yyyy") + : "Select date"} + </span> + <svg + xmlns="http://www.w3.org/2000/svg" + width="16" + height="16" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + strokeWidth="2" + strokeLinecap="round" + strokeLinejoin="round" + className="opacity-50" + > + <title>Calendar + + + + + + + + + { + if (date) { + setDateFilter(format(date, "yyyy-MM-dd")); + } else { + setDateFilter(""); + } + }} + initialFocus + /> + + + + + {(categoryFilter !== "all" || + authorFilter !== "all" || + dateFilter) && ( + + )} + + diff --git a/components/form/post.tsx b/components/form/post.tsx index edfd7fb..b995c59 100644 --- a/components/form/post.tsx +++ b/components/form/post.tsx @@ -19,6 +19,7 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; +import type { PostCategory } from "@/drizzle/types"; import { displayMutationError } from "@/lib/utils/error"; import { useTRPC } from "@/trpc/client"; import { Spinner } from "../core/loaders"; @@ -79,10 +80,8 @@ const PostForm = memo( ); const [title, setTitle] = useState(item?.title ?? ""); - const [category, setCategory] = useState< - "announcement" | "fyi" | "question" - >( - (item?.category as "announcement" | "fyi" | "question") ?? "announcement", + const [category, setCategory] = useState( + (item?.category as PostCategory) ?? "announcement", ); const formRef = useRef(null); @@ -105,24 +104,12 @@ const PostForm = memo( return (
-
- - setTitle(e.target.value)} - required - /> -
-
+
+ + setTitle(e.target.value)} + required + /> +
+
{upsertPost.isPending ? ( - + ) : ( <>
)}
- ) : null} - {isLoading ? ( + )} + {isFetchingNextPage && (
- ) : null} + )} ) : (
diff --git a/components/project/posts/posts-list.tsx b/components/project/posts/posts-list.tsx index 3e3acb7..3db216f 100644 --- a/components/project/posts/posts-list.tsx +++ b/components/project/posts/posts-list.tsx @@ -32,6 +32,12 @@ const categoryColors = { "bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200", }; +const categoryEmojis = { + announcement: "šŸ“¢", + fyi: "ā„¹ļø", + question: "ā“", +}; + const formatCategory = (category: string) => { if (category === "fyi") return "FYI"; return category.charAt(0).toUpperCase() + category.slice(1); @@ -97,6 +103,13 @@ export default function PostsList({ } variant="secondary" > + + { + categoryEmojis[ + post.category as keyof typeof categoryEmojis + ] + } + {formatCategory(post.category)} {post.isDraft && Draft} diff --git a/drizzle/types.ts b/drizzle/types.ts index aa51e35..bb8d5fd 100644 --- a/drizzle/types.ts +++ b/drizzle/types.ts @@ -36,6 +36,8 @@ export enum TaskStatus { DELETED = "deleted", } +export type PostCategory = "announcement" | "fyi" | "question"; + export type ProjectWithCreator = Project & { creator: User }; export type TaskWithDetails = Task & { diff --git a/trpc/client.tsx b/trpc/client.tsx index da6d1bf..70a17dc 100644 --- a/trpc/client.tsx +++ b/trpc/client.tsx @@ -9,7 +9,8 @@ import superjson from "superjson"; import { makeQueryClient } from "./query-client"; import type { AppRouter } from "./routers/_app"; -export const { TRPCProvider, useTRPC } = createTRPCContext(); +export const { TRPCProvider, useTRPC, useTRPCClient } = + createTRPCContext(); let browserQueryClient: QueryClient; function getQueryClient() { diff --git a/trpc/routers/posts.ts b/trpc/routers/posts.ts index 96f09e2..e75cbe6 100644 --- a/trpc/routers/posts.ts +++ b/trpc/routers/posts.ts @@ -16,10 +16,12 @@ export const postsRouter = createTRPCRouter({ .input( z.object({ projectId: z.number(), + limit: z.number().default(10), + offset: z.number().default(0), }), ) .query(async ({ ctx, input }) => { - const { projectId } = input; + const { projectId, limit, offset } = input; const hasAccess = await canViewProject(ctx, projectId); if (!hasAccess) { @@ -32,40 +34,8 @@ export const postsRouter = createTRPCRouter({ const posts = await ctx.db.query.post.findMany({ where: and(eq(post.projectId, projectId), eq(post.isDraft, false)), orderBy: [desc(post.publishedAt)], - with: { - creator: { - columns: { - id: true, - firstName: true, - lastName: true, - imageUrl: true, - }, - }, - }, - }); - - return posts; - }), - listAll: protectedProcedure - .input( - z.object({ - projectId: z.number(), - }), - ) - .query(async ({ ctx, input }) => { - const { projectId } = input; - - const canEdit = await canEditProject(ctx, projectId); - if (!canEdit) { - throw new TRPCError({ - code: "FORBIDDEN", - message: "Project edit access denied", - }); - } - - const posts = await ctx.db.query.post.findMany({ - where: eq(post.projectId, projectId), - orderBy: [desc(post.updatedAt)], + limit, + offset, with: { creator: { columns: { From d2f5301aba6cb0891f6dae56ce3f14237913fbeb Mon Sep 17 00:00:00 2001 From: Arjun Komath Date: Sun, 26 Oct 2025 08:02:58 +1100 Subject: [PATCH 7/7] exit process on success --- scripts/migrate-all-tenants.ts | 1 + scripts/migrate-ops.ts | 1 + scripts/post-upgrade-maintenance.ts | 1 + 3 files changed, 3 insertions(+) diff --git a/scripts/migrate-all-tenants.ts b/scripts/migrate-all-tenants.ts index 6a2c618..6e85df7 100644 --- a/scripts/migrate-all-tenants.ts +++ b/scripts/migrate-all-tenants.ts @@ -159,6 +159,7 @@ async function main() { } console.log("\nāœ“ All tenant databases migrated successfully!"); + process.exit(0); } main().catch((error) => { diff --git a/scripts/migrate-ops.ts b/scripts/migrate-ops.ts index a5e828b..85b71d8 100644 --- a/scripts/migrate-ops.ts +++ b/scripts/migrate-ops.ts @@ -40,6 +40,7 @@ async function main() { if (result.success) { console.log("āœ“ Ops database migrated successfully!\n"); + process.exit(0); } else { console.log(`āœ— Migration failed: ${result.error || "Unknown error"}\n`); process.exit(1); diff --git a/scripts/post-upgrade-maintenance.ts b/scripts/post-upgrade-maintenance.ts index d853e75..aaa347f 100644 --- a/scripts/post-upgrade-maintenance.ts +++ b/scripts/post-upgrade-maintenance.ts @@ -161,6 +161,7 @@ async function main() { } console.log("\nāœ“ All tenant databases processed successfully!"); + process.exit(0); } main().catch((error) => {