From f5c708e78502380d825055f44f8f3326687a0856 Mon Sep 17 00:00:00 2001 From: mav <0xmav.eth@gmail.com> Date: Sun, 23 Feb 2025 21:05:02 +0800 Subject: [PATCH] move server comps --- .../dashboard/[slug]/credentials/page.tsx | 303 ++++++++++-------- .../credentials/credentials-actions.tsx | 2 + .../credentials/credentials-card.tsx | 4 +- .../credentials/credentials-dialog.tsx | 21 +- .../credentials/credentials-form.tsx | 4 +- .../credentials/credentials-sort-controls.tsx | 139 +++++--- src/components/global/page-pagination.tsx | 17 +- src/components/ui/multi-select.tsx | 1 + src/lib/actions.ts | 0 src/lib/auth.ts | 3 + 10 files changed, 278 insertions(+), 216 deletions(-) delete mode 100644 src/lib/actions.ts diff --git a/src/app/(app)/dashboard/[slug]/credentials/page.tsx b/src/app/(app)/dashboard/[slug]/credentials/page.tsx index 4c88f40..1b5aad6 100644 --- a/src/app/(app)/dashboard/[slug]/credentials/page.tsx +++ b/src/app/(app)/dashboard/[slug]/credentials/page.tsx @@ -1,185 +1,206 @@ -'use client' - -import { useState, use, useCallback } from 'react'; -import { useSession } from 'next-auth/react'; -import { useQuery } from 'convex/react'; +import { auth } from '@/lib/auth'; import { LoadingScreen } from '@/components/global/loading-screen'; import { api } from '@/convex/_generated/api'; +import { fetchQuery } from 'convex/nextjs'; +import { CredentialsList } from '@/components/credentials/credentials-list'; import { CredentialsSortControls } from '@/components/credentials/credentials-sort-controls'; +import { PageHeader } from '@/components/global/page-header'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Button } from '@/components/ui/button'; +import { InboxIcon, Share2Icon } from 'lucide-react'; import { CredentialsDialog } from '@/components/credentials/credentials-dialog'; import { PagePagination } from '@/components/global/page-pagination'; +import { EmptySearch } from '@/components/credentials/empty-search'; import { isCredentialsActive } from '@/lib/utils'; import { Credentials, CredentialsRequest, CredentialsType } from '@/convex/types'; -import { EmptySearch } from '@/components/credentials/empty-search'; -import { Button } from '@/components/ui/button'; -import { InboxIcon, Share2Icon } from 'lucide-react'; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" -import { CredentialsList } from '@/components/credentials/credentials-list'; -import { PageHeader } from '@/components/global/page-header'; -import { CredentialsListSkeleton, CredentialsSortControlsSkeleton } from '@/components/skeletons/credentials-skeleton'; -import { useCredentialsManagement } from '@/hooks/use-credentials-management'; -import type { - CredentialsSortOption, - CredentialsDialogType -} from '@/hooks/use-credentials-management'; - +import { CredentialsSortOption } from '@/hooks/use-credentials-management'; +import Link from 'next/link'; +import { redirect } from 'next/navigation'; -type TabType = 'shared' | 'requested'; - -interface CredentialsProps { +interface CredentialsPageProps { params: Promise<{ slug: string; }>; + searchParams?: Promise<{ + tab?: 'shared' | 'requested'; + page?: string; + search?: string; + sort?: CredentialsSortOption; + types?: CredentialsType | CredentialsType[]; + hideExpired?: 'true' | 'false'; + dialog?: 'new' | 'request'; + }>; } -export default function CredentialsPage(props: CredentialsProps) { - const params = use(props.params); - const session = useSession(); - - const [activeTab, setActiveTab] = useState('shared'); - - const { state, actions } = useCredentialsManagement(); - - const workspace = useQuery(api.workspaces.getWorkspaceBySlug, { slug: params.slug }); - const credentials = useQuery(api.credentials.getWorkspaceCredentials, workspace ? { workspaceId: workspace._id } : 'skip'); - const credentialsRequests = useQuery(api.credentialsRequests.getWorkspaceCredentialsRequests, workspace ? { workspaceId: workspace._id } : 'skip'); - - const isLoading = credentials === undefined || credentialsRequests === undefined; - - const filterItems = useCallback((items: T[], isCredentials: boolean): T[] => { - if (!items) return []; - return items.filter(item => - item.name.toLowerCase().includes(state.filters.searchTerm.toLowerCase()) && - (state.filters.selectedTypes.length === 0 || - state.filters.selectedTypes.some(type => - (isCredentials - ? (item as Credentials).type === type - : (item as CredentialsRequest).credentials[0]?.type === type) - )) && - (isCredentials ? (!state.filters.hideExpired || isCredentialsActive(item as Credentials)) : true) - ).sort((a, b) => { - if (state.filters.sortOption === 'createdAtAsc') return Number(a._creationTime) - Number(b._creationTime); - if (state.filters.sortOption === 'createdAtDesc') return Number(b._creationTime) - Number(a._creationTime); - if (state.filters.sortOption === 'updatedAt') return Number(b.updatedAt ?? 0) - Number(a.updatedAt ?? 0); - return a.name.localeCompare(b.name); - }); - }, [state.filters]); - - const resetFilters = () => { - actions.setFilters({ - searchTerm: '', - sortOption: 'name', - selectedTypes: [], - hideExpired: false - }); - actions.setCurrentPage(1); +const CREDENTIALS_PER_PAGE = 14; + +export default async function CredentialsPage({ params, searchParams }: CredentialsPageProps) { + + const { slug } = await params; + const searchParamsResult = await searchParams; + + const session = await auth(); + const workspace = await fetchQuery(api.workspaces.getWorkspaceBySlug, { slug }); + + if (!session?.user) return ; + if (!workspace) return redirect('/dashboard'); + + const [credentials, credentialsRequests] = await Promise.all([ + fetchQuery(api.credentials.getWorkspaceCredentials, { workspaceId: workspace._id }), + fetchQuery(api.credentialsRequests.getWorkspaceCredentialsRequests, { workspaceId: workspace._id }) + ]); + + const activeTab = searchParamsResult?.tab || 'shared'; + const currentPage = Math.max(1, Math.min(Number(searchParamsResult?.page || 1), Math.ceil((activeTab === 'shared' ? credentials.length : credentialsRequests.length) / CREDENTIALS_PER_PAGE))); + const searchTerm = searchParamsResult?.search?.toLowerCase() || ''; + const sortOption = searchParamsResult?.sort || 'name'; + const selectedTypes = Array.isArray(searchParamsResult?.types) + ? searchParamsResult.types + : searchParamsResult?.types?.split(',') as CredentialsType[] || []; + const hideExpired = searchParamsResult?.hideExpired === 'true'; + + const filterItems = (items: T[], isCredentials: boolean): T[] => { + return items + .filter(item => { + const nameMatch = item.name.toLowerCase().includes(searchTerm); + const typeMatch = selectedTypes.length === 0 || selectedTypes.some(type => + isCredentials ? (item as Credentials).type === type : (item as CredentialsRequest).credentials[0]?.type === type + ); + const expiryMatch = isCredentials ? (!hideExpired || isCredentialsActive(item as Credentials)) : true; + return nameMatch && typeMatch && expiryMatch; + }) + .sort((a, b) => { + if (sortOption === 'createdAtAsc') return Number(a._creationTime) - Number(b._creationTime); + if (sortOption === 'createdAtDesc') return Number(b._creationTime) - Number(a._creationTime); + if (sortOption === 'updatedAt') return Number(b.updatedAt ?? 0) - Number(a.updatedAt ?? 0); + return a.name.localeCompare(b.name); + }); }; - const filteredItems = activeTab === 'shared' ? filterItems(credentials || [], true) : filterItems(credentialsRequests || [], false); - - const isFiltered = state.filters.searchTerm || state.filters.selectedTypes.length > 0 || (activeTab === 'shared' && state.filters.hideExpired); - const itemsPerPage = 14; - const totalPages = Math.ceil(filteredItems.length / itemsPerPage); - const paginatedItems = filteredItems.slice((state.currentPage - 1) * itemsPerPage, state.currentPage * itemsPerPage); + const filteredItems = activeTab === 'shared' + ? filterItems(credentials, true) + : filterItems(credentialsRequests, false); - function renderContent(type: TabType) { - const items = type === 'shared' ? credentials : credentialsRequests; + const totalPages = Math.ceil(filteredItems.length / CREDENTIALS_PER_PAGE); + const paginatedItems = filteredItems.slice((currentPage - 1) * CREDENTIALS_PER_PAGE, currentPage * CREDENTIALS_PER_PAGE); - if (!items || items.length === 0) { - return ( -
-

