("minimal");
15 | const [currentFont, setCurrentFont] = useState<{ heading: string; content: string }>({
16 | heading: "geist",
17 | content: "geist",
18 | });
19 |
20 | useEffect(() => {
21 | const fetchUserSettings = async () => {
22 | if (!clerkUser?.id) return;
23 |
24 | try {
25 | const [themeResponse, templateResponse, fontResponse] =
26 | await Promise.all([
27 | fetch("/api/theme"),
28 | fetch("/api/template"),
29 | fetch("/api/font"),
30 | ]);
31 |
32 | if (!themeResponse.ok || !templateResponse.ok || !fontResponse.ok) {
33 | throw new Error("Failed to fetch settings");
34 | }
35 |
36 | const themeData = await themeResponse.json();
37 | const templateData = await templateResponse.json();
38 | const fontData = await fontResponse.json();
39 |
40 | setCurrentTheme(themeData.theme);
41 | setCurrentTemplate(templateData.template);
42 | setCurrentFont(fontData.font);
43 | } catch (error) {
44 | console.error("Error fetching settings:", error);
45 | }
46 | };
47 |
48 | fetchUserSettings();
49 | }, [clerkUser?.id]);
50 |
51 | const handleThemeChange = (newTheme: string) => {
52 | setCurrentTheme(newTheme);
53 | };
54 |
55 | const handleFontChange = async (type: "heading" | "content", value: string) => {
56 | try {
57 | const newFont = { ...currentFont, [type]: value };
58 | const response = await fetch("/api/font", {
59 | method: "POST",
60 | headers: {
61 | "Content-Type": "application/json",
62 | },
63 | body: JSON.stringify({ font: newFont }),
64 | });
65 |
66 | if (!response.ok) {
67 | throw new Error("Failed to update font");
68 | }
69 |
70 | setCurrentFont(newFont);
71 | } catch (error) {
72 | console.error("Error updating font:", error);
73 | }
74 | };
75 |
76 | if (!isLoaded) {
77 | return Loading...
;
78 | }
79 |
80 | if (!clerkUser) {
81 | redirect("/sign-in");
82 | }
83 |
84 | return (
85 |
86 |
87 |
Styles
88 |
89 | Customize the appearance of your portfolio by selecting a theme,
90 | template, and font.
Your current theme is:{" "}
91 | {currentTheme},
92 | your current template is:{" "}
93 | {currentTemplate}
94 | , and your fonts are:{" "}
95 | {currentFont.heading} for headings and{" "}
96 | {currentFont.content} for content.
97 |
98 |
99 |
100 |
101 |
102 |
103 |
104 |
Fonts
105 | handleFontChange(type, value)}
107 | currentFont={currentFont}
108 | />
109 |
110 |
111 |
112 |
113 |
114 |
Templates
115 |
116 |
117 |
118 |
119 |
120 |
121 |
122 | Color Palettes
123 |
124 |
125 |
126 |
127 |
128 | );
129 | }
--------------------------------------------------------------------------------
/app/api/user/medium/[username]/route.ts:
--------------------------------------------------------------------------------
1 | import { NextRequest, NextResponse } from "next/server";
2 | import { db } from "@/db/drizzle";
3 | import { user } from "@/db/schema";
4 | import { eq } from "drizzle-orm";
5 | import { XMLParser } from "fast-xml-parser";
6 |
7 | interface RSSItem {
8 | guid: {
9 | "#text": string;
10 | "@_isPermaLink": string;
11 | } | string;
12 | title: string;
13 | description: string;
14 | link: string;
15 | pubDate: string;
16 | "content:encoded": string;
17 | category: string | string[];
18 | }
19 |
20 | interface RSSFeed {
21 | rss: {
22 | channel: {
23 | item: RSSItem[];
24 | };
25 | };
26 | }
27 |
28 | async function fetchMediumData(mediumUsername: string) {
29 | try {
30 | // Medium's RSS feed URL
31 | const rssUrl = `https://medium.com/feed/@${mediumUsername}`;
32 |
33 | // First fetch the RSS feed
34 | const response = await fetch(rssUrl);
35 | if (!response.ok) {
36 | throw new Error("Failed to fetch Medium RSS feed");
37 | }
38 |
39 | const text = await response.text();
40 |
41 | // Parse XML to JSON
42 | const parser = new XMLParser({
43 | ignoreAttributes: false,
44 | attributeNamePrefix: "@_"
45 | });
46 | const result = parser.parse(text) as RSSFeed;
47 |
48 | // Extract articles from the parsed RSS feed
49 | const items = result.rss?.channel?.item || [];
50 | const articles = items.map((item: RSSItem) => {
51 | // Get content and extract first image if exists
52 | const content = item["content:encoded"] || "";
53 | const coverImage = content.match(/
]+src="([^">]+)"/)?.[1] || null;
54 |
55 | // Get categories/tags
56 | const categories = Array.isArray(item.category) ? item.category : [item.category].filter(Boolean);
57 |
58 | // Extract reading time from content
59 | const readingTimeMatch = content.match(/(\d+)\s+min\s+read/);
60 | const readingTime = readingTimeMatch ? parseInt(readingTimeMatch[1]) : 5;
61 |
62 | // Handle guid which can be either a string or an object
63 | const guid = typeof item.guid === 'string' ? item.guid : item.guid?.["#text"] || item.link;
64 |
65 | // Extract description from content - get first paragraph after image
66 | const descriptionMatch = content.match(/(.*?)<\/p>/);
67 | const description = descriptionMatch ? descriptionMatch[1].replace(/<[^>]*>/g, "") : "";
68 |
69 | return {
70 | id: guid,
71 | title: item.title || "",
72 | description: description,
73 | url: item.link || "",
74 | publishedAt: item.pubDate || "",
75 | coverImage,
76 | tags: categories,
77 | readingTime,
78 | // Note: Medium's RSS feed doesn't provide claps and responses,
79 | // so we'll set default values
80 | claps: 0,
81 | responseCount: 0
82 | };
83 | });
84 |
85 | return {
86 | articles: articles.slice(0, 4), // Get latest 4 articles
87 | totalArticles: articles.length,
88 | totalClaps: 0 // Not available through RSS
89 | };
90 | } catch (error) {
91 | console.error("Error fetching Medium data:", error);
92 | throw error;
93 | }
94 | }
95 |
96 | export async function GET(
97 | _request: NextRequest,
98 | { params }: { params: Promise<{ username: string }> }
99 | ) {
100 | const { username } = await params;
101 |
102 | try {
103 | const userData = await db.query.user.findFirst({
104 | where: eq(user.username, username),
105 | columns: {
106 | medium: true,
107 | showMedium: true,
108 | },
109 | });
110 |
111 | if (!userData) {
112 | return NextResponse.json(
113 | { error: "User not found" },
114 | { status: 404 }
115 | );
116 | }
117 |
118 | if (!userData.showMedium) {
119 | return NextResponse.json(
120 | { error: "Medium articles are not enabled for this user" },
121 | { status: 403 }
122 | );
123 | }
124 |
125 | if (!userData.medium) {
126 | return NextResponse.json(
127 | { error: "Medium username not set" },
128 | { status: 404 }
129 | );
130 | }
131 |
132 | const data = await fetchMediumData(userData.medium);
133 | return NextResponse.json(data);
134 | } catch (error) {
135 | console.error("Error fetching Medium data:", error);
136 | return NextResponse.json(
137 | { error: error instanceof Error ? error.message : "Failed to fetch Medium data" },
138 | { status: 500 }
139 | );
140 | }
141 | }
--------------------------------------------------------------------------------
/drizzle/meta/0002_snapshot.json:
--------------------------------------------------------------------------------
1 | {
2 | "id": "dd26f52d-8ff2-4f0e-8173-134395666bd1",
3 | "prevId": "c2342d3e-3bcb-4b6a-b691-5b7d8fb047ae",
4 | "version": "7",
5 | "dialect": "postgresql",
6 | "tables": {
7 | "public.projects": {
8 | "name": "projects",
9 | "schema": "",
10 | "columns": {
11 | "id": {
12 | "name": "id",
13 | "type": "varchar",
14 | "primaryKey": true,
15 | "notNull": true
16 | },
17 | "name": {
18 | "name": "name",
19 | "type": "varchar",
20 | "primaryKey": false,
21 | "notNull": true
22 | },
23 | "description": {
24 | "name": "description",
25 | "type": "text",
26 | "primaryKey": false,
27 | "notNull": false
28 | },
29 | "link": {
30 | "name": "link",
31 | "type": "text",
32 | "primaryKey": false,
33 | "notNull": false
34 | },
35 | "user_id": {
36 | "name": "user_id",
37 | "type": "varchar",
38 | "primaryKey": false,
39 | "notNull": true
40 | },
41 | "logo": {
42 | "name": "logo",
43 | "type": "text",
44 | "primaryKey": false,
45 | "notNull": false
46 | },
47 | "banner": {
48 | "name": "banner",
49 | "type": "text",
50 | "primaryKey": false,
51 | "notNull": false
52 | },
53 | "category": {
54 | "name": "category",
55 | "type": "varchar",
56 | "primaryKey": false,
57 | "notNull": false
58 | }
59 | },
60 | "indexes": {},
61 | "foreignKeys": {
62 | "projects_user_id_user_id_fk": {
63 | "name": "projects_user_id_user_id_fk",
64 | "tableFrom": "projects",
65 | "tableTo": "user",
66 | "columnsFrom": [
67 | "user_id"
68 | ],
69 | "columnsTo": [
70 | "id"
71 | ],
72 | "onDelete": "no action",
73 | "onUpdate": "no action"
74 | }
75 | },
76 | "compositePrimaryKeys": {},
77 | "uniqueConstraints": {},
78 | "policies": {},
79 | "checkConstraints": {},
80 | "isRLSEnabled": false
81 | },
82 | "public.user": {
83 | "name": "user",
84 | "schema": "",
85 | "columns": {
86 | "id": {
87 | "name": "id",
88 | "type": "varchar",
89 | "primaryKey": true,
90 | "notNull": true
91 | },
92 | "name": {
93 | "name": "name",
94 | "type": "varchar",
95 | "primaryKey": false,
96 | "notNull": true
97 | },
98 | "email": {
99 | "name": "email",
100 | "type": "varchar",
101 | "primaryKey": false,
102 | "notNull": true
103 | },
104 | "username": {
105 | "name": "username",
106 | "type": "varchar",
107 | "primaryKey": false,
108 | "notNull": true
109 | },
110 | "tagline": {
111 | "name": "tagline",
112 | "type": "text",
113 | "primaryKey": false,
114 | "notNull": false
115 | },
116 | "bio": {
117 | "name": "bio",
118 | "type": "text",
119 | "primaryKey": false,
120 | "notNull": false
121 | },
122 | "twitter": {
123 | "name": "twitter",
124 | "type": "varchar",
125 | "primaryKey": false,
126 | "notNull": false
127 | },
128 | "github": {
129 | "name": "github",
130 | "type": "varchar",
131 | "primaryKey": false,
132 | "notNull": false
133 | },
134 | "link": {
135 | "name": "link",
136 | "type": "text",
137 | "primaryKey": false,
138 | "notNull": false
139 | },
140 | "location": {
141 | "name": "location",
142 | "type": "varchar",
143 | "primaryKey": false,
144 | "notNull": false
145 | },
146 | "profile_picture": {
147 | "name": "profile_picture",
148 | "type": "text",
149 | "primaryKey": false,
150 | "notNull": false
151 | }
152 | },
153 | "indexes": {},
154 | "foreignKeys": {},
155 | "compositePrimaryKeys": {},
156 | "uniqueConstraints": {},
157 | "policies": {},
158 | "checkConstraints": {},
159 | "isRLSEnabled": false
160 | }
161 | },
162 | "enums": {},
163 | "schemas": {},
164 | "sequences": {},
165 | "roles": {},
166 | "policies": {},
167 | "views": {},
168 | "_meta": {
169 | "columns": {},
170 | "schemas": {},
171 | "tables": {}
172 | }
173 | }
--------------------------------------------------------------------------------
/hooks/use-toast.ts:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | // Inspired by react-hot-toast library
4 | import * as React from "react"
5 |
6 | import type {
7 | ToastActionElement,
8 | ToastProps,
9 | } from "@/components/ui/toast"
10 |
11 | const TOAST_LIMIT = 1
12 | const TOAST_REMOVE_DELAY = 1000000
13 |
14 | type ToasterToast = ToastProps & {
15 | id: string
16 | title?: React.ReactNode
17 | description?: React.ReactNode
18 | action?: ToastActionElement
19 | }
20 |
21 | const actionTypes = {
22 | ADD_TOAST: "ADD_TOAST",
23 | UPDATE_TOAST: "UPDATE_TOAST",
24 | DISMISS_TOAST: "DISMISS_TOAST",
25 | REMOVE_TOAST: "REMOVE_TOAST",
26 | } as const
27 |
28 | let count = 0
29 |
30 | function genId() {
31 | count = (count + 1) % Number.MAX_SAFE_INTEGER
32 | return count.toString()
33 | }
34 |
35 | type ActionType = typeof actionTypes
36 |
37 | type Action =
38 | | {
39 | type: ActionType["ADD_TOAST"]
40 | toast: ToasterToast
41 | }
42 | | {
43 | type: ActionType["UPDATE_TOAST"]
44 | toast: Partial
45 | }
46 | | {
47 | type: ActionType["DISMISS_TOAST"]
48 | toastId?: ToasterToast["id"]
49 | }
50 | | {
51 | type: ActionType["REMOVE_TOAST"]
52 | toastId?: ToasterToast["id"]
53 | }
54 |
55 | interface State {
56 | toasts: ToasterToast[]
57 | }
58 |
59 | const toastTimeouts = new Map>()
60 |
61 | const addToRemoveQueue = (toastId: string) => {
62 | if (toastTimeouts.has(toastId)) {
63 | return
64 | }
65 |
66 | const timeout = setTimeout(() => {
67 | toastTimeouts.delete(toastId)
68 | dispatch({
69 | type: "REMOVE_TOAST",
70 | toastId: toastId,
71 | })
72 | }, TOAST_REMOVE_DELAY)
73 |
74 | toastTimeouts.set(toastId, timeout)
75 | }
76 |
77 | export const reducer = (state: State, action: Action): State => {
78 | switch (action.type) {
79 | case "ADD_TOAST":
80 | return {
81 | ...state,
82 | toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
83 | }
84 |
85 | case "UPDATE_TOAST":
86 | return {
87 | ...state,
88 | toasts: state.toasts.map((t) =>
89 | t.id === action.toast.id ? { ...t, ...action.toast } : t
90 | ),
91 | }
92 |
93 | case "DISMISS_TOAST": {
94 | const { toastId } = action
95 |
96 | // ! Side effects ! - This could be extracted into a dismissToast() action,
97 | // but I'll keep it here for simplicity
98 | if (toastId) {
99 | addToRemoveQueue(toastId)
100 | } else {
101 | state.toasts.forEach((toast) => {
102 | addToRemoveQueue(toast.id)
103 | })
104 | }
105 |
106 | return {
107 | ...state,
108 | toasts: state.toasts.map((t) =>
109 | t.id === toastId || toastId === undefined
110 | ? {
111 | ...t,
112 | open: false,
113 | }
114 | : t
115 | ),
116 | }
117 | }
118 | case "REMOVE_TOAST":
119 | if (action.toastId === undefined) {
120 | return {
121 | ...state,
122 | toasts: [],
123 | }
124 | }
125 | return {
126 | ...state,
127 | toasts: state.toasts.filter((t) => t.id !== action.toastId),
128 | }
129 | }
130 | }
131 |
132 | const listeners: Array<(state: State) => void> = []
133 |
134 | let memoryState: State = { toasts: [] }
135 |
136 | function dispatch(action: Action) {
137 | memoryState = reducer(memoryState, action)
138 | listeners.forEach((listener) => {
139 | listener(memoryState)
140 | })
141 | }
142 |
143 | type Toast = Omit
144 |
145 | function toast({ ...props }: Toast) {
146 | const id = genId()
147 |
148 | const update = (props: ToasterToast) =>
149 | dispatch({
150 | type: "UPDATE_TOAST",
151 | toast: { ...props, id },
152 | })
153 | const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id })
154 |
155 | dispatch({
156 | type: "ADD_TOAST",
157 | toast: {
158 | ...props,
159 | id,
160 | open: true,
161 | onOpenChange: (open) => {
162 | if (!open) dismiss()
163 | },
164 | },
165 | })
166 |
167 | return {
168 | id: id,
169 | dismiss,
170 | update,
171 | }
172 | }
173 |
174 | function useToast() {
175 | const [state, setState] = React.useState(memoryState)
176 |
177 | React.useEffect(() => {
178 | listeners.push(setState)
179 | return () => {
180 | const index = listeners.indexOf(setState)
181 | if (index > -1) {
182 | listeners.splice(index, 1)
183 | }
184 | }
185 | }, [state])
186 |
187 | return {
188 | ...state,
189 | toast,
190 | dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
191 | }
192 | }
193 |
194 | export { useToast, toast }
195 |
--------------------------------------------------------------------------------
/components/ui/sheet.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as SheetPrimitive from "@radix-ui/react-dialog"
5 | import { cva, type VariantProps } from "class-variance-authority"
6 | import { X } from "lucide-react"
7 |
8 | import { cn } from "@/lib/utils"
9 |
10 | const Sheet = SheetPrimitive.Root
11 |
12 | const SheetTrigger = SheetPrimitive.Trigger
13 |
14 | const SheetClose = SheetPrimitive.Close
15 |
16 | const SheetPortal = SheetPrimitive.Portal
17 |
18 | const SheetOverlay = React.forwardRef<
19 | React.ElementRef,
20 | React.ComponentPropsWithoutRef
21 | >(({ className, ...props }, ref) => (
22 |
30 | ))
31 | SheetOverlay.displayName = SheetPrimitive.Overlay.displayName
32 |
33 | const sheetVariants = cva(
34 | "fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
35 | {
36 | variants: {
37 | side: {
38 | top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
39 | bottom:
40 | "inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
41 | left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
42 | right:
43 | "inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
44 | },
45 | },
46 | defaultVariants: {
47 | side: "right",
48 | },
49 | }
50 | )
51 |
52 | interface SheetContentProps
53 | extends React.ComponentPropsWithoutRef,
54 | VariantProps {}
55 |
56 | const SheetContent = React.forwardRef<
57 | React.ElementRef,
58 | SheetContentProps
59 | >(({ side = "right", className, children, ...props }, ref) => (
60 |
61 |
62 |
67 | {children}
68 |
69 |
70 | Close
71 |
72 |
73 |
74 | ))
75 | SheetContent.displayName = SheetPrimitive.Content.displayName
76 |
77 | const SheetHeader = ({
78 | className,
79 | ...props
80 | }: React.HTMLAttributes) => (
81 |
88 | )
89 | SheetHeader.displayName = "SheetHeader"
90 |
91 | const SheetFooter = ({
92 | className,
93 | ...props
94 | }: React.HTMLAttributes) => (
95 |
102 | )
103 | SheetFooter.displayName = "SheetFooter"
104 |
105 | const SheetTitle = React.forwardRef<
106 | React.ElementRef,
107 | React.ComponentPropsWithoutRef
108 | >(({ className, ...props }, ref) => (
109 |
114 | ))
115 | SheetTitle.displayName = SheetPrimitive.Title.displayName
116 |
117 | const SheetDescription = React.forwardRef<
118 | React.ElementRef,
119 | React.ComponentPropsWithoutRef
120 | >(({ className, ...props }, ref) => (
121 |
126 | ))
127 | SheetDescription.displayName = SheetPrimitive.Description.displayName
128 |
129 | export {
130 | Sheet,
131 | SheetPortal,
132 | SheetOverlay,
133 | SheetTrigger,
134 | SheetClose,
135 | SheetContent,
136 | SheetHeader,
137 | SheetFooter,
138 | SheetTitle,
139 | SheetDescription,
140 | }
141 |
--------------------------------------------------------------------------------
/drizzle/meta/0003_snapshot.json:
--------------------------------------------------------------------------------
1 | {
2 | "id": "ffb843e6-b3e3-482b-8e49-082100e7795b",
3 | "prevId": "dd26f52d-8ff2-4f0e-8173-134395666bd1",
4 | "version": "7",
5 | "dialect": "postgresql",
6 | "tables": {
7 | "public.projects": {
8 | "name": "projects",
9 | "schema": "",
10 | "columns": {
11 | "id": {
12 | "name": "id",
13 | "type": "varchar",
14 | "primaryKey": true,
15 | "notNull": true
16 | },
17 | "name": {
18 | "name": "name",
19 | "type": "varchar",
20 | "primaryKey": false,
21 | "notNull": true
22 | },
23 | "description": {
24 | "name": "description",
25 | "type": "text",
26 | "primaryKey": false,
27 | "notNull": false
28 | },
29 | "link": {
30 | "name": "link",
31 | "type": "text",
32 | "primaryKey": false,
33 | "notNull": false
34 | },
35 | "user_id": {
36 | "name": "user_id",
37 | "type": "varchar",
38 | "primaryKey": false,
39 | "notNull": true
40 | },
41 | "logo": {
42 | "name": "logo",
43 | "type": "text",
44 | "primaryKey": false,
45 | "notNull": false
46 | },
47 | "banner": {
48 | "name": "banner",
49 | "type": "text",
50 | "primaryKey": false,
51 | "notNull": false
52 | },
53 | "category": {
54 | "name": "category",
55 | "type": "varchar",
56 | "primaryKey": false,
57 | "notNull": false
58 | },
59 | "order": {
60 | "name": "order",
61 | "type": "integer",
62 | "primaryKey": false,
63 | "notNull": false,
64 | "default": 0
65 | }
66 | },
67 | "indexes": {},
68 | "foreignKeys": {
69 | "projects_user_id_user_id_fk": {
70 | "name": "projects_user_id_user_id_fk",
71 | "tableFrom": "projects",
72 | "tableTo": "user",
73 | "columnsFrom": [
74 | "user_id"
75 | ],
76 | "columnsTo": [
77 | "id"
78 | ],
79 | "onDelete": "no action",
80 | "onUpdate": "no action"
81 | }
82 | },
83 | "compositePrimaryKeys": {},
84 | "uniqueConstraints": {},
85 | "policies": {},
86 | "checkConstraints": {},
87 | "isRLSEnabled": false
88 | },
89 | "public.user": {
90 | "name": "user",
91 | "schema": "",
92 | "columns": {
93 | "id": {
94 | "name": "id",
95 | "type": "varchar",
96 | "primaryKey": true,
97 | "notNull": true
98 | },
99 | "name": {
100 | "name": "name",
101 | "type": "varchar",
102 | "primaryKey": false,
103 | "notNull": true
104 | },
105 | "email": {
106 | "name": "email",
107 | "type": "varchar",
108 | "primaryKey": false,
109 | "notNull": true
110 | },
111 | "username": {
112 | "name": "username",
113 | "type": "varchar",
114 | "primaryKey": false,
115 | "notNull": true
116 | },
117 | "tagline": {
118 | "name": "tagline",
119 | "type": "text",
120 | "primaryKey": false,
121 | "notNull": false
122 | },
123 | "bio": {
124 | "name": "bio",
125 | "type": "text",
126 | "primaryKey": false,
127 | "notNull": false
128 | },
129 | "twitter": {
130 | "name": "twitter",
131 | "type": "varchar",
132 | "primaryKey": false,
133 | "notNull": false
134 | },
135 | "github": {
136 | "name": "github",
137 | "type": "varchar",
138 | "primaryKey": false,
139 | "notNull": false
140 | },
141 | "link": {
142 | "name": "link",
143 | "type": "text",
144 | "primaryKey": false,
145 | "notNull": false
146 | },
147 | "location": {
148 | "name": "location",
149 | "type": "varchar",
150 | "primaryKey": false,
151 | "notNull": false
152 | },
153 | "profile_picture": {
154 | "name": "profile_picture",
155 | "type": "text",
156 | "primaryKey": false,
157 | "notNull": false
158 | }
159 | },
160 | "indexes": {},
161 | "foreignKeys": {},
162 | "compositePrimaryKeys": {},
163 | "uniqueConstraints": {},
164 | "policies": {},
165 | "checkConstraints": {},
166 | "isRLSEnabled": false
167 | }
168 | },
169 | "enums": {},
170 | "schemas": {},
171 | "sequences": {},
172 | "roles": {},
173 | "policies": {},
174 | "views": {},
175 | "_meta": {
176 | "columns": {},
177 | "schemas": {},
178 | "tables": {}
179 | }
180 | }
--------------------------------------------------------------------------------
/components/ui/platform-toggle.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import { useEffect, useState } from "react";
4 | import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
5 | import { Switch } from "@/components/ui/switch";
6 | import { useToast } from "@/hooks/use-toast";
7 | import { useAuth } from "@clerk/nextjs";
8 | import { Loader2 } from "lucide-react";
9 | import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
10 |
11 | interface PlatformToggleProps {
12 | title: string;
13 | description: string;
14 | platform: "github" | "producthunt" | "devto" | "medium";
15 | }
16 |
17 | const getPlatformKey = (platform: string) => {
18 | switch (platform) {
19 | case "producthunt":
20 | return "ProductHunt";
21 | case "devto":
22 | return "Devto";
23 | default:
24 | return platform.charAt(0).toUpperCase() + platform.slice(1);
25 | }
26 | };
27 |
28 | export default function PlatformToggle({ title, description, platform }: PlatformToggleProps) {
29 | const { userId } = useAuth();
30 | const { toast } = useToast();
31 | const [showPlatform, setShowPlatform] = useState(false);
32 | const [loading, setLoading] = useState(true);
33 | const [saving, setSaving] = useState(false);
34 |
35 | useEffect(() => {
36 | const fetchSettings = async () => {
37 | try {
38 | const response = await fetch(`/api/user/${platform}/settings/${userId}`);
39 | const data = await response.json();
40 | const key = `show${getPlatformKey(platform)}`;
41 | setShowPlatform(data[key]);
42 | } catch (error) {
43 | console.error(`Error fetching ${platform} settings:`, error);
44 | toast({
45 | title: "Error",
46 | description: `Failed to load ${platform} settings`,
47 | variant: "destructive",
48 | });
49 | } finally {
50 | setLoading(false);
51 | }
52 | };
53 |
54 | if (userId) {
55 | fetchSettings();
56 | }
57 | }, [userId, platform, toast]);
58 |
59 | const handleToggle = async () => {
60 | setSaving(true);
61 | try {
62 | const key = `show${getPlatformKey(platform)}`;
63 | const response = await fetch(`/api/user/${platform}/settings/${userId}`, {
64 | method: "PATCH",
65 | headers: {
66 | "Content-Type": "application/json",
67 | },
68 | body: JSON.stringify({
69 | [key]: !showPlatform,
70 | }),
71 | });
72 |
73 | if (!response.ok) {
74 | throw new Error(`Failed to update ${platform} settings`);
75 | }
76 |
77 | setShowPlatform(!showPlatform);
78 | toast({
79 | title: "Success",
80 | description: `${title} ${!showPlatform ? "enabled" : "disabled"}`,
81 | });
82 | } catch (error) {
83 | console.error(`Error updating ${platform} settings:`, error);
84 | toast({
85 | title: "Error",
86 | description: `Failed to update ${platform} settings`,
87 | variant: "destructive",
88 | });
89 | } finally {
90 | setSaving(false);
91 | }
92 | };
93 |
94 | if (loading) {
95 | return (
96 |
97 |
98 |
99 |
100 |
101 |
102 |
103 | );
104 | }
105 |
106 | // Split description to separate the username requirement
107 | const mainDescription = description.split("Please ensure")[0].trim();
108 | const tooltipContent = `Please ensure that your ${
109 | platform === "producthunt" ? "Product Hunt" :
110 | platform === "devto" ? "Dev.to" :
111 | platform === "github" ? "GitHub" :
112 | platform === "medium" ? "Medium" :
113 | platform
114 | } username is entered in the basic information card above too.`;
115 |
116 | return (
117 |
118 |
119 |
120 |
{title}
121 |
122 |
123 |
124 |
125 | ⓘ
126 |
127 |
128 |
129 | {tooltipContent}
130 |
131 |
132 |
133 |
134 | {mainDescription}
135 |
136 |
137 |
142 |
143 |
144 | );
145 | }
--------------------------------------------------------------------------------
/app/api/user/producthunt/[username]/route.ts:
--------------------------------------------------------------------------------
1 | import { NextRequest, NextResponse } from "next/server";
2 | import { db } from "@/db/drizzle";
3 | import { user } from "@/db/schema";
4 | import { eq } from "drizzle-orm";
5 |
6 | const PRODUCT_HUNT_API_URL = "https://api.producthunt.com/v2/api/graphql";
7 |
8 | interface ProductHuntGraphQLResponse {
9 | data?: {
10 | user?: {
11 | madePosts: {
12 | edges: Array<{
13 | node: {
14 | id: string;
15 | name: string;
16 | tagline: string;
17 | url: string;
18 | thumbnail?: {
19 | url: string;
20 | };
21 | votesCount: number;
22 | commentsCount: number;
23 | createdAt: string;
24 | };
25 | }>;
26 | };
27 | };
28 | };
29 | errors?: Array<{
30 | message: string;
31 | }>;
32 | }
33 |
34 | interface ProductLaunch {
35 | id: string;
36 | name: string;
37 | tagline: string;
38 | url: string;
39 | thumbnail: string;
40 | votesCount: number;
41 | commentsCount: number;
42 | launchedAt: string;
43 | }
44 |
45 | interface ProductHuntData {
46 | launches: ProductLaunch[];
47 | totalUpvotes: number;
48 | totalLaunches: number;
49 | }
50 |
51 | async function fetchProductHuntData(productHuntUsername: string): Promise {
52 | if (!process.env.PRODUCT_HUNT_API_TOKEN) {
53 | throw new Error("Product Hunt access token is not configured");
54 | }
55 |
56 | const query = `
57 | query($username: String!) {
58 | user(username: $username) {
59 | madePosts(first: 10) {
60 | edges {
61 | node {
62 | id
63 | name
64 | tagline
65 | url
66 | thumbnail {
67 | url
68 | }
69 | votesCount
70 | commentsCount
71 | createdAt
72 | }
73 | }
74 | }
75 | }
76 | }
77 | `;
78 |
79 | const response = await fetch(PRODUCT_HUNT_API_URL, {
80 | method: "POST",
81 | headers: {
82 | "Authorization": `Bearer ${process.env.PRODUCT_HUNT_API_TOKEN}`,
83 | "Content-Type": "application/json",
84 | "Accept": "application/json",
85 | },
86 | body: JSON.stringify({
87 | query,
88 | variables: { username: productHuntUsername.replace("@", "") },
89 | }),
90 | });
91 |
92 | if (!response.ok) {
93 | const error = await response.text();
94 | console.error("Product Hunt API error:", error);
95 | throw new Error("Product Hunt API request failed");
96 | }
97 |
98 | const { data, errors }: ProductHuntGraphQLResponse = await response.json();
99 |
100 | if (errors) {
101 | console.error("Product Hunt GraphQL errors:", errors);
102 | throw new Error(errors[0]?.message || "GraphQL query failed");
103 | }
104 |
105 | if (!data?.user) {
106 | throw new Error("Invalid Product Hunt username or missing data");
107 | }
108 |
109 | const launches = data.user.madePosts.edges
110 | .map((edge) => ({
111 | id: edge.node.id,
112 | name: edge.node.name,
113 | tagline: edge.node.tagline,
114 | url: edge.node.url,
115 | thumbnail: edge.node.thumbnail?.url || "",
116 | votesCount: edge.node.votesCount,
117 | commentsCount: edge.node.commentsCount,
118 | launchedAt: edge.node.createdAt,
119 | }))
120 | .sort((a, b) => b.votesCount - a.votesCount);
121 |
122 | const totalUpvotes = launches.reduce((sum, launch) => sum + launch.votesCount, 0);
123 |
124 | return {
125 | launches,
126 | totalUpvotes,
127 | totalLaunches: launches.length,
128 | };
129 | }
130 |
131 | export async function GET(
132 | _request: NextRequest,
133 | { params }: { params: Promise<{ username: string }> }
134 | ) {
135 | const { username } = await params;
136 |
137 | try {
138 | const userData = await db.query.user.findFirst({
139 | where: eq(user.username, username),
140 | columns: {
141 | productHunt: true,
142 | showProductHunt: true,
143 | },
144 | });
145 |
146 | if (!userData) {
147 | return NextResponse.json(
148 | { error: "User not found" },
149 | { status: 404 }
150 | );
151 | }
152 |
153 | if (!userData.showProductHunt) {
154 | return NextResponse.json(
155 | { error: "Product Hunt showcase is not enabled for this user" },
156 | { status: 403 }
157 | );
158 | }
159 |
160 | if (!userData.productHunt) {
161 | return NextResponse.json(
162 | { error: "Product Hunt username not set" },
163 | { status: 404 }
164 | );
165 | }
166 |
167 | const data = await fetchProductHuntData(userData.productHunt);
168 | return NextResponse.json(data);
169 | } catch (error) {
170 | console.error("Error fetching Product Hunt data:", error);
171 | return NextResponse.json(
172 | { error: error instanceof Error ? error.message : "Failed to fetch Product Hunt data" },
173 | { status: 500 }
174 | );
175 | }
176 | }
--------------------------------------------------------------------------------