initial commit
This commit is contained in:
9
src/components/AppLayout.tsx
Normal file
9
src/components/AppLayout.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
36
src/components/LanguageProvider.tsx
Normal file
36
src/components/LanguageProvider.tsx
Normal 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
86
src/components/Navbar.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
31
src/components/SettingsMenu.tsx
Normal file
31
src/components/SettingsMenu.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
59
src/components/ThemeProvider.tsx
Normal file
59
src/components/ThemeProvider.tsx
Normal 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)
|
||||
17
src/components/ThemeToggle.tsx
Normal file
17
src/components/ThemeToggle.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
59
src/components/ui/JalaliDatePicker.tsx
Normal file
59
src/components/ui/JalaliDatePicker.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
52
src/components/ui/button.tsx
Normal file
52
src/components/ui/button.tsx
Normal 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 }
|
||||
25
src/components/ui/card.tsx
Normal file
25
src/components/ui/card.tsx
Normal 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 }
|
||||
25
src/components/ui/input.tsx
Normal file
25
src/components/ui/input.tsx
Normal 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 }
|
||||
21
src/components/ui/toaster.tsx
Normal file
21
src/components/ui/toaster.tsx
Normal 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
|
||||
/>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user