initial commit

This commit is contained in:
2026-03-12 06:37:16 +08:00
commit c31ebd35e7
41 changed files with 6272 additions and 0 deletions

View File

@@ -0,0 +1,9 @@
import React from "react"
export function AppLayout({ children }: { children: React.ReactNode }) {
return (
<div className="min-h-screen flex flex-col bg-white dark:bg-slate-950 text-slate-900 dark:text-slate-50 transition-colors">
{children}
</div>
)
}

View File

@@ -0,0 +1,36 @@
import React, { createContext, useContext, useState, useEffect } from "react"
type Language = "en" | "fa"
interface LanguageContextType {
language: Language
setLanguage: (lang: Language) => void
}
const LanguageContext = createContext<LanguageContextType | undefined>(undefined)
export function LanguageProvider({ children }: { children: React.ReactNode }) {
const [language, setLanguage] = useState<Language>(
(localStorage.getItem("language") as Language) || "fa"
)
useEffect(() => {
localStorage.setItem("language", language)
document.documentElement.lang = language
document.documentElement.dir = language === "fa" ? "rtl" : "ltr"
}, [language])
return (
<LanguageContext.Provider value={{ language, setLanguage }}>
{children}
</LanguageContext.Provider>
)
}
export function useLanguage() {
const context = useContext(LanguageContext)
if (context === undefined) {
throw new Error("useLanguage must be used within a LanguageProvider")
}
return context
}

86
src/components/Navbar.tsx Normal file
View File

@@ -0,0 +1,86 @@
import { useState } from "react"
import { useNavigate } from "react-router-dom"
import { useTranslation } from "../hooks/useTranslation"
import { Button } from "./ui/button"
import { SettingsMenu } from "./SettingsMenu"
import { LogOut } from "lucide-react"
import { logoutUser } from "../api/users"
import { toast } from "sonner"
export function Navbar() {
const { t } = useTranslation()
const navigate = useNavigate()
const [showLogoutModal, setShowLogoutModal] = useState(false)
const handleLogout = async () => {
try {
const refreshToken = localStorage.getItem("refreshToken")
if (refreshToken) {
await logoutUser(refreshToken)
}
} catch (error) {
console.error("Logout API failed:", error)
} finally {
localStorage.removeItem("accessToken")
localStorage.removeItem("refreshToken")
setShowLogoutModal(false)
toast.success(t.logoutToast || "Successfully logged out!")
navigate("/login")
}
}
return (
<>
<header className="border-b border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900 px-6 py-4 flex items-center justify-between transition-colors">
<div className="flex items-center gap-2">
<div className="w-8 h-8 rounded bg-blue-600 flex items-center justify-center text-white font-bold">
Q
</div>
<span className="font-bold text-xl tracking-tight text-slate-900 dark:text-slate-50">Qlockify</span>
</div>
<div className="flex items-center gap-4">
<SettingsMenu />
<Button
variant="ghost"
size="icon"
onClick={() => setShowLogoutModal(true)}
className="text-red-500 dark:text-red-500 hover:text-red-600 dark:hover:text-red-400 hover:bg-red-50 dark:hover:bg-red-950/50"
title={t.logout || "Logout"}
>
<LogOut className="h-5 w-5" />
</Button>
</div>
</header>
{showLogoutModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 px-4" onClick={() => setShowLogoutModal(false)}>
<div className="w-full max-w-sm rounded-lg bg-white p-6 shadow-lg dark:bg-slate-900 border dark:border-slate-800" onClick={(e) => e.stopPropagation()}>
<h2 className="mb-2 text-lg font-bold text-slate-900 dark:text-white">
{t.confirmLogoutTitle || "Confirm Logout"}
</h2>
<p className="mb-6 text-slate-600 dark:text-slate-400">
{t.confirmLogoutMessage || "Are you sure you want to log out of your account?"}
</p>
<div className="flex justify-end gap-3">
<Button
variant="outline"
onClick={() => setShowLogoutModal(false)}
className="dark:text-white"
>
{t.cancel || "Cancel"}
</Button>
<Button
variant="destructive"
onClick={handleLogout}
className="bg-red-500 text-white hover:bg-red-600 dark:bg-red-600 dark:hover:bg-red-700"
>
{t.logout || "Logout"}
</Button>
</div>
</div>
</div>
)}
</>
)
}

