feat(App.tsx): integrate theme provider for dark/light mode switching and update UI elements to use new theme classes
This commit is contained in:
@@ -5,10 +5,12 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|||||||
import { BrowserRouter, Routes, Route } from "react-router-dom";
|
import { BrowserRouter, Routes, Route } from "react-router-dom";
|
||||||
import Index from "./pages/Index";
|
import Index from "./pages/Index";
|
||||||
import NotFound from "./pages/NotFound";
|
import NotFound from "./pages/NotFound";
|
||||||
|
import { ThemeProvider } from "@/components/theme-provider";
|
||||||
|
|
||||||
const queryClient = new QueryClient();
|
const queryClient = new QueryClient();
|
||||||
|
|
||||||
const App = () => (
|
const App = () => (
|
||||||
|
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<Toaster />
|
<Toaster />
|
||||||
@@ -22,6 +24,7 @@ const App = () => (
|
|||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
|
</ThemeProvider>
|
||||||
);
|
);
|
||||||
|
|
||||||
export default App;
|
export default App;
|
||||||
|
8
src/components/theme-provider.tsx
Normal file
8
src/components/theme-provider.tsx
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { ThemeProvider as NextThemesProvider } from "next-themes";
|
||||||
|
import type { ThemeProviderProps } from "next-themes/dist/types";
|
||||||
|
|
||||||
|
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
|
||||||
|
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
|
||||||
|
}
|
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
import { Trash2 } from 'lucide-react';
|
import { Trash2, Moon, Sun } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||||
@@ -9,6 +9,8 @@ import { Slider } from '@/components/ui/slider';
|
|||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
import { toast } from '@/hooks/use-toast';
|
import { toast } from '@/hooks/use-toast';
|
||||||
import { Window } from '@tauri-apps/api/window';
|
import { Window } from '@tauri-apps/api/window';
|
||||||
|
import { Switch } from '@/components/ui/switch';
|
||||||
|
import { useTheme } from 'next-themes';
|
||||||
|
|
||||||
interface Note {
|
interface Note {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -71,6 +73,8 @@ const Index = () => {
|
|||||||
const [gotoDateInput, setGotoDateInput] = useState('');
|
const [gotoDateInput, setGotoDateInput] = useState('');
|
||||||
const [cacheMode, setCacheMode] = useState<'global' | 'scoped'>('global');
|
const [cacheMode, setCacheMode] = useState<'global' | 'scoped'>('global');
|
||||||
|
|
||||||
|
const { resolvedTheme, setTheme } = useTheme();
|
||||||
|
|
||||||
const previousNoteRef = useRef<HTMLDivElement>(null);
|
const previousNoteRef = useRef<HTMLDivElement>(null);
|
||||||
const currentNoteRef = useRef<HTMLTextAreaElement>(null);
|
const currentNoteRef = useRef<HTMLTextAreaElement>(null);
|
||||||
const scratchRef = useRef<HTMLTextAreaElement>(null);
|
const scratchRef = useRef<HTMLTextAreaElement>(null);
|
||||||
@@ -1185,12 +1189,21 @@ const Index = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`min-h-screen bg-gradient-to-br from-slate-900 to-stone-900 flex flex-col ${getTextClass('base')}`}>
|
<div className={`min-h-screen bg-background text-foreground flex flex-col ${getTextClass('base')}`}>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<header className="bg-slate-800 border-b border-slate-700 p-4 shadow-sm">
|
<header className="bg-card text-card-foreground border-b border-border p-4 shadow-sm">
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<h1 className={`${getTextClass('5xl')} font-bold text-slate-100`}>Journal</h1>
|
<h1 className={`${getTextClass('5xl')} font-bold`}>Journal</h1>
|
||||||
<div className="flex gap-2 items-center">
|
<div className="flex gap-3 items-center">
|
||||||
|
<div className="hidden sm:flex items-center gap-2">
|
||||||
|
<Sun className="h-4 w-4" />
|
||||||
|
<Switch
|
||||||
|
checked={resolvedTheme === 'dark'}
|
||||||
|
onCheckedChange={(checked) => setTheme(checked ? 'dark' : 'light')}
|
||||||
|
aria-label="Toggle theme"
|
||||||
|
/>
|
||||||
|
<Moon className="h-4 w-4" />
|
||||||
|
</div>
|
||||||
<Select value={fontSize} onValueChange={saveFontSizeSetting}>
|
<Select value={fontSize} onValueChange={saveFontSizeSetting}>
|
||||||
<SelectTrigger className="w-32">
|
<SelectTrigger className="w-32">
|
||||||
<SelectValue placeholder="Font Size" />
|
<SelectValue placeholder="Font Size" />
|
||||||
@@ -1217,15 +1230,15 @@ const Index = () => {
|
|||||||
{/* Previous Note - Top Half */}
|
{/* Previous Note - Top Half */}
|
||||||
<div
|
<div
|
||||||
ref={previousNoteRef}
|
ref={previousNoteRef}
|
||||||
className="flex-1 bg-slate-800 rounded-lg border border-slate-700 shadow-sm p-6 overflow-auto cursor-pointer select-none"
|
className="flex-1 bg-card rounded-lg border border-border shadow-sm p-6 overflow-auto cursor-pointer select-none"
|
||||||
>
|
>
|
||||||
<div className={`${getTextClass('2xl')} text-slate-400 mb-3`}>
|
<div className={`${getTextClass('2xl')} text-muted-foreground mb-3`}>
|
||||||
Previous Entry {currentNoteIndex > 0 && `(${currentNoteIndex + 1} of ${noteCache.length})`}
|
Previous Entry {currentNoteIndex > 0 && `(${currentNoteIndex + 1} of ${noteCache.length})`}
|
||||||
<span className={`ml-2 ${getTextClass('xl')} text-slate-500`}>Scroll to navigate</span>
|
<span className={`ml-2 ${getTextClass('xl')} text-muted-foreground`}>Scroll to navigate</span>
|
||||||
</div>
|
</div>
|
||||||
{previousNote ? (
|
{previousNote ? (
|
||||||
<div className="space-y-4 h-[calc(100%-2rem)]">
|
<div className="space-y-4 h-[calc(100%-2rem)]">
|
||||||
<div className={`${getTextClass('xl')} text-slate-500`}>
|
<div className={`${getTextClass('xl')} text-muted-foreground`}>
|
||||||
{formatDate(previousNote.epochTime)}
|
{formatDate(previousNote.epochTime)}
|
||||||
</div>
|
</div>
|
||||||
<Textarea
|
<Textarea
|
||||||
@@ -1239,20 +1252,20 @@ const Index = () => {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onBlur={handlePreviousNoteBlur}
|
onBlur={handlePreviousNoteBlur}
|
||||||
className={`h-[calc(100%-3rem)] border-0 resize-none focus:ring-0 text-slate-200 bg-transparent ${getTextClass('2xl')}`}
|
className={`h-[calc(100%-3rem)] border-0 resize-none focus:ring-0 bg-transparent ${getTextClass('2xl')}`}
|
||||||
placeholder="Previous entry content..."
|
placeholder="Previous entry content..."
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className={`text-slate-500 text-center py-12 ${getTextClass('2xl')}`}>
|
<div className={`text-muted-foreground text-center py-12 ${getTextClass('2xl')}`}>
|
||||||
{isLoading ? 'Loading notes...' : 'No previous entries found'}
|
{isLoading ? 'Loading notes...' : 'No previous entries found'}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Current Note - Bottom Half */}
|
{/* Current Note - Bottom Half */}
|
||||||
<div className="flex-1 bg-slate-800 rounded-lg border border-slate-700 shadow-sm p-6 overflow-hidden">
|
<div className="flex-1 bg-card rounded-lg border border-border shadow-sm p-6 overflow-hidden">
|
||||||
<div className={`${getTextClass('2xl')} text-slate-400 mb-3`}>Current Entry</div>
|
<div className={`${getTextClass('2xl')} text-muted-foreground mb-3`}>Current Entry</div>
|
||||||
<Textarea
|
<Textarea
|
||||||
ref={currentNoteRef}
|
ref={currentNoteRef}
|
||||||
value={currentNote}
|
value={currentNote}
|
||||||
@@ -1260,20 +1273,20 @@ const Index = () => {
|
|||||||
setCurrentNote(e.target.value);
|
setCurrentNote(e.target.value);
|
||||||
}}
|
}}
|
||||||
onBlur={handleCurrentNoteBlur}
|
onBlur={handleCurrentNoteBlur}
|
||||||
className={`h-[calc(100%-2rem)] border-0 resize-none focus:ring-0 text-slate-200 bg-transparent ${getTextClass('2xl')}`}
|
className={`h-[calc(100%-2rem)] border-0 resize-none focus:ring-0 bg-transparent ${getTextClass('2xl')}`}
|
||||||
placeholder="Start writing your thoughts..."
|
placeholder="Start writing your thoughts..."
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right Panel - Scratch Pad - 30% */}
|
{/* Right Panel - Scratch Pad - 30% */}
|
||||||
<div className="flex-[3] bg-slate-800 rounded-lg border border-slate-700 shadow-sm p-6 h-[calc(100vh-8rem)] overflow-hidden">
|
<div className="flex-[3] bg-card rounded-lg border border-border shadow-sm p-6 h-[calc(100vh-8rem)] overflow-hidden">
|
||||||
<div className={`${getTextClass('2xl')} text-slate-400 mb-3`}>Scratch Pad</div>
|
<div className={`${getTextClass('2xl')} text-muted-foreground mb-3`}>Scratch Pad</div>
|
||||||
<Textarea
|
<Textarea
|
||||||
ref={scratchRef}
|
ref={scratchRef}
|
||||||
value={scratchPad}
|
value={scratchPad}
|
||||||
onChange={(e) => setScratchPad(e.target.value)}
|
onChange={(e) => setScratchPad(e.target.value)}
|
||||||
className={`h-[calc(100%-2rem)] border-0 resize-none focus:ring-0 text-slate-200 bg-transparent ${getTextClass('2xl')}`}
|
className={`h-[calc(100%-2rem)] border-0 resize-none focus:ring-0 bg-transparent ${getTextClass('2xl')}`}
|
||||||
placeholder="Quick notes, ideas, temporary thoughts..."
|
placeholder="Quick notes, ideas, temporary thoughts..."
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -1281,9 +1294,9 @@ const Index = () => {
|
|||||||
|
|
||||||
{/* Search Modal */}
|
{/* Search Modal */}
|
||||||
<Dialog open={isSearchOpen} onOpenChange={setIsSearchOpen}>
|
<Dialog open={isSearchOpen} onOpenChange={setIsSearchOpen}>
|
||||||
<DialogContent className="max-w-4xl max-h-[80vh] overflow-hidden bg-slate-800 border-slate-700">
|
<DialogContent className="max-w-4xl max-h-[80vh] overflow-hidden bg-popover border-border">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className={`${getTextClass('4xl')} text-slate-100`}>Search Notes</DialogTitle>
|
<DialogTitle className={`${getTextClass('4xl')}`}>Search Notes</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<Input
|
<Input
|
||||||
@@ -1292,31 +1305,31 @@ const Index = () => {
|
|||||||
placeholder="Search your notes..."
|
placeholder="Search your notes..."
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
className={`${getTextClass('2xl')} bg-slate-700 border-slate-600 text-slate-200`}
|
className={`${getTextClass('2xl')} bg-secondary border-input text-secondary-foreground`}
|
||||||
/>
|
/>
|
||||||
<div className="overflow-auto max-h-[50vh] space-y-4">
|
<div className="overflow-auto max-h-[50vh] space-y-4">
|
||||||
{searchResults.length > 0 ? (
|
{searchResults.length > 0 ? (
|
||||||
searchResults.map((note) => (
|
searchResults.map((note) => (
|
||||||
<div
|
<div
|
||||||
key={note.id}
|
key={note.id}
|
||||||
className="p-4 border border-slate-700 rounded-lg hover:bg-slate-700 cursor-pointer"
|
className="p-4 border border-border rounded-lg hover:bg-accent cursor-pointer"
|
||||||
onClick={() => handleSearchResultClick(note)}
|
onClick={() => handleSearchResultClick(note)}
|
||||||
>
|
>
|
||||||
<div className={`${getTextClass('xl')} text-slate-400 mb-2`}>
|
<div className={`${getTextClass('xl')} text-muted-foreground mb-2`}>
|
||||||
{formatDate(note.epochTime)}
|
{formatDate(note.epochTime)}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className={`${getTextClass('2xl')} text-slate-200 whitespace-pre-wrap`}
|
className={`${getTextClass('2xl')} whitespace-pre-wrap`}
|
||||||
dangerouslySetInnerHTML={{ __html: note.snippet || note.content }}
|
dangerouslySetInnerHTML={{ __html: note.snippet || note.content }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
) : searchQuery ? (
|
) : searchQuery ? (
|
||||||
<div className={`text-center py-8 text-slate-400 ${getTextClass('2xl')}`}>
|
<div className={`text-center py-8 text-muted-foreground ${getTextClass('2xl')}`}>
|
||||||
No results found for "{searchQuery}"
|
No results found for "{searchQuery}"
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className={`text-center py-8 text-slate-400 ${getTextClass('2xl')}`}>
|
<div className={`text-center py-8 text-muted-foreground ${getTextClass('2xl')}`}>
|
||||||
Start typing to search your notes...
|
Start typing to search your notes...
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -1327,9 +1340,9 @@ const Index = () => {
|
|||||||
|
|
||||||
{/* Go To Date Modal */}
|
{/* Go To Date Modal */}
|
||||||
<Dialog open={isGotoOpen} onOpenChange={setIsGotoOpen}>
|
<Dialog open={isGotoOpen} onOpenChange={setIsGotoOpen}>
|
||||||
<DialogContent className="max-w-xl bg-slate-800 border-slate-700">
|
<DialogContent className="max-w-xl bg-popover border-border">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className={`${getTextClass('4xl')} text-slate-100`}>Go to date</DialogTitle>
|
<DialogTitle className={`${getTextClass('4xl')}`}>Go to date</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<Input
|
<Input
|
||||||
@@ -1343,7 +1356,7 @@ const Index = () => {
|
|||||||
goToClosestNoteToDate(gotoDateInput);
|
goToClosestNoteToDate(gotoDateInput);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className={`${getTextClass('2xl')} bg-slate-700 border-slate-600 text-slate-200`}
|
className={`${getTextClass('2xl')} bg-secondary border-input text-secondary-foreground`}
|
||||||
/>
|
/>
|
||||||
<div className="flex justify-end gap-2">
|
<div className="flex justify-end gap-2">
|
||||||
<Button
|
<Button
|
||||||
@@ -1366,14 +1379,14 @@ const Index = () => {
|
|||||||
|
|
||||||
{/* Cleanup Modal */}
|
{/* Cleanup Modal */}
|
||||||
<Dialog open={isCleanupOpen} onOpenChange={setIsCleanupOpen}>
|
<Dialog open={isCleanupOpen} onOpenChange={setIsCleanupOpen}>
|
||||||
<DialogContent className="max-w-6xl max-h-[90vh] overflow-hidden bg-slate-800 border-slate-700">
|
<DialogContent className="max-w-6xl max-h-[90vh] overflow-hidden bg-popover border-border">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className={`${getTextClass('4xl')} text-slate-100 mb-6`}>Cleanup Notes</DialogTitle>
|
<DialogTitle className={`${getTextClass('4xl')} mb-6`}>Cleanup Notes</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<label className={`${getTextClass('2xl')} font-medium text-slate-200 mb-4 block`}>
|
<label className={`${getTextClass('2xl')} font-medium mb-4 block`}>
|
||||||
Character Repetition Threshold: {(cleanupThreshold[0] * 100).toFixed(0)}%
|
Character Repetition Threshold: {(cleanupThreshold[0] * 100).toFixed(0)}%
|
||||||
</label>
|
</label>
|
||||||
<Slider
|
<Slider
|
||||||
@@ -1384,13 +1397,13 @@ const Index = () => {
|
|||||||
step={0.05}
|
step={0.05}
|
||||||
className="w-full h-8"
|
className="w-full h-8"
|
||||||
/>
|
/>
|
||||||
<p className={`${getTextClass('xl')} text-slate-400 mt-2`}>
|
<p className={`${getTextClass('xl')} text-muted-foreground mt-2`}>
|
||||||
Notes where a single character makes up more than this percentage will be flagged
|
Notes where a single character makes up more than this percentage will be flagged
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className={`${getTextClass('2xl')} font-medium text-slate-200 mb-4 block`}>
|
<label className={`${getTextClass('2xl')} font-medium mb-4 block`}>
|
||||||
Minimum Letter Count: {minLetterCount[0]}
|
Minimum Letter Count: {minLetterCount[0]}
|
||||||
</label>
|
</label>
|
||||||
<Slider
|
<Slider
|
||||||
@@ -1401,7 +1414,7 @@ const Index = () => {
|
|||||||
step={1}
|
step={1}
|
||||||
className="w-full h-8"
|
className="w-full h-8"
|
||||||
/>
|
/>
|
||||||
<p className={`${getTextClass('xl')} text-slate-400 mt-2`}>
|
<p className={`${getTextClass('xl')} text-muted-foreground mt-2`}>
|
||||||
Notes with fewer letters than this will be flagged as potentially accidental
|
Notes with fewer letters than this will be flagged as potentially accidental
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -1416,17 +1429,17 @@ const Index = () => {
|
|||||||
problematicNotes.map((note) => (
|
problematicNotes.map((note) => (
|
||||||
<div
|
<div
|
||||||
key={note.id}
|
key={note.id}
|
||||||
className="p-6 border border-orange-900 rounded-lg bg-orange-950"
|
className="p-6 border border-destructive/50 rounded-lg bg-destructive/10"
|
||||||
>
|
>
|
||||||
<div className="flex justify-between items-start mb-4">
|
<div className="flex justify-between items-start mb-4">
|
||||||
<div className={`${getTextClass('2xl')} text-slate-400`}>
|
<div className={`${getTextClass('2xl')} text-muted-foreground`}>
|
||||||
{formatDate(note.epochTime)}
|
{formatDate(note.epochTime)}
|
||||||
</div>
|
</div>
|
||||||
<Badge variant="destructive" className={`${getTextClass('2xl')} px-4 py-2`}>
|
<Badge variant="destructive" className={`${getTextClass('2xl')} px-4 py-2`}>
|
||||||
{note.problemReason}
|
{note.problemReason}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<div className={`${getTextClass('2xl')} text-slate-200 mb-4`}>
|
<div className={`${getTextClass('2xl')} mb-4`}>
|
||||||
{note.content.substring(0, 200)}
|
{note.content.substring(0, 200)}
|
||||||
{note.content.length > 200 && '...'}
|
{note.content.length > 200 && '...'}
|
||||||
</div>
|
</div>
|
||||||
@@ -1441,7 +1454,7 @@ const Index = () => {
|
|||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
<div className={`text-center py-12 text-slate-400 ${getTextClass('2xl')}`}>
|
<div className={`text-center py-12 text-muted-foreground ${getTextClass('2xl')}`}>
|
||||||
No problematic notes found with current settings
|
No problematic notes found with current settings
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
@@ -12,11 +12,11 @@ const NotFound = () => {
|
|||||||
}, [location.pathname]);
|
}, [location.pathname]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center bg-gray-100">
|
<div className="min-h-screen flex items-center justify-center bg-background text-foreground">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<h1 className="text-4xl font-bold mb-4">404</h1>
|
<h1 className="text-4xl font-bold mb-4">404</h1>
|
||||||
<p className="text-xl text-gray-600 mb-4">Oops! Page not found</p>
|
<p className="text-xl text-muted-foreground mb-4">Oops! Page not found</p>
|
||||||
<a href="/" className="text-blue-500 hover:text-blue-700 underline">
|
<a href="/" className="text-primary underline">
|
||||||
Return to Home
|
Return to Home
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
Reference in New Issue
Block a user