feat(shares): add shares page with search, table, and modals

Introduce a new shares page that includes a search bar, shares table, and various modals for managing shares. The page supports creating, editing, deleting, and generating links for shares. Additionally, it includes functionality for notifying recipients and viewing share details. The layout and components are designed to be reusable and maintainable.
This commit is contained in:
Daniel Luiz Alves
2025-04-14 15:21:31 -03:00
parent 15dce5dab1
commit e50abf953e
28 changed files with 481 additions and 10 deletions

View File

@@ -2,12 +2,13 @@ import { Metadata } from "next";
import { getTranslations } from "next-intl/server"; import { getTranslations } from "next-intl/server";
import { getAllConfigs } from "@/http/endpoints"; import { getAllConfigs } from "@/http/endpoints";
import { Config } from "@/types/layout";
export async function generateMetadata(): Promise<Metadata> { export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations(); const t = await getTranslations();
const response = await getAllConfigs(); const response = await getAllConfigs();
const appNameConfig = response.data.configs.find((config: any) => config.key === "appName"); const appNameConfig = response.data.configs.find((config: Config) => config.key === "appName");
const appName = appNameConfig?.value || "Palmr"; const appName = appNameConfig?.value || "Palmr";
return { return {

View File

@@ -5,3 +5,8 @@ export interface HomeContentProps {
export interface HomeHeaderProps { export interface HomeHeaderProps {
title: string; title: string;
} }
export interface Config {
key: string;
value: string;
}

View File

@@ -2,6 +2,7 @@ import { Metadata } from "next";
import { getTranslations } from "next-intl/server"; import { getTranslations } from "next-intl/server";
import { getAllConfigs } from "@/http/endpoints"; import { getAllConfigs } from "@/http/endpoints";
import { Config } from "@/types/layout";
interface LayoutProps { interface LayoutProps {
children: React.ReactNode; children: React.ReactNode;
@@ -11,7 +12,7 @@ export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations(); const t = await getTranslations();
const response = await getAllConfigs(); const response = await getAllConfigs();
const appNameConfig = response.data.configs.find((config: any) => config.key === "appName"); const appNameConfig = response.data.configs.find((config: Config) => config.key === "appName");
const appName = appNameConfig?.value || "Palmr"; const appName = appNameConfig?.value || "Palmr";
return { return {

View File

@@ -2,6 +2,7 @@ import { Metadata } from "next";
import { getTranslations } from "next-intl/server"; import { getTranslations } from "next-intl/server";
import { getAllConfigs } from "@/http/endpoints"; import { getAllConfigs } from "@/http/endpoints";
import { Config } from "@/types/layout";
interface LayoutProps { interface LayoutProps {
children: React.ReactNode; children: React.ReactNode;
@@ -11,7 +12,7 @@ export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations(); const t = await getTranslations();
const response = await getAllConfigs(); const response = await getAllConfigs();
const appNameConfig = response.data.configs.find((config: any) => config.key === "appName"); const appNameConfig = response.data.configs.find((config: Config) => config.key === "appName");
const appName = appNameConfig?.value || "Palmr"; const appName = appNameConfig?.value || "Palmr";
return { return {

View File

@@ -35,13 +35,14 @@ export default async function RootLayout({
children: React.ReactNode; children: React.ReactNode;
}>) { }>) {
const locale = await getLocale(); const locale = await getLocale();
const isRTL = locale === "ar-SA";
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
useAppInfo.getState().refreshAppInfo(); useAppInfo.getState().refreshAppInfo();
} }
return ( return (
<html lang={locale} suppressHydrationWarning> <html lang={locale} dir={isRTL ? "rtl" : "ltr"} suppressHydrationWarning>
<head> <head>
<Favicon /> <Favicon />
</head> </head>

View File

@@ -2,6 +2,7 @@ import { Metadata } from "next";
import { getTranslations } from "next-intl/server"; import { getTranslations } from "next-intl/server";
import { getAllConfigs } from "@/http/endpoints"; import { getAllConfigs } from "@/http/endpoints";
import { Config } from "@/types/layout";
interface LayoutProps { interface LayoutProps {
children: React.ReactNode; children: React.ReactNode;
@@ -11,7 +12,7 @@ export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations(); const t = await getTranslations();
const response = await getAllConfigs(); const response = await getAllConfigs();
const appNameConfig = response.data.configs.find((config: any) => config.key === "appName"); const appNameConfig = response.data.configs.find((config: Config) => config.key === "appName");
const appName = appNameConfig?.value || "Palmr"; const appName = appNameConfig?.value || "Palmr";
return { return {

View File

@@ -2,6 +2,7 @@ import { Metadata } from "next";
import { getTranslations } from "next-intl/server"; import { getTranslations } from "next-intl/server";
import { getAllConfigs } from "@/http/endpoints"; import { getAllConfigs } from "@/http/endpoints";
import { Config } from "@/types/layout";
interface LayoutProps { interface LayoutProps {
children: React.ReactNode; children: React.ReactNode;
@@ -11,7 +12,7 @@ export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations(); const t = await getTranslations();
const response = await getAllConfigs(); const response = await getAllConfigs();
const appNameConfig = response.data.configs.find((config: any) => config.key === "appName"); const appNameConfig = response.data.configs.find((config: Config) => config.key === "appName");
const appName = appNameConfig?.value || "Palmr"; const appName = appNameConfig?.value || "Palmr";
return { return {

View File

@@ -2,6 +2,7 @@ import { Metadata } from "next";
import { getTranslations } from "next-intl/server"; import { getTranslations } from "next-intl/server";
import { getAllConfigs } from "@/http/endpoints"; import { getAllConfigs } from "@/http/endpoints";
import { Config } from "@/types/layout";
interface LayoutProps { interface LayoutProps {
children: React.ReactNode; children: React.ReactNode;
@@ -11,7 +12,7 @@ export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations(); const t = await getTranslations();
const response = await getAllConfigs(); const response = await getAllConfigs();
const appNameConfig = response.data.configs.find((config: any) => config.key === "appName"); const appNameConfig = response.data.configs.find((config: Config) => config.key === "appName");
const appName = appNameConfig?.value || "Palmr"; const appName = appNameConfig?.value || "Palmr";
return { return {

View File

@@ -0,0 +1,23 @@
import { IconPlus, IconShare } from "@tabler/icons-react";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
interface EmptySharesStateProps {
onCreateShare: () => void;
}
export function EmptySharesState({ onCreateShare }: EmptySharesStateProps) {
const t = useTranslations();
return (
<div className="text-center py-6 flex flex-col items-center gap-2">
<IconShare className="w-8 h-8 text-gray-500" />
<p className="text-gray-500">{t("shares.empty.message")}</p>
<Button variant="default" size="sm" onClick={onCreateShare} className="gap-2">
<IconPlus className="h-4 w-4" />
{t("shares.empty.createButton")}
</Button>
</div>
);
}

View File

@@ -0,0 +1,44 @@
import Link from "next/link";
import { IconLayoutDashboard, IconShare } from "@tabler/icons-react";
import { useTranslations } from "next-intl";
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbSeparator,
} from "@/components/ui/breadcrumb";
import { Separator } from "@/components/ui/separator";
export function SharesHeader() {
const t = useTranslations();
return (
<div className="flex flex-col gap-4">
<div className="flex flex-row items-center gap-2">
<IconShare className="text-xl" />
<h1 className="text-2xl font-bold">{t("shares.header.title")}</h1>
</div>
<Separator />
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink asChild>
<Link href="/dashboard" className="flex items-center">
<IconLayoutDashboard size={20} className="mr-2" />
{t("common.dashboard")}
</Link>
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<span className="flex items-center gap-2">
<IconShare size={20} /> {t("shares.header.myShares")}
</span>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
</div>
);
}

View File

@@ -0,0 +1,48 @@
import { CreateShareModal } from "@/components/modals/create-share-modal";
import { GenerateShareLinkModal } from "@/components/modals/generate-share-link-modal";
import { ShareActionsModals } from "@/components/modals/share-actions-modals";
import { ShareDetailsModal } from "@/components/modals/share-details-modal";
import { SharesModalsProps } from "../types";
export function SharesModals({
isCreateModalOpen,
onCloseCreateModal,
shareToViewDetails,
shareToGenerateLink,
shareManager,
onSuccess,
onCloseViewDetails,
onCloseGenerateLink,
}: SharesModalsProps) {
return (
<>
<CreateShareModal isOpen={isCreateModalOpen} onClose={onCloseCreateModal} onSuccess={onSuccess} />
<ShareActionsModals
shareToDelete={shareManager.shareToDelete}
shareToEdit={shareManager.shareToEdit}
shareToManageFiles={shareManager.shareToManageFiles}
shareToManageRecipients={shareManager.shareToManageRecipients}
onCloseDelete={() => shareManager.setShareToDelete(null)}
onCloseEdit={() => shareManager.setShareToEdit(null)}
onCloseManageFiles={() => shareManager.setShareToManageFiles(null)}
onCloseManageRecipients={() => shareManager.setShareToManageRecipients(null)}
onDelete={shareManager.handleDelete}
onEdit={shareManager.handleEdit}
onManageFiles={shareManager.handleManageFiles}
onManageRecipients={shareManager.handleManageRecipients}
onSuccess={onSuccess}
/>
<ShareDetailsModal shareId={shareToViewDetails?.id || null} onClose={onCloseViewDetails} />
<GenerateShareLinkModal
share={shareToGenerateLink}
shareId={shareToGenerateLink?.id || null}
onClose={onCloseGenerateLink}
onGenerate={shareManager.handleGenerateLink}
onSuccess={onSuccess}
/>
</>
);
}

View File

@@ -0,0 +1,48 @@
import { IconPlus, IconSearch } from "@tabler/icons-react";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { SharesSearchProps } from "../types";
export function SharesSearch({
searchQuery,
onSearchChange,
onCreateShare,
totalShares,
filteredCount,
}: SharesSearchProps) {
const t = useTranslations();
return (
<div className="flex flex-col gap-6">
<div className="flex justify-between items-center">
<h2 className="text-xl font-semibold">{t("shares.search.title")}</h2>
<Button onClick={onCreateShare}>
<IconPlus className="h-4 w-4" />
{t("shares.search.createButton")}
</Button>
</div>
<div className="flex items-center gap-2">
<div className="relative max-w-xs">
<IconSearch className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
className="pl-9"
placeholder={t("shares.search.placeholder")}
value={searchQuery}
onChange={(e) => onSearchChange(e.target.value)}
/>
</div>
{searchQuery && (
<span className="text-sm text-muted-foreground">
{t("shares.search.results", {
filtered: filteredCount,
total: totalShares,
})}
</span>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,21 @@
import { SharesTable } from "@/components/tables/shares-table";
import { SharesTableContainerProps } from "../types";
import { EmptySharesState } from "./empty-shares-state";
export function SharesTableContainer({ shares, onCopyLink, onCreateShare, shareManager }: SharesTableContainerProps) {
return shares.length > 0 ? (
<SharesTable
shares={shares}
onCopyLink={onCopyLink}
onDelete={shareManager.setShareToDelete}
onEdit={shareManager.setShareToEdit}
onGenerateLink={shareManager.setShareToGenerateLink}
onManageFiles={shareManager.setShareToManageFiles}
onManageRecipients={shareManager.setShareToManageRecipients}
onNotifyRecipients={shareManager.handleNotifyRecipients}
onViewDetails={shareManager.setShareToViewDetails}
/>
) : (
<EmptySharesState onCreateShare={onCreateShare} />
);
}

View File

@@ -0,0 +1,94 @@
"use client";
import { useEffect, useState } from "react";
import { useTranslations } from "next-intl";
import { toast } from "sonner";
import { getAllConfigs, listUserShares, notifyRecipients } from "@/http/endpoints";
import { ListUserShares200SharesItem } from "@/http/models/listUserShares200SharesItem";
export function useShares() {
const t = useTranslations();
const [shares, setShares] = useState<ListUserShares200SharesItem[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [searchQuery, setSearchQuery] = useState("");
const [shareToViewDetails, setShareToViewDetails] = useState<ListUserShares200SharesItem | null>(null);
const [shareToGenerateLink, setShareToGenerateLink] = useState<ListUserShares200SharesItem | null>(null);
const [smtpEnabled, setSmtpEnabled] = useState("false");
const loadShares = async () => {
try {
const response = await listUserShares();
const allShares = response.data.shares || [];
const sortedShares = [...allShares].sort(
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
);
setShares(sortedShares);
} catch (error) {
toast.error(t("shares.errors.loadFailed"));
console.error(error);
} finally {
setIsLoading(false);
}
};
const loadConfigs = async () => {
try {
const response = await getAllConfigs();
const smtpConfig = response.data.configs.find((config: any) => config.key === "smtpEnabled");
setSmtpEnabled(smtpConfig?.value || "false");
} catch (error) {
console.error(t("shares.errors.smtpConfigFailed"), error);
}
};
useEffect(() => {
loadShares();
loadConfigs();
}, []);
const filteredShares = shares.filter(
(share) => share.name?.toLowerCase().includes(searchQuery.toLowerCase()) ?? false
);
const handleCopyLink = (share: ListUserShares200SharesItem) => {
if (!share.alias?.alias) return;
const link = `${window.location.origin}/s/${share.alias.alias}`;
navigator.clipboard.writeText(link);
toast.success(t("shares.messages.linkCopied"));
};
const handleNotifyRecipients = async (share: ListUserShares200SharesItem) => {
if (!share.alias?.alias) return;
const link = `${window.location.origin}/s/${share.alias.alias}`;
try {
await notifyRecipients(share.id, { shareLink: link });
toast.success(t("shares.messages.recipientsNotified"));
} catch (error) {
console.error(error);
toast.error(t("shares.errors.notifyFailed"));
}
};
return {
shares,
isLoading,
searchQuery,
shareToViewDetails,
shareToGenerateLink,
filteredShares,
smtpEnabled,
setSearchQuery,
setShareToViewDetails,
setShareToGenerateLink,
handleCopyLink,
handleNotifyRecipients,
loadShares,
};
}

View File

@@ -0,0 +1,25 @@
import { Metadata } from "next";
import { getTranslations } from "next-intl/server";
import { getAllConfigs } from "@/http/endpoints";
import { Config } from "@/types/layout";
interface LayoutProps {
children: React.ReactNode;
}
export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations();
const response = await getAllConfigs();
const appNameConfig = response.data.configs.find((config: Config) => config.key === "appName");
const appName = appNameConfig?.value || "Palmr";
return {
title: `${t("shares.pageTitle")} | ${appName}`,
};
}
export default function UsersManagementLayout({ children }: LayoutProps) {
return <>{children}</>;
}

View File

@@ -0,0 +1,82 @@
"use client";
import { LoadingScreen } from "@/components/layout/loading-screen";
import { Navbar } from "@/components/layout/navbar";
import { Card, CardContent } from "@/components/ui/card";
import { DefaultFooter } from "@/components/ui/default-footer";
import { useDisclosure } from "@/hooks/use-disclosure";
import { useShareManager } from "@/hooks/use-share-manager";
import { SharesHeader } from "./components/shares-header";
import { SharesModals } from "./components/shares-modals";
import { SharesSearch } from "./components/shares-search";
import { SharesTableContainer } from "./components/shares-table-container";
import { useShares } from "./hooks/use-shares";
export default function SharesPage() {
const {
shares,
isLoading,
searchQuery,
setSearchQuery,
filteredShares,
shareToViewDetails,
shareToGenerateLink,
handleCopyLink,
loadShares,
setShareToViewDetails,
setShareToGenerateLink,
smtpEnabled,
} = useShares();
const { isOpen: isCreateModalOpen, onOpen: onOpenCreateModal, onClose: onCloseCreateModal } = useDisclosure();
const shareManager = useShareManager(loadShares);
if (isLoading) {
return <LoadingScreen />;
}
return (
<div className="w-full h-screen flex flex-col">
<Navbar />
<div className="flex-1 max-w-7xl mx-auto w-full px-6 py-8">
<div className="flex flex-col gap-8">
<SharesHeader />
<Card>
<CardContent>
<div className="flex flex-col gap-6">
<SharesSearch
filteredCount={filteredShares.length}
searchQuery={searchQuery}
totalShares={shares.length}
onCreateShare={onOpenCreateModal}
onSearchChange={setSearchQuery}
/>
<SharesTableContainer
shareManager={shareManager}
shares={filteredShares}
onCopyLink={handleCopyLink}
onCreateShare={onOpenCreateModal}
/>
</div>
</CardContent>
</Card>
<SharesModals
isCreateModalOpen={isCreateModalOpen}
shareManager={shareManager}
shareToGenerateLink={shareToGenerateLink}
shareToViewDetails={shareToViewDetails}
smtpEnabled={smtpEnabled}
onCloseCreateModal={onCloseCreateModal}
onCloseGenerateLink={() => setShareToGenerateLink(null)}
onCloseViewDetails={() => setShareToViewDetails(null)}
onSuccess={loadShares}
/>
</div>
</div>
<DefaultFooter />
</div>
);
}

View File

@@ -0,0 +1,48 @@
import { ListUserShares200SharesItem } from "@/http/models";
export interface SharesSearchProps {
searchQuery: string;
onSearchChange: (value: string) => void;
onCreateShare: () => void;
totalShares: number;
filteredCount: number;
}
export interface SharesTableContainerProps {
shares: any[];
onCopyLink: (share: any) => void;
onCreateShare: () => void;
shareManager: any;
}
export interface ShareManager {
shareToDelete: ListUserShares200SharesItem | null;
shareToEdit: ListUserShares200SharesItem | null;
shareToManageFiles: ListUserShares200SharesItem | null;
shareToManageRecipients: ListUserShares200SharesItem | null;
setShareToDelete: (share: ListUserShares200SharesItem | null) => void;
setShareToEdit: (share: ListUserShares200SharesItem | null) => void;
setShareToManageFiles: (share: ListUserShares200SharesItem | null) => void;
setShareToManageRecipients: (share: ListUserShares200SharesItem | null) => void;
setShareToViewDetails: (share: ListUserShares200SharesItem | null) => void;
setShareToGenerateLink: (share: ListUserShares200SharesItem | null) => void;
handleDelete: (shareId: string) => Promise<void>;
handleEdit: (shareId: string, data: any) => Promise<void>;
handleManageFiles: (shareId: string, files: any[]) => Promise<void>;
handleManageRecipients: (shareId: string, recipients: any[]) => Promise<void>;
handleGenerateLink: (shareId: string, alias: string) => Promise<void>;
handleNotifyRecipients: (share: ListUserShares200SharesItem) => Promise<void>;
}
export interface SharesModalsProps {
isCreateModalOpen: boolean;
onCloseCreateModal: () => void;
shareToViewDetails: any;
shareToGenerateLink: any;
shareManager: any;
onSuccess: () => void;
onCloseViewDetails: () => void;
onCloseGenerateLink: () => void;
smtpEnabled?: string;
}

View File

@@ -2,6 +2,7 @@ import { Metadata } from "next";
import { getTranslations } from "next-intl/server"; import { getTranslations } from "next-intl/server";
import { getAllConfigs } from "@/http/endpoints"; import { getAllConfigs } from "@/http/endpoints";
import { Config } from "@/types/layout";
interface LayoutProps { interface LayoutProps {
children: React.ReactNode; children: React.ReactNode;
@@ -11,7 +12,7 @@ export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations(); const t = await getTranslations();
const response = await getAllConfigs(); const response = await getAllConfigs();
const appNameConfig = response.data.configs.find((config: any) => config.key === "appName"); const appNameConfig = response.data.configs.find((config: Config) => config.key === "appName");
const appName = appNameConfig?.value || "Palmr"; const appName = appNameConfig?.value || "Palmr";
return { return {

View File

@@ -1,3 +1,5 @@
"use client";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { IconArrowLeft, IconArrowRight, IconFile } from "@tabler/icons-react"; import { IconArrowLeft, IconArrowRight, IconFile } from "@tabler/icons-react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";

View File

@@ -1,6 +1,6 @@
"use client"; "use client";
import { useRouter } from "next/navigation"; import { usePathname, useRouter } from "next/navigation";
import { IconLanguage } from "@tabler/icons-react"; import { IconLanguage } from "@tabler/icons-react";
import { useLocale } from "next-intl"; import { useLocale } from "next-intl";
import { setCookie } from "nookies"; import { setCookie } from "nookies";
@@ -32,11 +32,18 @@ const languages = {
const COOKIE_LANG_KEY = "NEXT_LOCALE"; const COOKIE_LANG_KEY = "NEXT_LOCALE";
const COOKIE_MAX_AGE = 365 * 24 * 60 * 60; const COOKIE_MAX_AGE = 365 * 24 * 60 * 60;
const RTL_LANGUAGES = ["ar-SA"];
export function LanguageSwitcher() { export function LanguageSwitcher() {
const locale = useLocale(); const locale = useLocale();
const router = useRouter(); const router = useRouter();
const pathname = usePathname();
const changeLanguage = (fullLocale: string) => { const changeLanguage = (fullLocale: string) => {
// Update document direction based on language
const isRTL = RTL_LANGUAGES.includes(fullLocale);
document.documentElement.dir = isRTL ? "rtl" : "ltr";
setCookie(null, COOKIE_LANG_KEY, fullLocale, { setCookie(null, COOKIE_LANG_KEY, fullLocale, {
maxAge: COOKIE_MAX_AGE, maxAge: COOKIE_MAX_AGE,
path: "/", path: "/",

View File

@@ -40,7 +40,7 @@ export function Navbar() {
<div className="flex flex-1 items-center justify-between"> <div className="flex flex-1 items-center justify-between">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Link href="/dashboard" className="flex items-center gap-2 cursor-pointer"> <Link href="/dashboard" className="flex items-center gap-2 cursor-pointer">
{appLogo && <img alt={t("navbar.logoAlt")} className="h-8 w-8 object-contain" src={appLogo} />} {appLogo && <img alt={t("navbar.logoAlt")} className="h-8 w-8 object-contain rounded" src={appLogo} />}
<p className="font-bold text-2xl">{appName}</p> <p className="font-bold text-2xl">{appName}</p>
</Link> </Link>
</div> </div>

View File

@@ -1,3 +1,5 @@
"use client";
import { useState } from "react"; import { useState } from "react";
import { IconCalendar, IconEye, IconLock, IconShare } from "@tabler/icons-react"; import { IconCalendar, IconEye, IconLock, IconShare } from "@tabler/icons-react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";

View File

@@ -1,3 +1,5 @@
"use client";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { IconCopy } from "@tabler/icons-react"; import { IconCopy } from "@tabler/icons-react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";

View File

@@ -1,3 +1,5 @@
"use client";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { toast } from "sonner"; import { toast } from "sonner";

View File

@@ -1,3 +1,5 @@
"use client";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { IconLock, IconLockOpen, IconMail } from "@tabler/icons-react"; import { IconLock, IconLockOpen, IconMail } from "@tabler/icons-react";
import { format } from "date-fns"; import { format } from "date-fns";

View File

@@ -1,3 +1,5 @@
"use client";
import { useState } from "react"; import { useState } from "react";
export function useDisclosure(initial = false) { export function useDisclosure(initial = false) {

View File

@@ -1,3 +1,5 @@
"use client";
import { useState } from "react"; import { useState } from "react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { toast } from "sonner"; import { toast } from "sonner";

View File

@@ -0,0 +1,4 @@
export interface Config {
key: string;
value: string;
}