diff --git a/package-lock.json b/package-lock.json index b2fbe35..d1e2dba 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.0.1-alpha.0", "dependencies": { "@radix-ui/react-checkbox": "^1.1.4", + "@radix-ui/react-collapsible": "^1.1.3", "@radix-ui/react-context-menu": "^2.2.6", "@radix-ui/react-dialog": "^1.1.6", "@radix-ui/react-label": "^2.1.2", @@ -17,6 +18,7 @@ "@radix-ui/react-separator": "^1.1.2", "@radix-ui/react-slot": "^1.1.2", "@radix-ui/react-tabs": "^1.1.3", + "@radix-ui/react-toast": "^1.2.6", "@radix-ui/react-tooltip": "^1.1.8", "@schedule-x/drag-and-drop": "^2.19.0", "@schedule-x/events-service": "^2.19.0", @@ -28,6 +30,7 @@ "clsx": "^2.1.1", "date-fns": "^3.6.0", "instagram-graph-api": "^7.0.0", + "js-cookie": "^3.0.5", "lucide-react": "^0.475.0", "mongodb": "^6.13.0", "next": "15.1.7", @@ -41,6 +44,7 @@ "twitter-api-v2": "^1.19.1" }, "devDependencies": { + "@types/js-cookie": "^3.0.6", "@types/node": "^20", "@types/react": "^18", "@types/react-dom": "^18", @@ -828,6 +832,36 @@ } } }, + "node_modules/@radix-ui/react-collapsible": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.3.tgz", + "integrity": "sha512-jFSerheto1X03MUC0g6R7LedNW9EEGWdg9W1+MlpkMLwGkgkbUXLPBH/KIuWKXUoeYRVY11llqbTBDzuLg7qrw==", + "license": "MIT", + "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-id": "1.1.0", + "@radix-ui/react-presence": "1.1.2", + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-use-controllable-state": "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" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-collection": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.2.tgz", @@ -1396,6 +1430,40 @@ } } }, + "node_modules/@radix-ui/react-toast": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toast/-/react-toast-1.2.6.tgz", + "integrity": "sha512-gN4dpuIVKEgpLn1z5FhzT9mYRUitbfZq9XqN/7kkBMUgFTzTG8x/KszWJugJXHcwxckY8xcKDZPz7kG3o6DsUA==", + "license": "MIT", + "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-dismissable-layer": "1.1.5", + "@radix-ui/react-portal": "1.1.4", + "@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-controllable-state": "1.1.0", + "@radix-ui/react-use-layout-effect": "1.1.0", + "@radix-ui/react-visually-hidden": "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" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-tooltip": { "version": "1.1.8", "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.1.8.tgz", @@ -1700,6 +1768,13 @@ "integrity": "sha512-p9eZ2X9B80iKiTW4ukVj8B4K6q9/+xFtQ5MGYA5HWToY9nL4EkhV9+6ftT2VHpVMEZb5Tv00Iel516bVdO+yRw==", "license": "MIT" }, + "node_modules/@types/js-cookie": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/js-cookie/-/js-cookie-3.0.6.tgz", + "integrity": "sha512-wkw9yd1kEXOPnvEeEV1Go1MmxtBJL0RR79aOTAApecWFVu7w0NNXNqhcWgvw2YgZDYadliXkl14pa3WXw5jlCQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "20.17.19", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.17.19.tgz", @@ -2591,6 +2666,15 @@ "jiti": "bin/jiti.js" } }, + "node_modules/js-cookie": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz", + "integrity": "sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", diff --git a/package.json b/package.json index 0a01de9..4b9cf2e 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ }, "dependencies": { "@radix-ui/react-checkbox": "^1.1.4", + "@radix-ui/react-collapsible": "^1.1.3", "@radix-ui/react-context-menu": "^2.2.6", "@radix-ui/react-dialog": "^1.1.6", "@radix-ui/react-label": "^2.1.2", @@ -19,6 +20,7 @@ "@radix-ui/react-separator": "^1.1.2", "@radix-ui/react-slot": "^1.1.2", "@radix-ui/react-tabs": "^1.1.3", + "@radix-ui/react-toast": "^1.2.6", "@radix-ui/react-tooltip": "^1.1.8", "@schedule-x/drag-and-drop": "^2.19.0", "@schedule-x/events-service": "^2.19.0", @@ -30,6 +32,7 @@ "clsx": "^2.1.1", "date-fns": "^3.6.0", "instagram-graph-api": "^7.0.0", + "js-cookie": "^3.0.5", "lucide-react": "^0.475.0", "mongodb": "^6.13.0", "next": "15.1.7", @@ -43,6 +46,7 @@ "twitter-api-v2": "^1.19.1" }, "devDependencies": { + "@types/js-cookie": "^3.0.6", "@types/node": "^20", "@types/react": "^18", "@types/react-dom": "^18", diff --git a/src/app/api/account/route.ts b/src/app/api/account/route.ts index 88f2ec7..f97fc02 100644 --- a/src/app/api/account/route.ts +++ b/src/app/api/account/route.ts @@ -40,47 +40,3 @@ export async function POST(req: NextRequest) { { status: 200 } ); } - -// Account login -// Email exists -// Password is valid -export async function GET(req: NextRequest) { - const { email, password } = await req.json(); - - if (typeof email !== 'string' || typeof password !== 'string') - return NextResponse.json( - { - message: - "Invalid structure, body requires 'email: string' and 'password: string'", - }, - { status: 400 } - ); - - const user = await database.users.findOne({ email }); - - if (!user) - return NextResponse.json( - { - message: 'Invalid email', - }, - { status: 400 } - ); - - if (user.password !== password) - return NextResponse.json( - { - message: 'Invalid password', - }, - { status: 400 } - ); - - const sessionId = (await database.sessions.insertOne({ email })).insertedId; - - return NextResponse.json( - { - message: 'ok', - sessionId, - }, - { status: 200 } - ); -} diff --git a/src/app/api/session/route.ts b/src/app/api/session/route.ts index f46e412..4659253 100644 --- a/src/app/api/session/route.ts +++ b/src/app/api/session/route.ts @@ -46,3 +46,47 @@ export async function GET(req: NextRequest) { { status: 200 } ); } + +// Account login +// Email exists +// Password is valid +export async function POST(req: NextRequest) { + const { email, password } = await req.json(); + + if (typeof email !== 'string' || typeof password !== 'string') + return NextResponse.json( + { + message: + "Invalid structure, body requires 'email: string' and 'password: string'", + }, + { status: 400 } + ); + + const user = await database.users.findOne({ email }); + + if (!user) + return NextResponse.json( + { + message: 'Invalid email', + }, + { status: 401 } + ); + + if (user.password !== password) + return NextResponse.json( + { + message: 'Invalid password', + }, + { status: 401 } + ); + + const sessionId = (await database.sessions.insertOne({ email })).insertedId; + + return NextResponse.json( + { + message: 'ok', + sessionId, + }, + { status: 200 } + ); +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index dbdd684..61e1427 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -2,6 +2,7 @@ import type { Metadata } from 'next'; import { cn } from '@/lib/utils'; import './globals.css'; import { ThemeProvider } from './ThemeProvider'; +import { Toaster } from '@/components/ui/toaster'; export const metadata: Metadata = { title: 'Create Next App', @@ -28,6 +29,7 @@ export default function RootLayout({ disableTransitionOnChange > {children} + diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index 2777e87..c664048 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -3,11 +3,20 @@ import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; +import { useToast } from '@/hooks/use-toast'; +import axios from 'axios'; import Link from 'next/link'; +import { useState } from 'react'; +import cookies from 'js-cookie'; +import { useRouter } from 'next/navigation'; export default function Login() { + const { toast } = useToast(); + const router = useRouter(); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + return ( - //
@@ -19,15 +28,42 @@ export default function Login() { className="w-80 mt-5 mx-auto" type="email" placeholder="name@example.com" + onChange={(event) => setEmail(event.target.value)} /> setPassword(event.target.value)} /> - +
- //
); } diff --git a/src/components/app.tsx b/src/components/app.tsx index 813e4ac..b835cb8 100644 --- a/src/components/app.tsx +++ b/src/components/app.tsx @@ -1,3 +1,22 @@ +'use client'; + +import * as React from 'react'; +import { Menu, ChevronDown, ChevronRight } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { + Sheet, + SheetContent, + SheetFooter, + SheetTitle, + SheetTrigger, +} from '@/components/ui/sheet'; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from '@/components/ui/collapsible'; +import { cn } from '@/lib/utils'; + import { SidebarContent, SidebarProvider, @@ -7,12 +26,12 @@ import { SidebarGroupContent, SidebarMenu, SidebarMenuItem, - SidebarMenuButton, SidebarFooter, } from '@/components/ui/sidebar'; import { Calendar, LayoutDashboardIcon, Settings } from 'lucide-react'; import Link from 'next/link'; +import useSmallScreen from '@/hooks/is-small'; // Menu items. const items = [ @@ -36,18 +55,90 @@ const footer = [ }, ]; -export default function App({ children }: { children?: any }) { +export function HamburgerMenu() { + const [open, setOpen] = React.useState(false); + return ( + + Menu + + + + + {items.map((item, i) => ( +
+ + + +
+ ))} + +
+ + + {footer.map((item, i) => ( + + + + ))} + + + + ); +} + +export default function App({ children }: { children?: any }) { + const isSmall = useSmallScreen(); + + return isSmall ? ( +
+
+
+ {items.map((item, i) => ( +
+ + + +
+ ))} +
+ +
+ {footer.map((item, i) => ( +
+ + + +
+ ))} +
+ {/* {items.map((item, i) => ( +
+ + + +
+ ))} */} +
+ {children} +
+ ) : (
-
- +
+ Klayrion v0.0.1-alpha.0 - + {items.map((item, i) => ( - + {footer.map((item, i) => ( , + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +ToastViewport.displayName = ToastPrimitives.Viewport.displayName + +const toastVariants = cva( + "group pointer-events-auto relative flex w-full items-center justify-between space-x-2 overflow-hidden rounded-md border p-4 pr-6 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full", + { + variants: { + variant: { + default: "border bg-background text-foreground", + destructive: + "destructive group border-destructive bg-destructive text-destructive-foreground", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +const Toast = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & + VariantProps +>(({ className, variant, ...props }, ref) => { + return ( + + ) +}) +Toast.displayName = ToastPrimitives.Root.displayName + +const ToastAction = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +ToastAction.displayName = ToastPrimitives.Action.displayName + +const ToastClose = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)) +ToastClose.displayName = ToastPrimitives.Close.displayName + +const ToastTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +ToastTitle.displayName = ToastPrimitives.Title.displayName + +const ToastDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +ToastDescription.displayName = ToastPrimitives.Description.displayName + +type ToastProps = React.ComponentPropsWithoutRef + +type ToastActionElement = React.ReactElement + +export { + type ToastProps, + type ToastActionElement, + ToastProvider, + ToastViewport, + Toast, + ToastTitle, + ToastDescription, + ToastClose, + ToastAction, +} diff --git a/src/components/ui/toaster.tsx b/src/components/ui/toaster.tsx new file mode 100644 index 0000000..171beb4 --- /dev/null +++ b/src/components/ui/toaster.tsx @@ -0,0 +1,35 @@ +"use client" + +import { useToast } from "@/hooks/use-toast" +import { + Toast, + ToastClose, + ToastDescription, + ToastProvider, + ToastTitle, + ToastViewport, +} from "@/components/ui/toast" + +export function Toaster() { + const { toasts } = useToast() + + return ( + + {toasts.map(function ({ id, title, description, action, ...props }) { + return ( + +
+ {title && {title}} + {description && ( + {description} + )} +
+ {action} + +
+ ) + })} + +
+ ) +} diff --git a/src/hooks/is-small.tsx b/src/hooks/is-small.tsx new file mode 100644 index 0000000..741f3d6 --- /dev/null +++ b/src/hooks/is-small.tsx @@ -0,0 +1,17 @@ +import { useEffect, useState } from 'react'; + +export default function useSmallScreen() { + const [isSmall, setIsSmall] = useState(false); + + useEffect(() => { + const mediaQuery = window.matchMedia('(max-width: 768px)'); + setIsSmall(mediaQuery.matches); + + const handler = (e: MediaQueryListEvent) => setIsSmall(e.matches); + mediaQuery.addEventListener('change', handler); + + return () => mediaQuery.removeEventListener('change', handler); + }, []); + + return isSmall; +}; diff --git a/src/hooks/use-toast.ts b/src/hooks/use-toast.ts new file mode 100644 index 0000000..02e111d --- /dev/null +++ b/src/hooks/use-toast.ts @@ -0,0 +1,194 @@ +"use client" + +// Inspired by react-hot-toast library +import * as React from "react" + +import type { + ToastActionElement, + ToastProps, +} from "@/components/ui/toast" + +const TOAST_LIMIT = 1 +const TOAST_REMOVE_DELAY = 1000000 + +type ToasterToast = ToastProps & { + id: string + title?: React.ReactNode + description?: React.ReactNode + action?: ToastActionElement +} + +const actionTypes = { + ADD_TOAST: "ADD_TOAST", + UPDATE_TOAST: "UPDATE_TOAST", + DISMISS_TOAST: "DISMISS_TOAST", + REMOVE_TOAST: "REMOVE_TOAST", +} as const + +let count = 0 + +function genId() { + count = (count + 1) % Number.MAX_SAFE_INTEGER + return count.toString() +} + +type ActionType = typeof actionTypes + +type Action = + | { + type: ActionType["ADD_TOAST"] + toast: ToasterToast + } + | { + type: ActionType["UPDATE_TOAST"] + toast: Partial + } + | { + type: ActionType["DISMISS_TOAST"] + toastId?: ToasterToast["id"] + } + | { + type: ActionType["REMOVE_TOAST"] + toastId?: ToasterToast["id"] + } + +interface State { + toasts: ToasterToast[] +} + +const toastTimeouts = new Map>() + +const addToRemoveQueue = (toastId: string) => { + if (toastTimeouts.has(toastId)) { + return + } + + const timeout = setTimeout(() => { + toastTimeouts.delete(toastId) + dispatch({ + type: "REMOVE_TOAST", + toastId: toastId, + }) + }, TOAST_REMOVE_DELAY) + + toastTimeouts.set(toastId, timeout) +} + +export const reducer = (state: State, action: Action): State => { + switch (action.type) { + case "ADD_TOAST": + return { + ...state, + toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT), + } + + case "UPDATE_TOAST": + return { + ...state, + toasts: state.toasts.map((t) => + t.id === action.toast.id ? { ...t, ...action.toast } : t + ), + } + + case "DISMISS_TOAST": { + const { toastId } = action + + // ! Side effects ! - This could be extracted into a dismissToast() action, + // but I'll keep it here for simplicity + if (toastId) { + addToRemoveQueue(toastId) + } else { + state.toasts.forEach((toast) => { + addToRemoveQueue(toast.id) + }) + } + + return { + ...state, + toasts: state.toasts.map((t) => + t.id === toastId || toastId === undefined + ? { + ...t, + open: false, + } + : t + ), + } + } + case "REMOVE_TOAST": + if (action.toastId === undefined) { + return { + ...state, + toasts: [], + } + } + return { + ...state, + toasts: state.toasts.filter((t) => t.id !== action.toastId), + } + } +} + +const listeners: Array<(state: State) => void> = [] + +let memoryState: State = { toasts: [] } + +function dispatch(action: Action) { + memoryState = reducer(memoryState, action) + listeners.forEach((listener) => { + listener(memoryState) + }) +} + +type Toast = Omit + +function toast({ ...props }: Toast) { + const id = genId() + + const update = (props: ToasterToast) => + dispatch({ + type: "UPDATE_TOAST", + toast: { ...props, id }, + }) + const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id }) + + dispatch({ + type: "ADD_TOAST", + toast: { + ...props, + id, + open: true, + onOpenChange: (open) => { + if (!open) dismiss() + }, + }, + }) + + return { + id: id, + dismiss, + update, + } +} + +function useToast() { + const [state, setState] = React.useState(memoryState) + + React.useEffect(() => { + listeners.push(setState) + return () => { + const index = listeners.indexOf(setState) + if (index > -1) { + listeners.splice(index, 1) + } + } + }, [state]) + + return { + ...state, + toast, + dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }), + } +} + +export { useToast, toast }