diff --git a/bun.lock b/bun.lock index ee00c7b..7110137 100644 --- a/bun.lock +++ b/bun.lock @@ -6,6 +6,7 @@ "dependencies": { "@clerk/nextjs": "^6.20.2", "@neondatabase/serverless": "neondatabase/serverless", + "@radix-ui/react-avatar": "^1.1.10", "@radix-ui/react-dialog": "^1.1.14", "@radix-ui/react-dropdown-menu": "^2.1.15", "@radix-ui/react-label": "^2.1.7", @@ -20,6 +21,7 @@ "drizzle-orm": "^0.44.1", "lucide-react": "^0.511.0", "next": "^15.2.3", + "next-themes": "^0.4.6", "postgres": "^3.4.7", "react": "^19.0.0", "react-dom": "^19.0.0", @@ -249,6 +251,8 @@ "@radix-ui/react-arrow": ["@radix-ui/react-arrow@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.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-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w=="], + "@radix-ui/react-avatar": ["@radix-ui/react-avatar@1.1.10", "", { "dependencies": { "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-is-hydrated": "0.1.0", "@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-V8piFfWapM5OmNCXTzVQY+E1rDa53zY+MQ4Y7356v4fFz6vqCyUtIz2rUD44ZEdwg78/jKmMJHj07+C/Z/rcog=="], + "@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-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=="], @@ -293,6 +297,8 @@ "@radix-ui/react-use-escape-keydown": ["@radix-ui/react-use-escape-keydown@1.1.1", "", { "dependencies": { "@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-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g=="], + "@radix-ui/react-use-is-hydrated": ["@radix-ui/react-use-is-hydrated@0.1.0", "", { "dependencies": { "use-sync-external-store": "^1.5.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-U+UORVEq+cTnRIaostJv9AGdV3G6Y+zbVd+12e18jQ5A3c0xL03IhnHuiU4UV69wolOQp5GfR58NW/EgdQhwOA=="], + "@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-use-rect": ["@radix-ui/react-use-rect@1.1.1", "", { "dependencies": { "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w=="], @@ -829,6 +835,8 @@ "next": ["next@15.3.3", "", { "dependencies": { "@next/env": "15.3.3", "@swc/counter": "0.1.3", "@swc/helpers": "0.5.15", "busboy": "1.6.0", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "15.3.3", "@next/swc-darwin-x64": "15.3.3", "@next/swc-linux-arm64-gnu": "15.3.3", "@next/swc-linux-arm64-musl": "15.3.3", "@next/swc-linux-x64-gnu": "15.3.3", "@next/swc-linux-x64-musl": "15.3.3", "@next/swc-win32-arm64-msvc": "15.3.3", "@next/swc-win32-x64-msvc": "15.3.3", "sharp": "^0.34.1" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.41.2", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-JqNj29hHNmCLtNvd090SyRbXJiivQ+58XjCcrC50Crb5g5u2zi7Y2YivbsEfzk6AtVI80akdOQbaMZwWB1Hthw=="], + "next-themes": ["next-themes@0.4.6", "", { "peerDependencies": { "react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc", "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" } }, "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA=="], + "no-case": ["no-case@3.0.4", "", { "dependencies": { "lower-case": "^2.0.2", "tslib": "^2.0.3" } }, "sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg=="], "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], diff --git a/drizzle/0002_magical_nico_minoru.sql b/drizzle/0002_magical_nico_minoru.sql new file mode 100644 index 0000000..86d91de --- /dev/null +++ b/drizzle/0002_magical_nico_minoru.sql @@ -0,0 +1,5 @@ +ALTER TABLE "flipside_article" ALTER COLUMN "user_id" SET DATA TYPE text;--> statement-breakpoint +ALTER TABLE "flipside_article" ALTER COLUMN "title" SET NOT NULL;--> statement-breakpoint +ALTER TABLE "flipside_article" ALTER COLUMN "created_at" SET NOT NULL;--> statement-breakpoint +ALTER TABLE "flipside_article" ADD COLUMN "image_url" text;--> statement-breakpoint +ALTER TABLE "flipside_article" ADD COLUMN "updated_at" timestamp DEFAULT now() NOT NULL; \ No newline at end of file diff --git a/drizzle/0003_perpetual_spectrum.sql b/drizzle/0003_perpetual_spectrum.sql new file mode 100644 index 0000000..4e4ddba --- /dev/null +++ b/drizzle/0003_perpetual_spectrum.sql @@ -0,0 +1 @@ +ALTER TABLE "flipside_article" ALTER COLUMN "title" DROP NOT NULL; \ No newline at end of file diff --git a/drizzle/0004_opposite_dark_phoenix.sql b/drizzle/0004_opposite_dark_phoenix.sql new file mode 100644 index 0000000..503ec6b --- /dev/null +++ b/drizzle/0004_opposite_dark_phoenix.sql @@ -0,0 +1 @@ +ALTER TABLE "flipside_article" ALTER COLUMN "title" SET NOT NULL; \ No newline at end of file diff --git a/drizzle/meta/0002_snapshot.json b/drizzle/meta/0002_snapshot.json new file mode 100644 index 0000000..41cfc20 --- /dev/null +++ b/drizzle/meta/0002_snapshot.json @@ -0,0 +1,105 @@ +{ + "id": "b097010d-2b82-4f2e-af77-b0728110f64e", + "prevId": "9c8a25c5-9684-438a-a61d-1df97a052646", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.flipside_article": { + "name": "flipside_article", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "image_url": { + "name": "image_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tags": { + "name": "tags", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "title_idx": { + "name": "title_idx", + "columns": [ + { + "expression": "title", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "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/0003_snapshot.json b/drizzle/meta/0003_snapshot.json new file mode 100644 index 0000000..45e0073 --- /dev/null +++ b/drizzle/meta/0003_snapshot.json @@ -0,0 +1,105 @@ +{ + "id": "53528728-7967-459d-b52e-07421fc0a36a", + "prevId": "b097010d-2b82-4f2e-af77-b0728110f64e", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.flipside_article": { + "name": "flipside_article", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "image_url": { + "name": "image_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tags": { + "name": "tags", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "title_idx": { + "name": "title_idx", + "columns": [ + { + "expression": "title", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "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/0004_snapshot.json b/drizzle/meta/0004_snapshot.json new file mode 100644 index 0000000..a9d9b3f --- /dev/null +++ b/drizzle/meta/0004_snapshot.json @@ -0,0 +1,105 @@ +{ + "id": "2aee8837-ca7a-451c-9fa3-02172faedbd0", + "prevId": "53528728-7967-459d-b52e-07421fc0a36a", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.flipside_article": { + "name": "flipside_article", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "image_url": { + "name": "image_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tags": { + "name": "tags", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "title_idx": { + "name": "title_idx", + "columns": [ + { + "expression": "title", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "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 5ef2f43..5c011c3 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -15,6 +15,27 @@ "when": 1748671600706, "tag": "0001_glamorous_ben_parker", "breakpoints": true + }, + { + "idx": 2, + "version": "7", + "when": 1749018745913, + "tag": "0002_magical_nico_minoru", + "breakpoints": true + }, + { + "idx": 3, + "version": "7", + "when": 1749021195475, + "tag": "0003_perpetual_spectrum", + "breakpoints": true + }, + { + "idx": 4, + "version": "7", + "when": 1749021231634, + "tag": "0004_opposite_dark_phoenix", + "breakpoints": true } ] } \ No newline at end of file diff --git a/package.json b/package.json index c7a9f5c..467c8cd 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "db:migrate": "drizzle-kit migrate", "db:push": "drizzle-kit push", "db:studio": "drizzle-kit studio", + "db:pull": "drizzle-kit pull", "dev": "next dev --turbo", "format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,mdx}\" --cache", "format:write": "prettier --write \"**/*.{ts,tsx,js,jsx,mdx}\" --cache", @@ -22,6 +23,8 @@ "dependencies": { "@clerk/nextjs": "^6.20.2", "@neondatabase/serverless": "neondatabase/serverless", + "@radix-ui/react-alert-dialog": "^1.1.14", + "@radix-ui/react-avatar": "^1.1.10", "@radix-ui/react-dialog": "^1.1.14", "@radix-ui/react-dropdown-menu": "^2.1.15", "@radix-ui/react-label": "^2.1.7", @@ -36,6 +39,7 @@ "drizzle-orm": "^0.44.1", "lucide-react": "^0.511.0", "next": "^15.2.3", + "next-themes": "^0.4.6", "postgres": "^3.4.7", "react": "^19.0.0", "react-dom": "^19.0.0", diff --git a/src/app/articles/loading.tsx b/src/app/articles/loading.tsx new file mode 100644 index 0000000..f15322a --- /dev/null +++ b/src/app/articles/loading.tsx @@ -0,0 +1,3 @@ +export default function Loading() { + return null +} diff --git a/src/app/articles/page.tsx b/src/app/articles/page.tsx new file mode 100644 index 0000000..7e92cb3 --- /dev/null +++ b/src/app/articles/page.tsx @@ -0,0 +1,169 @@ +"use client"; + +import { useState, useMemo } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Plus, Search } from "lucide-react"; +import { ArticleList } from "@/components/article-list"; +import { NewArticleModal } from "@/components/modals/new-article-modal"; +import { EditArticleModal } from "@/components/modals/edit-article-modal"; +import type { Article } from "@/lib/types"; +import { api } from "@/trpc/react"; + +export default function ArticlesPage() { + const { + data: articles, + isLoading, + isError, + error, + } = api.articles.getAll.useQuery(); + const [searchQuery, setSearchQuery] = useState(""); + const [isNewArticleModalOpen, setIsNewArticleModalOpen] = useState(false); + const [isEditArticleModalOpen, setIsEditArticleModalOpen] = useState(false); + const [editingArticle, setEditingArticle] = useState
(null); + const utils = api.useUtils(); + + const deleteArticle = api.articles.delete.useMutation({ + onSuccess: () => { + // Invalidate and refetch articles query + utils.articles.getAll.invalidate().catch((error) => { + console.error("Failed to invalidate cache:", error); + }); + console.log("Article deleted successfully!"); + }, + onError: (error) => { + console.log(`Failed to delete article: ${error.message}`); + }, + }); + + const filteredArticles = useMemo((): Article[] => { + if (!articles) return []; + return articles.filter((article) => { + const matchesSearch = article.title + .toLowerCase() + .includes(searchQuery.toLowerCase()); + + return matchesSearch; + }); + }, [articles, searchQuery]); + + const handleNewArticle = () => { + // Refresh articles list - in real app, this would refetch from API + console.log("Refreshing articles list..."); + }; + + const handleEditArticle = (article: Article) => { + setEditingArticle(article); + setIsEditArticleModalOpen(true); + }; + + const handleUpdateArticle = () => { + // Refresh articles list - in real app, this would refetch from API + console.log("Refreshing articles list after update..."); + }; + + const handleDeleteArticle = async (id: string) => { + try { + deleteArticle.mutateAsync({ id: id }); + } catch (error) { + console.log("Failed to delete article", error); + } + }; + + if (isLoading) { + return ( +
+

Loading articles...

+
+ ); + } + + if (isError) { + return ( +
+

Error loading articles: {error?.message}

+ +
+ ); + } + + if (!articles) { + return
Something went wrong
; + } + + return ( +
+ {/* Header */} +
+
+

My Articles

+

+ {articles.length} article{articles.length !== 1 ? "s" : ""} saved +

+
+ +
+ + {/* Search Bar */} +
+ + setSearchQuery(e.target.value)} + className="pl-10" + /> +
+ + {/* Articles List or Empty State */} + {articles.length === 0 ? ( +
+
📚
+

+ No articles yet +

+

+ Add your first article to get started! +

+ +
+ ) : ( + + )} + + {/* Modals */} + setIsNewArticleModalOpen(false)} + onSave={handleNewArticle} + /> + + { + setIsEditArticleModalOpen(false); + setEditingArticle(null); + }} + onSave={handleUpdateArticle} + /> +
+ ); +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index eba9f62..d813161 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,25 +1,36 @@ +import type React from "react"; +import type { Metadata } from "next"; +import { Inter } from "next/font/google"; import "@/styles/globals.css"; - -import { type Metadata } from "next"; - +import { Navbar } from "@/components/navbar"; +import { Footer } from "@/components/footer"; +import { ClerkProvider } from "@clerk/nextjs"; import { TRPCReactProvider } from "@/trpc/react"; -import { ClerkProvider } from "@clerk/nextjs"; +const inter = Inter({ subsets: ["latin"] }); export const metadata: Metadata = { - title: "Create T3 App", - description: "Generated by create-t3-app", + title: "Flipside - Save and Organize Your Articles", + description: "Save articles from URLs and organize them in one place", icons: [{ rel: "icon", url: "/favicon.png" }], }; export default function RootLayout({ children, -}: Readonly<{ children: React.ReactNode }>) { +}: { + children: React.ReactNode; +}) { return ( - {children} + +
+ +
{children}
+
+
+
diff --git a/src/app/loading.tsx b/src/app/loading.tsx new file mode 100644 index 0000000..2b7b503 --- /dev/null +++ b/src/app/loading.tsx @@ -0,0 +1,9 @@ +import { LoadingSpinner } from "@/components/loading-spinner" + +export default function Loading() { + return ( +
+ +
+ ) +} diff --git a/src/app/not-found.tsx b/src/app/not-found.tsx new file mode 100644 index 0000000..350e37c --- /dev/null +++ b/src/app/not-found.tsx @@ -0,0 +1,34 @@ +import Link from "next/link" +import { Button } from "@/components/ui/button" +import { Home, ArrowLeft } from "lucide-react" + +export default function NotFound() { + return ( +
+
+
+
404
+

Page Not Found

+

+ Oops! The page you're looking for doesn't exist. It might have been moved or deleted. +

+
+ +
+ + +
+
+
+ ) +} diff --git a/src/app/page.tsx b/src/app/page.tsx index 68a53a3..7592bdf 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,19 +1,96 @@ -// import { api, HydrateClient } from "@/trpc/server"; -import { SignedIn, SignedOut } from "@clerk/nextjs"; -import Navbar from "../components/navbar"; -import HomePage from "@/components/HomePage"; -import LandingPage from "@/components/LandingPage"; +import Link from "next/link"; +import { Button } from "@/components/ui/button"; +import { Search, Upload, Zap } from "lucide-react"; -export default async function Home() { +export default function LandingPage() { return ( - <> - - - - - - - - +
+ {/* Hero Section */} +
+
+

+ Save Articles, Stay Organized +

+

+ Never lose track of interesting articles again. Save them with a + simple URL, organize them beautifully, and access them anywhere. +

+ +
+
+ + {/* Features Section */} +
+
+
+

+ Everything you need to manage articles +

+

+ Simple, powerful tools to help you save, organize, and rediscover + your favorite content. +

+
+ +
+
+
+ +
+

Lightning Fast

+

+ Just paste a URL and we'll automatically extract the title, + description, and image. +

+
+ +
+
+ +
+

Smart Search

+

+ Find any article instantly with our powerful search + functionality. +

+
+ +
+
+ +
+

Bulk Import

+

+ Import hundreds of articles at once from a CSV file. +

+
+
+
+
+ + {/* CTA Section */} +
+
+

+ Ready to get organized? +

+

+ Join thousands of users who have already organized their reading + with Flipside. +

+ +
+
+
); } diff --git a/src/components/article-card.tsx b/src/components/article-card.tsx new file mode 100644 index 0000000..8535e50 --- /dev/null +++ b/src/components/article-card.tsx @@ -0,0 +1,95 @@ +"use client" + +import Image from "next/image" +import type { Article } from "@/lib/types" +import { Button } from "@/components/ui/button" +import { Card, CardContent } from "@/components/ui/card" +import { Edit, Trash2, ExternalLink } from "lucide-react" +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog" + +interface ArticleCardProps { + article: Article + onEdit: (article: Article) => void + onDelete: (id: string) => void +} + +export function ArticleCard({ article, onEdit, onDelete }: ArticleCardProps) { + const handleDelete = () => { + onDelete(article.id) + } + + return ( + + +
+ {/* Image */} +
+ {article.title} +
+ + {/* Content */} +
+

{article.title}

+

{article.description}

+
+ + {/* Actions */} +
+ + +
+ + + + + + + + + Delete Article + + Are you sure you want to delete "{article.title}"? This action cannot be undone. + + + + Cancel + Delete + + + +
+
+
+
+
+ ) +} diff --git a/src/components/article-list.tsx b/src/components/article-list.tsx new file mode 100644 index 0000000..3737e69 --- /dev/null +++ b/src/components/article-list.tsx @@ -0,0 +1,29 @@ +"use client" + +import type { Article } from "@/lib/types" +import { ArticleCard } from "./article-card" + +interface ArticleListProps { + articles: Article[] + onEdit: (article: Article) => void + onDelete: (id: string) => void +} + +export function ArticleList({ articles, onEdit, onDelete }: ArticleListProps) { + if (articles.length === 0) { + return ( +
+
No articles found
+

Try adjusting your search or add a new article

+
+ ) + } + + return ( +
+ {articles.map((article) => ( + + ))} +
+ ) +} diff --git a/src/components/footer.tsx b/src/components/footer.tsx new file mode 100644 index 0000000..687fa2e --- /dev/null +++ b/src/components/footer.tsx @@ -0,0 +1,35 @@ +import Link from "next/link"; + +export function Footer() { + return ( +
+
+
+
+ © {new Date().getFullYear()} Flipside. All rights reserved. +
+
+ + About + + + Contact + + + Privacy + +
+
+
+
+ ); +} diff --git a/src/components/loading-spinner.tsx b/src/components/loading-spinner.tsx new file mode 100644 index 0000000..1de3be5 --- /dev/null +++ b/src/components/loading-spinner.tsx @@ -0,0 +1,21 @@ +import { Loader2 } from "lucide-react" + +interface LoadingSpinnerProps { + size?: "sm" | "md" | "lg" + text?: string +} + +export function LoadingSpinner({ size = "md", text }: LoadingSpinnerProps) { + const sizeClasses = { + sm: "h-4 w-4", + md: "h-8 w-8", + lg: "h-12 w-12", + } + + return ( +
+ + {text &&

{text}

} +
+ ) +} diff --git a/src/components/modals/edit-article-modal.tsx b/src/components/modals/edit-article-modal.tsx new file mode 100644 index 0000000..2d92930 --- /dev/null +++ b/src/components/modals/edit-article-modal.tsx @@ -0,0 +1,157 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { Loader2 } from "lucide-react"; +import type { Article } from "@/lib/types"; +import Image from "next/image"; +import { api } from "@/trpc/react"; + +interface EditArticleModalProps { + isOpen: boolean; + article: Article | null; + onClose: () => void; + onSave: () => void; +} + +export function EditArticleModal({ + isOpen, + article, + onClose, + onSave, +}: EditArticleModalProps) { + const [title, setTitle] = useState(""); + const [description, setDescription] = useState(""); + const [tags, setTags] = useState(""); + const [isSaving, setIsSaving] = useState(false); + const utils = api.useUtils(); + + const updateArticle = api.articles.updateArticle.useMutation({ + onSuccess: () => { + // Invalidate and refetch articles query + utils.articles.getAll.invalidate().catch((error) => { + console.error("Failed to invalidate cache:", error); + }); + console.log("Article created successfully!"); + }, + onError: (error) => { + console.log(`Failed to create article: ${error.message}`); + }, + }); + + useEffect(() => { + if (article) { + setTitle(article.title); + setDescription(article.description ?? ""); + } + }, [article]); + + const handleSave = async () => { + if (!article || !title) return; + + setIsSaving(true); + try { + await updateArticle.mutateAsync({ + id: article.id, + title: title, + tags: tags || article.tags, + }); + + onSave(); + onClose(); + } catch (error) { + console.error("Failed to update article:", error); + } finally { + setIsSaving(false); + } + }; + + const handleClose = () => { + setTitle(""); + setDescription(""); + onClose(); + }; + + if (!article) return null; + + return ( + + + + Edit Article + + Update the title and description for this article. + + + +
+
+ + +
+ +
+ + setTitle(e.target.value)} + /> +
+ +
+ +