View File

@@ -0,0 +1,31 @@
import { Moon, Sun, Languages } from "lucide-react"
import { Button } from "./ui/button"
import { useTheme } from "./ThemeProvider"
import { useTranslation } from "../hooks/useTranslation"
export function SettingsMenu() {
const { theme, setTheme } = useTheme()
const { lang, setLanguage } = useTranslation()
return (
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="icon"
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
className="text-slate-900 dark:text-slate-50"
>
{theme === "dark" ? <Sun className="h-5 w-5" /> : <Moon className="h-5 w-5" />}
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => setLanguage(lang === "fa" ? "en" : "fa")}
className="text-slate-900 dark:text-slate-50 font-bold"
>
<Languages className="h-5 w-5" />
</Button>
</div>
)
}

View File

@@ -0,0 +1,59 @@
import React, { createContext, useContext, useEffect, useState } from "react"
type Theme = "dark" | "light" | "system"
type ThemeProviderProps = {
children: React.ReactNode
defaultTheme?: Theme
storageKey?: string
}
type ThemeProviderState = {
theme: Theme
setTheme: (theme: Theme) => void
}
const ThemeProviderContext = createContext<ThemeProviderState>({
theme: "system",
setTheme: () => null,
})
export function ThemeProvider({
children,
defaultTheme = "system",
storageKey = "vite-ui-theme",
...props
}: ThemeProviderProps) {
const [theme, setTheme] = useState<Theme>(
() => (localStorage.getItem(storageKey) as Theme) || defaultTheme
)
useEffect(() => {
const root = window.document.documentElement
root.classList.remove("light", "dark")
if (theme === "system") {
const systemTheme = window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"
root.classList.add(systemTheme)
return
}
root.classList.add(theme)
}, [theme])
const value = {
theme,
setTheme: (theme: Theme) => {
localStorage.setItem(storageKey, theme)
setTheme(theme)
},
}
return (
<ThemeProviderContext.Provider {...props} value={value}>
{children}
</ThemeProviderContext.Provider>
)
}
export const useTheme = () => useContext(ThemeProviderContext)

View File

@@ -0,0 +1,17 @@
import { Moon, Sun } from "lucide-react"
import { useTheme } from "./ThemeProvider"
export function ThemeToggle() {
const { theme, setTheme } = useTheme()
return (
<button
onClick={() => setTheme(theme === "light" ? "dark" : "light")}
className="relative inline-flex h-10 w-10 items-center justify-center rounded-md border border-slate-200 bg-white transition-colors hover:bg-slate-100 dark:border-slate-800 dark:bg-slate-950 dark:hover:bg-slate-800"
>
<Sun className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all text-slate-900 dark:-rotate-90 dark:scale-0" />
<Moon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all text-slate-50 dark:rotate-0 dark:scale-100" />
<span className="sr-only">Toggle theme</span>
</button>
)
}

View File

