+
);
diff --git a/frontend/components/navigation/sidebar/sidebar-item-with-icon.tsx b/frontend/components/navigation/sidebar/sidebar-item-with-icon.tsx
new file mode 100644
index 00000000..ab7d614c
--- /dev/null
+++ b/frontend/components/navigation/sidebar/sidebar-item-with-icon.tsx
@@ -0,0 +1,23 @@
+import { LucideIcon } from "lucide-react";
+
+interface SidebarItemWithIconProps {
+ Icon: LucideIcon;
+ label: string;
+}
+
+const SidebarItemWithIcon = ({ Icon, label }: SidebarItemWithIconProps) => {
+ return (
+
+
+
+ {label}
+
+
+ );
+};
+
+export default SidebarItemWithIcon;
diff --git a/frontend/components/navigation/sidebar/sidebar-other-topics.tsx b/frontend/components/navigation/sidebar/sidebar-other-topics.tsx
new file mode 100644
index 00000000..6bb3765e
--- /dev/null
+++ b/frontend/components/navigation/sidebar/sidebar-other-topics.tsx
@@ -0,0 +1,46 @@
+import {
+ categoriesToDisplayName,
+ categoriesToIconsMap,
+ Category,
+} from "@/types/categories";
+
+import SidebarItemWithIcon from "./sidebar-item-with-icon";
+
+// TODO: dynamically fetch
+const otherTopics = [
+ Category.SciTech,
+ Category.ArtsHumanities,
+ Category.Politics,
+ Category.Media,
+ Category.Environment,
+ Category.Economics,
+ Category.Sports,
+ Category.GenderEquality,
+ Category.Religion,
+ Category.SocietyCulture,
+];
+
+const SidebarOtherTopics = () => {
+ return (
+
+
+ Other topics
+
+
+ {otherTopics.map((category) => {
+ const categoryLabel = categoriesToDisplayName[category];
+ const categoryIcon = categoriesToIconsMap[category];
+ return (
+
+ );
+ })}
+
+
+ );
+};
+
+export default SidebarOtherTopics;
diff --git a/frontend/components/navigation/sidebar/sidebar.tsx b/frontend/components/navigation/sidebar/sidebar.tsx
new file mode 100644
index 00000000..546abbf0
--- /dev/null
+++ b/frontend/components/navigation/sidebar/sidebar.tsx
@@ -0,0 +1,14 @@
+import UserProfileButton from "@/components/auth/user-profile-button";
+import SidebarOtherTopics from "@/components/navigation/sidebar/sidebar-other-topics";
+
+/* Assumption: This component is only rendered if the user is logged in */
+const Sidebar = () => {
+ return (
+
+
+
+
+ );
+};
+
+export default Sidebar;
diff --git a/frontend/components/news/news-article.tsx b/frontend/components/news/news-article.tsx
new file mode 100644
index 00000000..439a89a7
--- /dev/null
+++ b/frontend/components/news/news-article.tsx
@@ -0,0 +1,63 @@
+import Image from "next/image";
+import { ArrowUpRightIcon } from "lucide-react";
+
+import Chip from "@/components/display/chip";
+import {
+ categoriesToDisplayName,
+ categoriesToIconsMap,
+ Category,
+} from "@/types/categories";
+
+const sampleArticleCategories = [
+ Category.Economics,
+ Category.Environment,
+ Category.Media,
+ Category.Politics,
+];
+
+const NewsArticle = () => {
+ return (
+
+
+
+
+ CNA, Guardian
+
+
21 Sep 2024
+
+
+ Norris Claims Singapore GP Pole Amid Ferrari’s Setback
+
+
+ A Reflection on Commercialization, Sustainability, and Global Sports
+ as Cultural Forces
+
+
+ {sampleArticleCategories.map((category) => (
+
+ ))}
+
+
+
+
+
+
+ );
+};
+
+export default NewsArticle;
diff --git a/frontend/components/ui/avatar.tsx b/frontend/components/ui/avatar.tsx
index 13fb9acd..a61bd2fa 100644
--- a/frontend/components/ui/avatar.tsx
+++ b/frontend/components/ui/avatar.tsx
@@ -11,7 +11,7 @@ const Avatar = React.forwardRef<
>(({ className, ...props }, ref) => (
(({ className, ...props }, ref) => (
) => (
+
+)
+
+const ResizablePanel = ResizablePrimitive.Panel
+
+const ResizableHandle = ({
+ withHandle,
+ className,
+ ...props
+}: React.ComponentProps & {
+ withHandle?: boolean
+}) => (
+ div]:rotate-90",
+ className
+ )}
+ {...props}
+ >
+ {withHandle && (
+
+
+
+ )}
+
+)
+
+export { ResizablePanelGroup, ResizablePanel, ResizableHandle }
diff --git a/frontend/components/ui/select.tsx b/frontend/components/ui/select.tsx
new file mode 100644
index 00000000..91566d73
--- /dev/null
+++ b/frontend/components/ui/select.tsx
@@ -0,0 +1,160 @@
+"use client";
+
+import * as React from "react";
+import * as SelectPrimitive from "@radix-ui/react-select";
+import { Check, ChevronDown, ChevronUp } from "lucide-react";
+
+import { cn } from "@/lib/utils";
+
+const Select = SelectPrimitive.Root;
+
+const SelectGroup = SelectPrimitive.Group;
+
+const SelectValue = SelectPrimitive.Value;
+
+const SelectTrigger = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, ...props }, ref) => (
+ span]:line-clamp-1",
+ className,
+ )}
+ ref={ref}
+ {...props}
+ >
+ {children}
+
+
+
+
+));
+SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
+
+const SelectScrollUpButton = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+
+
+));
+SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
+
+const SelectScrollDownButton = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+
+
+));
+SelectScrollDownButton.displayName =
+ SelectPrimitive.ScrollDownButton.displayName;
+
+const SelectContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, position = "popper", ...props }, ref) => (
+
+
+
+
+ {children}
+
+
+
+
+));
+SelectContent.displayName = SelectPrimitive.Content.displayName;
+
+const SelectLabel = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+SelectLabel.displayName = SelectPrimitive.Label.displayName;
+
+const SelectItem = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, ...props }, ref) => (
+
+
+
+
+
+
+
+ {children}
+
+));
+SelectItem.displayName = SelectPrimitive.Item.displayName;
+
+const SelectSeparator = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
+
+export {
+ Select,
+ SelectContent,
+ SelectGroup,
+ SelectItem,
+ SelectLabel,
+ SelectScrollDownButton,
+ SelectScrollUpButton,
+ SelectSeparator,
+ SelectTrigger,
+ SelectValue,
+};
diff --git a/frontend/components/ui/skeleton.tsx b/frontend/components/ui/skeleton.tsx
new file mode 100644
index 00000000..2cdf440d
--- /dev/null
+++ b/frontend/components/ui/skeleton.tsx
@@ -0,0 +1,15 @@
+import { cn } from "@/lib/utils";
+
+function Skeleton({
+ className,
+ ...props
+}: React.HTMLAttributes) {
+ return (
+
+ );
+}
+
+export { Skeleton };
diff --git a/frontend/hooks/use-breakpoint-media-query.ts b/frontend/hooks/use-breakpoint-media-query.ts
new file mode 100644
index 00000000..af784809
--- /dev/null
+++ b/frontend/hooks/use-breakpoint-media-query.ts
@@ -0,0 +1,23 @@
+import { useMediaQuery } from "usehooks-ts";
+
+import { MediaBreakpoint } from "@/utils/media";
+
+function useBreakpointMediaQuery(): MediaBreakpoint {
+ const isMdBreakpoint = useMediaQuery(`(min-width: ${MediaBreakpoint.Md})`);
+ const isLgBreakpoint = useMediaQuery(`(min-width: ${MediaBreakpoint.Lg})`);
+ const isXlBreakpoint = useMediaQuery(`(min-width: ${MediaBreakpoint.Xl})`);
+ const isXxlBreakpoint = useMediaQuery(`(min-width: ${MediaBreakpoint.Xxl})`);
+ const isXxxlBreakpoint = useMediaQuery(
+ `(min-width: ${MediaBreakpoint.Xxxl})`,
+ );
+
+ if (isXxxlBreakpoint) return MediaBreakpoint.Xxxl;
+ if (isXxlBreakpoint) return MediaBreakpoint.Xxl;
+ if (isXlBreakpoint) return MediaBreakpoint.Xl;
+ if (isMdBreakpoint) return MediaBreakpoint.Md;
+ if (isLgBreakpoint) return MediaBreakpoint.Lg;
+
+ return MediaBreakpoint.Sm;
+}
+
+export default useBreakpointMediaQuery;
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 2447282d..d1edeb49 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -16,6 +16,7 @@
"@radix-ui/react-label": "^2.1.0",
"@radix-ui/react-navigation-menu": "^1.2.0",
"@radix-ui/react-popover": "^1.1.1",
+ "@radix-ui/react-select": "^2.1.1",
"@radix-ui/react-slot": "^1.1.0",
"@radix-ui/react-toast": "^1.2.1",
"@tanstack/react-query": "^5.56.2",
@@ -28,8 +29,10 @@
"react-dom": "^18",
"react-ga4": "^2.1.0",
"react-hook-form": "^7.53.0",
+ "react-resizable-panels": "^2.1.3",
"tailwind-merge": "^2.5.2",
"tailwindcss-animate": "^1.0.7",
+ "usehooks-ts": "^3.1.0",
"zod": "^3.23.8",
"zustand": "^5.0.0-rc.2"
},
@@ -1073,6 +1076,12 @@
"url": "https://opencollective.com/unts"
}
},
+ "node_modules/@radix-ui/number": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.0.tgz",
+ "integrity": "sha512-V3gRzhVNU1ldS5XhAPTom1fOIo4ccrjjJgmE+LI2h/WaFpHmx0MQApT+KZHnx8abG6Avtfcz4WoEciMnpFT3HQ==",
+ "license": "MIT"
+ },
"node_modules/@radix-ui/primitive": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.0.tgz",
@@ -1613,6 +1622,49 @@
}
}
},
+ "node_modules/@radix-ui/react-select": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.1.1.tgz",
+ "integrity": "sha512-8iRDfyLtzxlprOo9IicnzvpsO1wNCkuwzzCM+Z5Rb5tNOpCdMvcc2AkzX0Fz+Tz9v6NJ5B/7EEgyZveo4FBRfQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/number": "1.1.0",
+ "@radix-ui/primitive": "1.1.0",
+ "@radix-ui/react-collection": "1.1.0",
+ "@radix-ui/react-compose-refs": "1.1.0",
+ "@radix-ui/react-context": "1.1.0",
+ "@radix-ui/react-direction": "1.1.0",
+ "@radix-ui/react-dismissable-layer": "1.1.0",
+ "@radix-ui/react-focus-guards": "1.1.0",
+ "@radix-ui/react-focus-scope": "1.1.0",
+ "@radix-ui/react-id": "1.1.0",
+ "@radix-ui/react-popper": "1.2.0",
+ "@radix-ui/react-portal": "1.1.1",
+ "@radix-ui/react-primitive": "2.0.0",
+ "@radix-ui/react-slot": "1.1.0",
+ "@radix-ui/react-use-callback-ref": "1.1.0",
+ "@radix-ui/react-use-controllable-state": "1.1.0",
+ "@radix-ui/react-use-layout-effect": "1.1.0",
+ "@radix-ui/react-use-previous": "1.1.0",
+ "@radix-ui/react-visually-hidden": "1.1.0",
+ "aria-hidden": "^1.1.1",
+ "react-remove-scroll": "2.5.7"
+ },
+ "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"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@radix-ui/react-slot": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.0.tgz",
@@ -5333,6 +5385,12 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/lodash.debounce": {
+ "version": "4.0.8",
+ "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz",
+ "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==",
+ "license": "MIT"
+ },
"node_modules/lodash.merge": {
"version": "4.6.2",
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
@@ -6535,6 +6593,16 @@
}
}
},
+ "node_modules/react-resizable-panels": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/react-resizable-panels/-/react-resizable-panels-2.1.3.tgz",
+ "integrity": "sha512-Zz0sCro6aUubL+hYh67eTnn5vxAu+HUZ7+IXvGjsBCBaudDEpIyZyDGE3vcgKi2w6IN3rYH+WXO+MwpgMSOpaQ==",
+ "license": "MIT",
+ "peerDependencies": {
+ "react": "^16.14.0 || ^17.0.0 || ^18.0.0",
+ "react-dom": "^16.14.0 || ^17.0.0 || ^18.0.0"
+ }
+ },
"node_modules/react-style-singleton": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.1.tgz",
@@ -7681,6 +7749,21 @@
}
}
},
+ "node_modules/usehooks-ts": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/usehooks-ts/-/usehooks-ts-3.1.0.tgz",
+ "integrity": "sha512-bBIa7yUyPhE1BCc0GmR96VU/15l/9gP1Ch5mYdLcFBaFGQsdmXkvjV0TtOqW1yUd6VjIwDunm+flSciCQXujiw==",
+ "license": "MIT",
+ "dependencies": {
+ "lodash.debounce": "^4.0.8"
+ },
+ "engines": {
+ "node": ">=16.15.0"
+ },
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17 || ^18"
+ }
+ },
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
diff --git a/frontend/package.json b/frontend/package.json
index 668d734f..c54ac812 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -18,6 +18,7 @@
"@radix-ui/react-label": "^2.1.0",
"@radix-ui/react-navigation-menu": "^1.2.0",
"@radix-ui/react-popover": "^1.1.1",
+ "@radix-ui/react-select": "^2.1.1",
"@radix-ui/react-slot": "^1.1.0",
"@radix-ui/react-toast": "^1.2.1",
"@tanstack/react-query": "^5.56.2",
@@ -30,8 +31,10 @@
"react-dom": "^18",
"react-ga4": "^2.1.0",
"react-hook-form": "^7.53.0",
+ "react-resizable-panels": "^2.1.3",
"tailwind-merge": "^2.5.2",
"tailwindcss-animate": "^1.0.7",
+ "usehooks-ts": "^3.1.0",
"zod": "^3.23.8",
"zustand": "^5.0.0-rc.2"
},
diff --git a/frontend/store/user/user-store.ts b/frontend/store/user/user-store.ts
index 6ad7209d..7e54a067 100644
--- a/frontend/store/user/user-store.ts
+++ b/frontend/store/user/user-store.ts
@@ -1,9 +1,10 @@
import { createStore } from "zustand";
+import { UserPublic } from "@/client";
+
interface UserState {
isLoggedIn: boolean;
- userId?: number;
- email?: string;
+ user?: UserPublic;
}
export const defaultUserState: UserState = {
@@ -11,7 +12,7 @@ export const defaultUserState: UserState = {
};
interface UserActions {
- setLoggedIn: (userId?: number, email?: string) => void;
+ setLoggedIn: (user: UserPublic) => void;
setNotLoggedIn: () => void;
}
@@ -20,13 +21,11 @@ export type UserStore = UserState & UserActions;
export const createUserStore = (initState: UserState = defaultUserState) => {
return createStore()((set) => ({
...initState,
- setLoggedIn: (userId, email) =>
- set(() => ({ isLoggedIn: true, userId, email })),
+ setLoggedIn: (user) => set(() => ({ isLoggedIn: true, user })),
setNotLoggedIn: () =>
set(() => ({
isLoggedIn: false,
- userId: undefined,
- email: undefined,
+ user: undefined,
})),
}));
};
diff --git a/frontend/tailwind.config.ts b/frontend/tailwind.config.ts
index 21740ac7..c08d88a0 100644
--- a/frontend/tailwind.config.ts
+++ b/frontend/tailwind.config.ts
@@ -50,6 +50,7 @@ const config: Config = {
"4": "hsl(var(--chart-4))",
"5": "hsl(var(--chart-5))",
},
+ offblack: "#373530",
},
borderRadius: {
lg: "var(--radius)",
diff --git a/frontend/types/categories.ts b/frontend/types/categories.ts
new file mode 100644
index 00000000..3bc2145b
--- /dev/null
+++ b/frontend/types/categories.ts
@@ -0,0 +1,52 @@
+import {
+ Building2,
+ DollarSign,
+ Film,
+ HeartHandshake,
+ Leaf,
+ LucideIcon,
+ Medal,
+ Microscope,
+ Palette,
+ Scale,
+ UsersRound,
+} from "lucide-react";
+
+export enum Category {
+ SciTech = "Science & technology",
+ ArtsHumanities = "Arts & humanities",
+ Politics = "Politics",
+ Media = "Media",
+ Environment = "Environment",
+ Economics = "Economics",
+ Sports = "Sports",
+ GenderEquality = "Gender & equality",
+ Religion = "Religion",
+ SocietyCulture = "Society & culture",
+}
+
+export const categoriesToDisplayName: Record = {
+ [Category.SciTech]: "Science & technology",
+ [Category.ArtsHumanities]: "Arts & humanities",
+ [Category.Politics]: "Politics",
+ [Category.Media]: "Media",
+ [Category.Environment]: "Environment",
+ [Category.Economics]: "Economics",
+ [Category.Sports]: "Sports",
+ [Category.GenderEquality]: "Gender & equality",
+ [Category.Religion]: "Religion",
+ [Category.SocietyCulture]: "Society & culture",
+};
+
+export const categoriesToIconsMap: Record = {
+ [Category.SciTech]: Microscope,
+ [Category.ArtsHumanities]: Palette,
+ [Category.Politics]: Building2,
+ [Category.Media]: Film,
+ [Category.Environment]: Leaf,
+ [Category.Economics]: DollarSign,
+ [Category.Sports]: Medal,
+ [Category.GenderEquality]: Scale,
+ [Category.Religion]: HeartHandshake,
+ [Category.SocietyCulture]: UsersRound,
+};
diff --git a/frontend/utils/media.ts b/frontend/utils/media.ts
new file mode 100644
index 00000000..cdc093ee
--- /dev/null
+++ b/frontend/utils/media.ts
@@ -0,0 +1,8 @@
+export enum MediaBreakpoint {
+ Sm = "640px",
+ Md = "768px",
+ Lg = "1024px",
+ Xl = "1280px",
+ Xxl = "1920px",
+ Xxxl = "2560px",
+}