nico/27-okt-25 #1
@@ -1,4 +1,3 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
"use client";
|
||||
|
||||
import {
|
||||
@@ -15,19 +14,19 @@ import {
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import { IconArrowBack, IconImageInPicture } from "@tabler/icons-react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter, useParams } from "next/navigation";
|
||||
import { useProxy } from "valtio/utils";
|
||||
import { toast } from "react-toastify";
|
||||
import { useProxy } from "valtio/utils";
|
||||
|
||||
|
||||
import EditEditor from "@/app/admin/(dashboard)/_com/editEditor";
|
||||
import colors from "@/con/colors";
|
||||
import ApiFetch from "@/lib/api-fetch";
|
||||
import { FileInput } from "@mantine/core";
|
||||
import stateDashboardBerita from "../../../../_state/desa/berita";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { useShallowEffect } from "@mantine/hooks";
|
||||
import { BeritaEditor } from "../../_com/BeritaEditor";
|
||||
import colors from "@/con/colors";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import stateDashboardBerita from "../../../../_state/desa/berita";
|
||||
|
||||
function EditBerita() {
|
||||
const beritaState = useProxy(stateDashboardBerita);
|
||||
@@ -36,8 +35,6 @@ function EditBerita() {
|
||||
|
||||
const [previewImage, setPreviewImage] = useState<string | null>(null);
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [editorInstance, setEditorInstance] = useState<any>(null);
|
||||
const [isEditorReady, setIsEditorReady] = useState(false);
|
||||
const [formData, setFormData] = useState({
|
||||
judul: beritaState.berita.edit.form.judul || '',
|
||||
deskripsi: beritaState.berita.edit.form.deskripsi || '',
|
||||
@@ -76,28 +73,7 @@ function EditBerita() {
|
||||
loadBerita();
|
||||
}, [params?.id]); // ✅ hapus beritaState dari dependency
|
||||
|
||||
|
||||
|
||||
// Handle editor ready
|
||||
const handleEditorReady = (editor: any) => {
|
||||
setEditorInstance(editor);
|
||||
setIsEditorReady(true);
|
||||
|
||||
// Set initial content if exists
|
||||
if (formData.content) {
|
||||
editor.commands.setContent(formData.content);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!isEditorReady || !editorInstance) {
|
||||
return toast.error("Editor belum siap");
|
||||
}
|
||||
|
||||
const htmlContent = editorInstance.getHTML();
|
||||
if (!htmlContent || htmlContent === "<p></p>") {
|
||||
return toast.warn("Konten tidak boleh kosong");
|
||||
}
|
||||
|
||||
try {
|
||||
// Update global state with form data
|
||||
@@ -105,7 +81,7 @@ function EditBerita() {
|
||||
...beritaState.berita.edit.form,
|
||||
judul: formData.judul,
|
||||
deskripsi: formData.deskripsi,
|
||||
content: htmlContent,
|
||||
content: formData.content,
|
||||
kategoriBeritaId: formData.kategoriBeritaId || '',
|
||||
imageId: formData.imageId // Keep existing imageId if not changed
|
||||
};
|
||||
@@ -189,14 +165,12 @@ function EditBerita() {
|
||||
|
||||
<Box>
|
||||
<Text fz={"sm"} fw={"bold"}>Konten</Text>
|
||||
<BeritaEditor
|
||||
initialContent={formData.content}
|
||||
onEditorReady={handleEditorReady}
|
||||
showSubmit={false}
|
||||
onUpdate={(content) => {
|
||||
setFormData((prev) => ({ ...prev, content }));
|
||||
beritaState.berita.edit.form.content = content;
|
||||
}}
|
||||
<EditEditor
|
||||
value={formData.content}
|
||||
onChange={(htmlContent) => {
|
||||
setFormData((prev) => ({ ...prev, content: htmlContent }));
|
||||
beritaState.berita.edit.form.content = htmlContent;
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
@@ -8,8 +8,8 @@ import { useParams, useRouter } from 'next/navigation';
|
||||
import { useState } from 'react';
|
||||
|
||||
import colors from '@/con/colors';
|
||||
import { ModalKonfirmasiHapus } from '../../../../_com/modalKonfirmasiHapus';
|
||||
import stateDashboardBerita from '../../../../_state/desa/berita';
|
||||
import { ModalKonfirmasiHapus } from '../../../_com/modalKonfirmasiHapus';
|
||||
import stateDashboardBerita from '../../../_state/desa/berita';
|
||||
|
||||
function DetailBerita() {
|
||||
const beritaState = useProxy(stateDashboardBerita)
|
||||
@@ -45,52 +45,64 @@ function DetailBerita() {
|
||||
return (
|
||||
<Box>
|
||||
<Box mb={10}>
|
||||
<Button variant="subtle" onClick={() => router.back()}>
|
||||
<IconArrowBack color={colors['blue-button']} size={25} />
|
||||
</Button>
|
||||
<Button variant="subtle" onClick={() => router.back()}>
|
||||
<IconArrowBack color={colors['blue-button']} size={25} />
|
||||
</Button>
|
||||
</Box>
|
||||
<Paper bg={colors['white-1']} w={{ base: "100%", md: "100%", lg: "50%" }} p={'md'}>
|
||||
<Stack>
|
||||
<Text fz={"xl"} fw={"bold"}>Detail Berita</Text>
|
||||
{beritaState.berita.findUnique.data ? (
|
||||
<Paper key={beritaState.berita.findUnique.data.id} bg={colors['BG-trans']} p={'md'}>
|
||||
{beritaState.berita.findUnique.data ? (
|
||||
<Paper key={beritaState.berita.findUnique.data.id} bg={colors['BG-trans']} p={'md'}>
|
||||
<Stack gap={"xs"}>
|
||||
<Box>
|
||||
<Text fw={"bold"} fz={"lg"}>Kategori</Text>
|
||||
<Text fz={"lg"}>{beritaState.berita.findUnique.data?.kategoriBerita?.name}</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text fw={"bold"} fz={"lg"}>Judul</Text>
|
||||
<Text fz={"lg"}>{beritaState.berita.findUnique.data?.judul}</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text fw={"bold"} fz={"lg"}>Deskripsi</Text>
|
||||
<Text fz={"lg"} >{beritaState.berita.findUnique.data?.deskripsi}</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text fw={"bold"} fz={"lg"}>Gambar</Text>
|
||||
<Image w={{ base: 150, md: 150, lg: 150 }} src={beritaState.berita.findUnique.data?.image?.link} alt="gambar" />
|
||||
<Flex gap={"xs"} mt={10}>
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (beritaState.berita.findUnique.data) {
|
||||
setSelectedId(beritaState.berita.findUnique.data.id);
|
||||
setModalHapus(true);
|
||||
}
|
||||
}}
|
||||
disabled={beritaState.berita.delete.loading || !beritaState.berita.findUnique.data}
|
||||
color={"red"}
|
||||
>
|
||||
<IconX size={20} />
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (beritaState.berita.findUnique.data) {
|
||||
router.push(`/admin/desa/berita/edit/${beritaState.berita.findUnique.data.id}`);
|
||||
}
|
||||
}}
|
||||
disabled={!beritaState.berita.findUnique.data}
|
||||
color={"green"}
|
||||
>
|
||||
<IconEdit size={20} />
|
||||
</Button>
|
||||
</Flex>
|
||||
</Box>
|
||||
</Paper>
|
||||
) : null}
|
||||
<Box>
|
||||
<Text fw={"bold"} fz={"lg"}>Konten</Text>
|
||||
<Text fz={"lg"} dangerouslySetInnerHTML={{ __html: beritaState.berita.findUnique.data?.content }} />
|
||||
</Box>
|
||||
<Flex gap={"xs"} mt={10}>
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (beritaState.berita.findUnique.data) {
|
||||
setSelectedId(beritaState.berita.findUnique.data.id);
|
||||
setModalHapus(true);
|
||||
}
|
||||
}}
|
||||
disabled={beritaState.berita.delete.loading || !beritaState.berita.findUnique.data}
|
||||
color={"red"}
|
||||
>
|
||||
<IconX size={20} />
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (beritaState.berita.findUnique.data) {
|
||||
router.push(`/admin/desa/berita/${beritaState.berita.findUnique.data.id}/edit`);
|
||||
}
|
||||
}}
|
||||
disabled={!beritaState.berita.findUnique.data}
|
||||
color={"green"}
|
||||
>
|
||||
<IconEdit size={20} />
|
||||
</Button>
|
||||
</Flex>
|
||||
</Stack>
|
||||
</Paper>
|
||||
) : null}
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
'use client'
|
||||
import colors from '@/con/colors';
|
||||
import ApiFetch from '@/lib/api-fetch';
|
||||
@@ -10,14 +9,13 @@ import { useRouter } from 'next/navigation';
|
||||
import { useState } from 'react';
|
||||
import { toast } from 'react-toastify';
|
||||
import { useProxy } from 'valtio/utils';
|
||||
import CreateEditor from '../../../_com/createEditor';
|
||||
import stateDashboardBerita from '../../../_state/desa/berita';
|
||||
import { BeritaEditor } from '../_com/BeritaEditor';
|
||||
|
||||
export default function CreateBerita() {
|
||||
const beritaState = useProxy(stateDashboardBerita);
|
||||
const [previewImage, setPreviewImage] = useState<string | null>(null);
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [editorInstance, setEditorInstance] = useState<any>(null);
|
||||
const router = useRouter()
|
||||
|
||||
const resetForm = () => {
|
||||
@@ -33,21 +31,12 @@ export default function CreateBerita() {
|
||||
// Reset state lokal
|
||||
setPreviewImage(null);
|
||||
setFile(null);
|
||||
if (editorInstance) {
|
||||
editorInstance.commands.setContent(""); // Kosongkan editor
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!file) {
|
||||
return toast.warn("Pilih file gambar terlebih dahulu");
|
||||
}
|
||||
if (!editorInstance) return toast.error("Editor belum siap");
|
||||
|
||||
const htmlContent = editorInstance.getHTML();
|
||||
if (!htmlContent || htmlContent === "<p></p>") return toast.warn("Konten tidak boleh kosong");
|
||||
|
||||
beritaState.berita.create.form.content = htmlContent;
|
||||
|
||||
// Upload gambar dulu
|
||||
const res = await ApiFetch.api.fileStorage.create.post({
|
||||
@@ -124,9 +113,11 @@ export default function CreateBerita() {
|
||||
)}
|
||||
<Box>
|
||||
<Text fz={"sm"} fw={"bold"}>Konten</Text>
|
||||
<BeritaEditor
|
||||
showSubmit={false}
|
||||
onEditorReady={(ed) => setEditorInstance(ed)}
|
||||
<CreateEditor
|
||||
value={beritaState.berita.create.form.content}
|
||||
onChange={(htmlContent) => {
|
||||
beritaState.berita.create.form.content = htmlContent;
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
<Button bg={colors['blue-button']} onClick={handleSubmit}>Simpan Berita</Button>
|
||||
|
||||
@@ -163,7 +163,7 @@ function BeritaList() {
|
||||
<Image w={100} src={item.image?.link} alt="gambar" />
|
||||
</TableTd>
|
||||
<TableTd>
|
||||
<Button bg={"green"} onClick={() => router.push(`/admin/desa/berita/detail/${item.id}`)}>
|
||||
<Button bg={"green"} onClick={() => router.push(`/admin/desa/berita/${item.id}`)}>
|
||||
<IconDeviceImacCog size={25} />
|
||||
</Button>
|
||||
</TableTd>
|
||||
|
||||
@@ -13,9 +13,6 @@ type FormCreate = Prisma.BeritaGetPayload<{
|
||||
}>;
|
||||
async function beritaCreate(context: Context) {
|
||||
const body = context.body as FormCreate;
|
||||
console.log(body)
|
||||
|
||||
// console.log(body)
|
||||
|
||||
await prisma.berita.create({
|
||||
data: {
|
||||
|
||||
@@ -40,26 +40,20 @@ export default async function handler(
|
||||
}
|
||||
|
||||
// Ensure we're returning a proper Response object
|
||||
return new Response(JSON.stringify({
|
||||
return Response.json({
|
||||
success: true,
|
||||
message: "Success fetch berita by ID",
|
||||
data,
|
||||
}), {
|
||||
}, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("Find by ID error:", e);
|
||||
return new Response(JSON.stringify({
|
||||
return Response.json({
|
||||
success: false,
|
||||
message: "Gagal mengambil berita: " + (e instanceof Error ? e.message : 'Unknown error'),
|
||||
}), {
|
||||
}, {
|
||||
status: 500,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -85,7 +85,7 @@ async function beritaUpdate(context: Context) {
|
||||
{ status: 200, headers: { 'Content-Type': 'application/json' } }
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Error updating berita:", error);
|
||||
console.error("Error updating berita:", error);
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
success: false,
|
||||
|
||||
@@ -10,7 +10,7 @@ function Footer() {
|
||||
return (
|
||||
<>
|
||||
<Stack bg={colors["blue-button"]}>
|
||||
<Box w={mobile ? "100%" : "100%"} p={"xl"} h={{ base: 1850, md: 1100 }} >
|
||||
<Box w={mobile ? "100%" : "100%"} p={"xl"} h={{ base: 2500, md: 1100 }} >
|
||||
<Center>
|
||||
<Paper w={"100%"}>
|
||||
<Box component="footer" py="xl">
|
||||
|
||||
@@ -44,9 +44,9 @@ function DesaAntiKorupsi() {
|
||||
<Stack gap={"0"} bg={colors.Bg} p={"sm"} h={mobile ? 2000 : 1150}>
|
||||
<Container w={{ base: "100%", md: "80%" }} p={"xl"} >
|
||||
<Center>
|
||||
<Text fz={"3.4rem"}>Desa Anti Korupsi</Text>
|
||||
<Text fz={{base: "2.4rem", md: "3.4rem"}}>Desa Anti Korupsi</Text>
|
||||
</Center>
|
||||
<Text ta={"center"} fz={"1.4rem"}>Desa antikorupsi mendorong pemerintahan jujur dan transparan. Keuangan desa dikelola terbuka dengan melibatkan warga mengawasi anggaran, sehingga digunakan tepat sasaran sesuai kebutuhan.</Text>
|
||||
<Text ta={"center"} fz={{base: "1.2rem", md: "1.4rem"}}>Desa antikorupsi mendorong pemerintahan jujur dan transparan. Keuangan desa dikelola terbuka dengan melibatkan warga mengawasi anggaran, sehingga digunakan tepat sasaran sesuai kebutuhan.</Text>
|
||||
<Center py={20}>
|
||||
<Button radius={"lg"} fz={"h4"} bg={colors["blue-button"]} component={Link} href={"/darmasaba/desaantikorupsi"}>Selengkapnya</Button>
|
||||
</Center>
|
||||
@@ -65,13 +65,13 @@ function DesaAntiKorupsi() {
|
||||
<Paper p={"lg"} >
|
||||
<Flex gap={"lg"} justify={"center"} align={"center"}>
|
||||
<Box >
|
||||
<Text fz={"lg"} ta={"center"} c={colors["blue-button"]}>{v.judul}</Text>
|
||||
<Text fz={{base: "1.2rem", md: "lg"}} ta={"center"} c={colors["blue-button"]}>{v.judul}</Text>
|
||||
<Flex justify={"center"} align={"center"}>
|
||||
<Box>
|
||||
{v.icon}
|
||||
</Box>
|
||||
<Box px={20}>
|
||||
<Text fz={"sm"} ta={"justify"}>{v.deskripsi}</Text>
|
||||
<Text fz={"sm"} ta={{base: "left", md: "justify"}}>{v.deskripsi}</Text>
|
||||
</Box>
|
||||
</Flex>
|
||||
</Box>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"use client";
|
||||
import { Stack, Container, Center, Text, Paper, Flex, Box, SimpleGrid } from "@mantine/core";
|
||||
import { BarChart, PieChart } from '@mantine/charts';
|
||||
import colors from "@/con/colors";
|
||||
import { BarChart, PieChart } from '@mantine/charts';
|
||||
import { Box, Center, Container, Flex, Paper, SimpleGrid, Stack, Text } from "@mantine/core";
|
||||
import { useMediaQuery } from "@mantine/hooks";
|
||||
|
||||
const dataBarChart = [
|
||||
{
|
||||
@@ -71,13 +72,14 @@ const dataPieChart3 = [
|
||||
]
|
||||
|
||||
function Kepuasan() {
|
||||
const isMobile = useMediaQuery('(max-width: 768px)');
|
||||
return (
|
||||
<Stack p={"sm"}>
|
||||
<Container w={{ base: "100%", md: "80%" }} p={"xl"}>
|
||||
<Center>
|
||||
<Text fz={"3.4rem"}>Indeks Kepuasan Masyarakat</Text>
|
||||
<Text ta={"center"} fz={{base: "2.4rem", md: "3.4rem"}}>Indeks Kepuasan Masyarakat</Text>
|
||||
</Center>
|
||||
<Text fz={"1.4rem"} ta={"center"}>Ukur kebahagiaan warga, tingkatkan layanan desa! Dengan partisipasi aktif masyarakat, kami berkomitmen untuk terus memperbaiki layanan agar lebih transparan, efektif, dan sesuai dengan kebutuhan warga. Kepuasan Anda adalah prioritas utama kami dalam membangun desa yang lebih baik!</Text>
|
||||
<Text fz={{base: "1.2rem", md: "1.4rem"}} ta={"center"}>Ukur kebahagiaan warga, tingkatkan layanan desa! Dengan partisipasi aktif masyarakat, kami berkomitmen untuk terus memperbaiki layanan agar lebih transparan, efektif, dan sesuai dengan kebutuhan warga. Kepuasan Anda adalah prioritas utama kami dalam membangun desa yang lebih baik!</Text>
|
||||
</Container>
|
||||
<Box px={"xl"}>
|
||||
<Paper p={"lg"} bg={colors.Bg}>
|
||||
@@ -118,7 +120,7 @@ function Kepuasan() {
|
||||
<Text fw={"bold"}>Jenis Kelamin</Text>
|
||||
<Box py={"xl"}>
|
||||
<PieChart
|
||||
size={250}
|
||||
size={isMobile ? 100 : 220}
|
||||
withLabelsLine
|
||||
labelsPosition="outside"
|
||||
labelsType="percent"
|
||||
@@ -135,7 +137,7 @@ function Kepuasan() {
|
||||
<Text fw={"bold"}>Pilihan</Text>
|
||||
<Box py={"xl"}>
|
||||
<PieChart
|
||||
size={250}
|
||||
size={isMobile ? 100 : 220}
|
||||
withLabelsLine
|
||||
labelsPosition="outside"
|
||||
labelsType="percent"
|
||||
@@ -152,7 +154,7 @@ function Kepuasan() {
|
||||
<Text fw={"bold"}>Umur</Text>
|
||||
<Box py={"xl"}>
|
||||
<PieChart
|
||||
size={250}
|
||||
size={isMobile ? 100 : 220}
|
||||
withLabelsLine
|
||||
labelsPosition="outside"
|
||||
labelsType="percent"
|
||||
|
||||
@@ -5,9 +5,9 @@ import {
|
||||
Card,
|
||||
Flex,
|
||||
Grid,
|
||||
GridCol,
|
||||
Image,
|
||||
Paper,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text
|
||||
} from "@mantine/core";
|
||||
@@ -58,10 +58,9 @@ function LandingPage() {
|
||||
<Grid
|
||||
>
|
||||
<Grid.Col span={{
|
||||
base: 2,
|
||||
sm: 3,
|
||||
base: 3,
|
||||
lg: 2,
|
||||
md: 3,
|
||||
xl: 2
|
||||
}}>
|
||||
<Box
|
||||
pos={"relative"}
|
||||
@@ -88,10 +87,9 @@ function LandingPage() {
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={{
|
||||
base: 2,
|
||||
sm: 3,
|
||||
base: 6,
|
||||
lg: 2,
|
||||
md: 3,
|
||||
xl: 2
|
||||
}}>
|
||||
<Box
|
||||
pos={"relative"}
|
||||
@@ -118,10 +116,9 @@ function LandingPage() {
|
||||
</Box>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{
|
||||
base: 8,
|
||||
sm: 12,
|
||||
base: 12,
|
||||
lg: 8,
|
||||
md: 12,
|
||||
xl: 8
|
||||
}}>
|
||||
<Paper
|
||||
pos={"relative"}
|
||||
@@ -130,15 +127,14 @@ function LandingPage() {
|
||||
w={{ base: "100%", sm: "auto", md: "auto" }}
|
||||
flex={{ base: "1", sm: "1", md: "1" }}
|
||||
>
|
||||
<SimpleGrid
|
||||
cols={{
|
||||
base: 2,
|
||||
sm: 1,
|
||||
md: 2,
|
||||
}}
|
||||
spacing={{ base: "xs", md: "md" }}
|
||||
<Grid
|
||||
>
|
||||
<Box>
|
||||
<GridCol span={{
|
||||
base: 12,
|
||||
lg: 6,
|
||||
md: 6,
|
||||
}}>
|
||||
<Box>
|
||||
<Text c={colors["white-1"]} fz={"sm"}>
|
||||
Jadwal Kerja
|
||||
</Text>
|
||||
@@ -168,7 +164,14 @@ function LandingPage() {
|
||||
</Flex>
|
||||
</Paper>
|
||||
</Box>
|
||||
<Box>
|
||||
</GridCol>
|
||||
|
||||
<GridCol span={{
|
||||
base: 12,
|
||||
lg: 6,
|
||||
md: 6,
|
||||
}}>
|
||||
<Box>
|
||||
<Text c={colors["white-1"]} fz={"sm"}>
|
||||
Rabu, 10 Maret 2025
|
||||
</Text>
|
||||
@@ -187,7 +190,8 @@ function LandingPage() {
|
||||
</Box>
|
||||
</Paper>
|
||||
</Box>
|
||||
</SimpleGrid>
|
||||
</GridCol>
|
||||
</Grid>
|
||||
</Paper>
|
||||
</Grid.Col>
|
||||
|
||||
|
||||
@@ -51,7 +51,7 @@ function Penghargaan() {
|
||||
<Text fz={"1.4rem"} c={"white"}>
|
||||
Juara 2 Duta Investasi
|
||||
</Text>
|
||||
<Text fz={"1.4rem"} c={"white"}>
|
||||
<Text fz={"1.2rem"} c={"white"}>
|
||||
Juara Favorit Lomba Video Pendek
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
Reference in New Issue
Block a user