- {type === 'shared' ? "Share your first credentials to see them here" : "Request your first credentials to see them here"} -

-
- ); - } - - return ( -
- actions.setFilters({ searchTerm })} - onSortChange={(sortOption) => actions.setFilters({ sortOption: sortOption as CredentialsSortOption })} - onTypeChange={(types) => actions.setFilters({ selectedTypes: types as CredentialsType[] })} - onHideExpiredChange={(hideExpired) => actions.setFilters({ hideExpired })} - showHideExpired={type === 'shared'} - /> - {paginatedItems.length === 0 && isFiltered ? ( - - ) : ( - - )} -
- ); + const buildQueryString = (params: Record) => { + const newParams = new URLSearchParams(); + Object.entries(params).forEach(([key, value]) => { + if (value === undefined || value === '') return; + const stringValue = Array.isArray(value) ? value.join(',') : String(value); + newParams.set(key, stringValue); + }); + return newParams.toString(); }; - if (!session || !session.data || !session.data.user) return ; - - if (isLoading) return ( -
- - -
- ); - return (
- actions.setDialogOpen('create', !state.isCreateDialogOpen)} - formType="new" - > - - - actions.setDialogOpen('request', !state.isRequestDialogOpen)} - formType="request" - > - - + -
1 && 'pb-10'} overflow-auto grow flex flex-col`}> - setActiveTab(value as TabType)} - > + +
1 ? 'pb-10' : ''} overflow-auto grow flex flex-col`}> + - Shared - Requested + + Shared + + + Requested + + - {renderContent('shared')} +
+ + {paginatedItems.length === 0 ? ( + searchTerm || selectedTypes.length > 0 || hideExpired ? ( + redirect(`?${buildQueryString({ tab: activeTab })}`)} /> + ) : ( +
+

+ Share your first credentials to see them here +

+
+ ) + ) : ( + + )} +
+ - {renderContent('requested')} +
+ + {paginatedItems.length === 0 ? ( + searchTerm || selectedTypes.length > 0 ? ( + redirect(`?${buildQueryString({ tab: activeTab })}`)} /> + ) : ( +
+

+ Request your first credentials to see them here +

+
+ ) + ) : ( + + )} +
+ {/* {totalPages > 1 && (
actions.setCurrentPage(page)} + hrefBuilder={(page) => `?${buildQueryString({ ...searchParamsResult, page })}`} />
- )} + )} */} + + {/* !open && redirect(`?${buildQueryString({ ...searchParamsResult, dialog: undefined })}`)} + /> */}
); } \ No newline at end of file diff --git a/src/components/credentials/credentials-actions.tsx b/src/components/credentials/credentials-actions.tsx index e419f70..d49be53 100644 --- a/src/components/credentials/credentials-actions.tsx +++ b/src/components/credentials/credentials-actions.tsx @@ -1,3 +1,5 @@ +"use client" + import { useState } from "react"; import { DotsHorizontalIcon } from "@radix-ui/react-icons"; import { Button } from "@/components/ui/button"; diff --git a/src/components/credentials/credentials-card.tsx b/src/components/credentials/credentials-card.tsx index 8fb09ff..4831c4c 100644 --- a/src/components/credentials/credentials-card.tsx +++ b/src/components/credentials/credentials-card.tsx @@ -1,3 +1,4 @@ +"use client" import { api } from "@/convex/_generated/api"; import { Credentials, CredentialsRequest } from "@/convex/types"; import { useQuery } from "convex/react"; @@ -7,6 +8,7 @@ import { formatTimestamp } from "@/lib/utils"; import { UserAvatar } from "@/components/global/user-avatar"; import { EyeIcon, KeyIcon, TimerIcon } from "lucide-react"; import { CredentialsActions } from "./credentials-actions"; +import { format } from 'date-fns'; interface CredentialsCardProps { item: Credentials | CredentialsRequest; @@ -89,7 +91,7 @@ export function CredentialsStatusInfo({ credentials }: CredentialsStatusInfoProp
- {credentials.expiresAt ? new Date(credentials.expiresAt).toLocaleDateString() : 'No expiration'} + {credentials.expiresAt ? format(new Date(credentials.expiresAt), 'yyyy-MM-dd') : 'No expiration'}
diff --git a/src/components/credentials/credentials-dialog.tsx b/src/components/credentials/credentials-dialog.tsx index 3fc1788..877cc8d 100644 --- a/src/components/credentials/credentials-dialog.tsx +++ b/src/components/credentials/credentials-dialog.tsx @@ -1,30 +1,30 @@ import { DialogContent, DialogTitle, DialogDescription, DialogHeader, DialogTrigger, Dialog } from "@/components/ui/dialog"; -import { Dispatch, ReactNode, SetStateAction } from "react"; +import { ReactNode } from "react"; import { Id } from "@/convex/_generated/dataModel"; import { Credentials } from "@/convex/types"; import { CredentialsForm } from "./credentials-form"; interface CredentialsDialogProps { - isOpen: boolean, - setIsOpen: (isOpen: boolean) => void; + isOpen: boolean; + onOpenChange: (open: boolean) => void; onCredentialsCreated?: () => void; onCredentialsUpdated?: () => void; onDialogClose?: () => void; - editId?: Id<"credentials">; - existingData?: Credentials; children?: ReactNode; formType: 'new' | 'request'; + editId?: Id<"credentials">; + existingData?: Credentials; } -export function CredentialsDialog({ children, isOpen, setIsOpen, onCredentialsCreated, onCredentialsUpdated, onDialogClose, editId, existingData, formType }: CredentialsDialogProps) { +export function CredentialsDialog({ children, isOpen, onOpenChange, onCredentialsCreated, onCredentialsUpdated, onDialogClose, editId, existingData, formType }: CredentialsDialogProps) { function handleDialogClose() { - setIsOpen(false); + onOpenChange(false); onDialogClose && onDialogClose() } return ( - + {children} @@ -38,11 +38,12 @@ export function CredentialsDialog({ children, isOpen, setIsOpen, onCredentialsCr {editId ? 'Update the details for this credential.' : formType === 'request' ? 'Fill in the details to create new credentials request.' : - 'Fill in the details to create new credentials.'} + 'Create new credentials'} + void; diff --git a/src/components/credentials/credentials-sort-controls.tsx b/src/components/credentials/credentials-sort-controls.tsx index c022879..35c9c05 100644 --- a/src/components/credentials/credentials-sort-controls.tsx +++ b/src/components/credentials/credentials-sort-controls.tsx @@ -2,24 +2,46 @@ import { Input } from '@/components/ui/input'; import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@/components/ui/select'; import { Checkbox } from '@/components/ui/checkbox'; import { Label } from '@/components/ui/label'; -import { MultiSelect } from '@/components/ui/multi-select'; import { CredentialsType } from '@/convex/types'; import { CREDENTIALS_TYPES } from '@/convex/schema'; import { credentialsFields } from '@/lib/config/credentials-fields'; +import { redirect } from 'next/navigation'; +import { Button } from '../ui/button'; +import { MultiSelect } from '../ui/multi-select'; interface CredentialsSortControlsProps { searchTerm: string; - onSearchChange: (value: string) => void; sortOption: string; - onSortChange: (value: string) => void; selectedTypes: CredentialsType[]; - onTypeChange: (types: string[]) => void; hideExpired: boolean; - onHideExpiredChange: (checked: boolean) => void; showHideExpired: boolean; + baseUrl: string; } -export function CredentialsSortControls({ searchTerm, onSearchChange, sortOption, onSortChange, selectedTypes, onTypeChange, hideExpired, onHideExpiredChange, showHideExpired }: CredentialsSortControlsProps) { +export async function CredentialsSortControls({ + searchTerm, + sortOption, + selectedTypes, + hideExpired, + showHideExpired, + baseUrl +}: CredentialsSortControlsProps) { + async function updateFilters(formData: FormData) { + 'use server'; + + const params = new URLSearchParams(); + const search = formData.get('search')?.toString() || ''; + const sort = formData.get('sort')?.toString() || 'name'; + const types = formData.getAll('types'); + const hideExpired = formData.get('hideExpired') === 'on'; + + if (search) params.set('search', search); + if (sort !== 'name') params.set('sort', sort); + if (types.length > 0) params.set('types', types.join(',')); + if (hideExpired) params.set('hideExpired', 'true'); + + redirect(`${baseUrl}?${params.toString()}`); + } const credentialTypeOptions = Object.values(CREDENTIALS_TYPES).map(type => ({ value: type, @@ -27,51 +49,64 @@ export function CredentialsSortControls({ searchTerm, onSearchChange, sortOption })); return ( - <> -
- onSearchChange(e.target.value)} - className='flex' - /> - - - {showHideExpired && ( -
- onHideExpiredChange(checked as boolean)} - className='rounded-[5px]' - /> - -
- )} -
- +
+ + + {/* TODO: FIX THIS */} + {/* { + const form = document.querySelector('form'); + const input = form?.querySelector('input[name="types"]') as HTMLInputElement; + if (input) { + input.value = values.join(','); + } + }} + defaultValue={selectedTypes} + placeholder="Select types" + maxCount={3} + /> + */} + + + + {showHideExpired && ( +
+ + +
+ )} + + + ); } \ No newline at end of file diff --git a/src/components/global/page-pagination.tsx b/src/components/global/page-pagination.tsx index 4379384..144bb60 100644 --- a/src/components/global/page-pagination.tsx +++ b/src/components/global/page-pagination.tsx @@ -3,26 +3,24 @@ import { Pagination, PaginationContent, PaginationEllipsis, PaginationItem, Pagi interface PaginationComponentProps { currentPage: number; totalPages: number; - setCurrentPage: (page: number) => void; + hrefBuilder: (page: number) => string; } -export function PagePagination({ currentPage, totalPages, setCurrentPage }: PaginationComponentProps) { +export function PagePagination({ currentPage, totalPages, hrefBuilder }: PaginationComponentProps) { return ( setCurrentPage(Math.max(currentPage - 1, 1))} + href={hrefBuilder(Math.max(currentPage - 1, 1))} /> {[...Array(totalPages)].map((_, index) => ( setCurrentPage(index + 1)} - className={currentPage === index + 1 ? "font-bold" : ""} + href={hrefBuilder(index + 1)} + isActive={currentPage === index + 1} > {index + 1} @@ -37,11 +35,10 @@ export function PagePagination({ currentPage, totalPages, setCurrentPage }: Pagi setCurrentPage(Math.min(currentPage + 1, totalPages))} + href={hrefBuilder(Math.min(currentPage + 1, totalPages))} /> ); -} +} \ No newline at end of file diff --git a/src/components/ui/multi-select.tsx b/src/components/ui/multi-select.tsx index b313c74..c24e259 100644 --- a/src/components/ui/multi-select.tsx +++ b/src/components/ui/multi-select.tsx @@ -1,3 +1,4 @@ +"use client" import * as React from "react"; import { cva, type VariantProps } from "class-variance-authority"; import { diff --git a/src/lib/actions.ts b/src/lib/actions.ts deleted file mode 100644 index e69de29..0000000 diff --git a/src/lib/auth.ts b/src/lib/auth.ts index c02fe1f..212048e 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -4,6 +4,7 @@ import Resend from 'next-auth/providers/resend'; import { ConvexAdapter } from '@/lib/convex-adapter'; import { getURL } from './utils'; + if (process.env.CONVEX_AUTH_PRIVATE_KEY === undefined) { throw new Error('Missing CONVEX_AUTH_PRIVATE_KEY Next.js environment variable'); } @@ -68,3 +69,5 @@ declare module 'next-auth' { convexToken: string; } } + +