@@ -0,0 +1,59 @@
import React, { useEffect, useState } from "react"
import DatePicker, { DateObject } from "react-multi-date-picker"
import persian from "react-date-object/calendars/persian"
import persian_fa from "react-date-object/locales/persian_fa"
import gregorian from "react-date-object/calendars/gregorian"
import gregorian_en from "react-date-object/locales/gregorian_en"
import "react-multi-date-picker/styles/backgrounds/bg-dark.css"
interface JalaliDatePickerProps {
value: string | null | undefined;
onChange: (date: string) => void;
label?: string;
disabled?: boolean;
}
export default function JalaliDatePicker({ value, onChange, label, disabled }: JalaliDatePickerProps) {
const isFa = document.documentElement.dir === 'rtl'
const [isDark, setIsDark] = useState(document.documentElement.classList.contains('dark'))
// Listen for dark mode changes dynamically (optional but good for UX)
useEffect(() => {
const observer = new MutationObserver(() => {
setIsDark(document.documentElement.classList.contains('dark'))
})
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] })
return () => observer.disconnect()
}, [])
const handleChange = (date: DateObject | null) => {
if (!date) {
onChange("")
} else {
// Always output standard Gregorian "YYYY-MM-DD" for backend
onChange(date.convert(gregorian, gregorian_en).format("YYYY-MM-DD"))
}
}
return (
<div className="w-full">
{label && (
<label className="text-sm font-medium dark:text-slate-300 mb-1 block">
{label}
</label>
)}
<DatePicker
value={value ? new Date(value) : null}
onChange={handleChange}
calendar={isFa ? persian : gregorian}
locale={isFa ? persian_fa : gregorian_en}
inputClass="w-full rounded-md border border-slate-300 bg-transparent px-3 py-2 text-sm dark:border-slate-700 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
containerClassName="w-full"
className={isDark ? "bg-dark" : ""}
calendarPosition="bottom-right"
fixMainPosition
disabled={disabled}
/>
</div>
)
}

View File

@@ -0,0 +1,52 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "../../lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
default: "bg-quera-blue text-white hover:bg-quera-hover dark:bg-quera-blue dark:text-white dark:hover:bg-quera-hover",
destructive: "bg-red-500 text-slate-50 hover:bg-red-500/90 dark:bg-red-900 dark:text-slate-50 dark:hover:bg-red-900/90",
outline: "border border-slate-200 bg-white text-slate-900 hover:bg-slate-100 hover:text-slate-900 dark:border-slate-800 dark:bg-slate-950 dark:text-slate-50 dark:hover:bg-slate-800 dark:hover:text-slate-50",
secondary: "bg-slate-100 text-slate-900 hover:bg-slate-100/80 dark:bg-slate-800 dark:text-slate-50 dark:hover:bg-slate-800/80",
ghost: "text-slate-900 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-50 dark:hover:bg-slate-800 dark:hover:text-slate-50",
link: "text-quera-blue underline-offset-4 hover:underline dark:text-blue-400",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
}
)
Button.displayName = "Button"
export { Button, buttonVariants }

View File

@@ -0,0 +1,25 @@
// src/components/ui/card.tsx
import * as React from "react"
import { cn } from "../../lib/utils"
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("rounded-lg border border-slate-200 bg-white text-slate-950 shadow-sm", className)} {...props} />
))
Card.displayName = "Card"
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("flex flex-col space-y-1.5 p-6", className)} {...props} />
))
CardHeader.displayName = "CardHeader"
const CardTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(({ className, ...props }, ref) => (
<h3 ref={ref} className={cn("text-2xl font-semibold leading-none tracking-tight", className)} {...props} />
))
CardTitle.displayName = "CardTitle"
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
))
CardContent.displayName = "CardContent"
export { Card, CardHeader, CardTitle, CardContent }

View File

@@ -0,0 +1,25 @@
// src/components/ui/input.tsx
import * as React from "react"
import { cn } from "../../lib/utils"
export interface InputProps
extends React.InputHTMLAttributes<HTMLInputElement> {}
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-10 w-full rounded-md border border-slate-200 bg-white dark:border-slate-800 dark:bg-slate-950 dark:text-slate-50 px-3 py-2 text-sm ring-offset-white dark:ring-offset-slate-950 file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-slate-500 dark:placeholder:text-slate-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-950 dark:focus-visible:ring-slate-300 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}
{...props}
/>
)
}
)
Input.displayName = "Input"
export { Input }

View File

@@ -0,0 +1,21 @@
import { Toaster as Sonner } from "sonner"
import { useTheme } from "../ThemeProvider"
import { useTranslation } from "../../hooks/useTranslation"
export function Toaster() {
const { theme } = useTheme()
const { lang } = useTranslation()
const isFa = lang === "fa"
return (
<Sonner
theme={theme as "light" | "dark" | "system"}
className="toaster group"
richColors
position={isFa ? "top-left" : "top-right"}
dir={isFa ? "rtl" : "ltr"}
closeButton
/>
)
}