-
Notifications
You must be signed in to change notification settings - Fork 0
picture export #174
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
picture export #174
Changes from 1 commit
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -6,6 +6,7 @@ import { | |||||||||
| UnauthorizedException, | ||||||||||
| } from '@nestjs/common'; | ||||||||||
| import { Prisma, ProfilePictureStatus, User } from '@prisma/client'; | ||||||||||
| import archiver from 'archiver'; | ||||||||||
| import { PrismaService } from 'nestjs-prisma'; | ||||||||||
| import { optimizeImage } from 'src/util'; | ||||||||||
|
|
||||||||||
|
|
@@ -242,6 +243,36 @@ export class UserService { | |||||||||
| } | ||||||||||
| } | ||||||||||
|
|
||||||||||
| async exportProfilePictures(userIds?: string[]): Promise<Buffer> { | ||||||||||
| const where: Prisma.ProfilePictureWhereInput = { status: ProfilePictureStatus.ACCEPTED }; | ||||||||||
| if (userIds && userIds.length > 0) { | ||||||||||
| where.userId = { in: userIds }; | ||||||||||
| } | ||||||||||
| const pictures = await this.prisma.profilePicture.findMany({ | ||||||||||
| where, | ||||||||||
| select: { | ||||||||||
| profileImage: true, | ||||||||||
| user: { select: { fullName: true, authSchId: true } }, | ||||||||||
| }, | ||||||||||
| }); | ||||||||||
|
|
||||||||||
| return new Promise((resolve, reject) => { | ||||||||||
| const archive = archiver('zip', { zlib: { level: 0 } }); | ||||||||||
| const chunks: Buffer[] = []; | ||||||||||
| archive.on('data', (chunk: Buffer) => chunks.push(chunk)); | ||||||||||
| archive.on('end', () => resolve(Buffer.concat(chunks))); | ||||||||||
| archive.on('error', (err: Error) => reject(err)); | ||||||||||
|
|
||||||||||
| for (const picture of pictures) { | ||||||||||
| const imageBuffer = Buffer.from(picture.profileImage.buffer); | ||||||||||
| const safeName = picture.user.fullName.replace(/[^\w\s\-áéíóöőúüűÁÉÍÓÖŐÚÜŰ]/g, '_'); | ||||||||||
|
||||||||||
| const safeName = picture.user.fullName.replace(/[^\w\s\-áéíóöőúüűÁÉÍÓÖŐÚÜŰ]/g, '_'); | |
| const safeName = | |
| picture.user.fullName.replace(/[^\w\s\-áéíóöőúüűÁÉÍÓÖŐÚÜŰ]/g, '_').trim() || | |
| picture.user.authSchId; |
Copilot
AI
Feb 23, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Multiple users with the same fullName will have filename collisions in the exported ZIP file. When two users have identical names, only one file will be preserved in the archive. Consider including the authSchId in the filename to ensure uniqueness, for example: ${safeName}_${picture.user.authSchId}.jpg
| archive.append(imageBuffer, { name: `${safeName}.jpg` }); | |
| archive.append(imageBuffer, { name: `${safeName}_${picture.user.authSchId}.jpg` }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,100 @@ | ||
| 'use client'; | ||
| import { useRouter } from 'next/navigation'; | ||
| import React from 'react'; | ||
| import useSWR from 'swr'; | ||
|
|
||
| import Th1 from '@/components/typography/typography'; | ||
| import { Button } from '@/components/ui/button'; | ||
| import { Card } from '@/components/ui/card'; | ||
| import { Checkbox } from '@/components/ui/checkbox'; | ||
| import LoadingCard from '@/components/ui/LoadingCard'; | ||
| import { axiosGetFetcher } from '@/lib/fetchers'; | ||
| import { exportProfilePictures } from '@/lib/profile-pictures'; | ||
| import { ProfilePictureStatus, UserEntityPagination } from '@/types/user-entity'; | ||
| import { LuDownload } from 'react-icons/lu'; | ||
|
|
||
| export default function Page() { | ||
| const router = useRouter(); | ||
| const { data, isLoading } = useSWR<UserEntityPagination>('users?page=-1&pageSize=-1', axiosGetFetcher); | ||
| const [selectedIds, setSelectedIds] = React.useState<Set<string>>(new Set()); | ||
|
|
||
| const usersWithPictures = React.useMemo( | ||
| () => data?.users.filter((u) => u.profilePicture?.status === ProfilePictureStatus.ACCEPTED) ?? [], | ||
| [data] | ||
| ); | ||
|
|
||
| const allSelected = usersWithPictures.length > 0 && selectedIds.size === usersWithPictures.length; | ||
|
|
||
| const toggleSelectAll = () => { | ||
| if (allSelected) { | ||
| setSelectedIds(new Set()); | ||
| } else { | ||
| setSelectedIds(new Set(usersWithPictures.map((u) => u.authSchId))); | ||
| } | ||
| }; | ||
|
|
||
| const toggleUser = (authSchId: string) => { | ||
| setSelectedIds((prev) => { | ||
| const next = new Set(prev); | ||
| if (next.has(authSchId)) { | ||
| next.delete(authSchId); | ||
| } else { | ||
| next.add(authSchId); | ||
| } | ||
| return next; | ||
| }); | ||
| }; | ||
|
|
||
| const handleExport = () => exportProfilePictures(selectedIds.size > 0 ? [...selectedIds] : undefined); | ||
|
|
||
| const exportLabel = selectedIds.size === 0 ? 'mind' : `${selectedIds.size} db`; | ||
|
|
||
| return ( | ||
| <> | ||
| <div className='flex justify-between items-center flex-wrap gap-4'> | ||
| <Th1>Profilképek exportálása</Th1> | ||
| <div className='flex gap-2'> | ||
| <Button variant='outline' onClick={toggleSelectAll} disabled={usersWithPictures.length === 0}> | ||
| {allSelected ? 'Kijelölés törlése' : 'Összes kijelölése'} | ||
| </Button> | ||
| <Button onClick={handleExport}> | ||
| <LuDownload /> | ||
| Exportálás ({exportLabel}) | ||
| </Button> | ||
| </div> | ||
| </div> | ||
|
|
||
| {isLoading && <LoadingCard />} | ||
|
|
||
| {!isLoading && usersWithPictures.length === 0 && ( | ||
| <p className='text-muted-foreground'>Nincs jóváhagyott profilkép.</p> | ||
| )} | ||
|
|
||
| <div className='grid max-lg:grid-cols-1 lg:grid-cols-2 gap-2'> | ||
| {usersWithPictures.map((user) => ( | ||
| <Card | ||
| key={user.authSchId} | ||
| className='flex items-center gap-4 p-4 cursor-pointer select-none' | ||
| onClick={() => toggleUser(user.authSchId)} | ||
| > | ||
| <Checkbox checked={selectedIds.has(user.authSchId)} onCheckedChange={() => toggleUser(user.authSchId)} /> | ||
| <img | ||
| src={`${process.env.NEXT_PUBLIC_API_URL}/users/${user.authSchId}/profile-picture`} | ||
| alt={user.fullName} | ||
| loading='lazy' | ||
| className='w-12 h-16 object-cover rounded' | ||
| /> | ||
| <div> | ||
| <p className='font-medium'>{user.fullName}</p> | ||
| <p className='text-sm text-muted-foreground'>{user.nickName}</p> | ||
| </div> | ||
| </Card> | ||
| ))} | ||
| </div> | ||
|
|
||
| <Button variant='secondary' onClick={() => router.push('/admin')}> | ||
| Vissza | ||
| </Button> | ||
| </> | ||
| ); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,24 +1,29 @@ | ||
| 'use client'; | ||
| import { useRouter } from 'next/navigation'; | ||
| import React from 'react'; | ||
|
|
||
| import api from '@/components/network/apiSetup'; | ||
| import Th1 from '@/components/typography/typography'; | ||
| import { Badge } from '@/components/ui/badge'; | ||
| import { Button } from '@/components/ui/button'; | ||
| import { Input } from '@/components/ui/input'; | ||
| import LoadingCard from '@/components/ui/LoadingCard'; | ||
| import NotFoundCard from '@/components/ui/NotFoundCard'; | ||
| import OwnPagination from '@/components/ui/ownPagination'; | ||
| import UserCard from '@/components/ui/UserCard'; | ||
| import { useUsers } from '@/hooks/useUsers'; | ||
| import { exportProfilePictures } from '@/lib/profile-pictures'; | ||
| import { toast } from '@/lib/use-toast'; | ||
| import { Role } from '@/types/user-entity'; | ||
| import { LuDownload } from 'react-icons/lu'; | ||
|
|
||
| export default function Page() { | ||
| const router = useRouter(); | ||
|
||
| const [search, setSearch] = React.useState(''); | ||
| const [pageIndex, setPageIndex] = React.useState(0); | ||
| const users = useUsers(search, pageIndex); | ||
|
|
||
| async function onChange(newRole: Role, userId: string) { | ||
| async function onRoleChange(newRole: Role, userId: string) { | ||
| try { | ||
| await api.patch(`/users/${userId}`, { role: newRole }); | ||
| await users.mutate(); | ||
|
|
@@ -32,11 +37,19 @@ export default function Page() { | |
| } | ||
| } | ||
|
|
||
| async function mutateUsers() { | ||
| await users.mutate(); | ||
| } | ||
|
|
||
| return ( | ||
| <> | ||
| <div className='flex justify-between md:flex-row max-md:flex-col items-center'> | ||
| <Th1>Jogosultságok kezelése</Th1> | ||
| <div className='flex gap-2'> | ||
| <Button onClick={() => exportProfilePictures()}> | ||
| <LuDownload /> | ||
| Minden profilkép exportálása | ||
| </Button> | ||
| <Input | ||
| placeholder='Keresés név alapján...' | ||
| value={search} | ||
|
|
@@ -68,7 +81,8 @@ export default function Page() { | |
| <UserCard | ||
| key={user.authSchId} | ||
| user={user} | ||
| onChange={(newRole: Role) => onChange(newRole, user.authSchId)} | ||
| onChange={(newRole: Role) => onRoleChange(newRole, user.authSchId)} | ||
| mutateUsers={mutateUsers} | ||
| /> | ||
| ))} | ||
| </div> | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||
|---|---|---|---|---|
| @@ -1,34 +1,41 @@ | ||||
| import { Card, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; | ||||
| import { RoleBadgeSelector } from '@/components/ui/RoleBadgeSelector'; | ||||
| import { Role, UserEntity } from '@/types/user-entity'; | ||||
| import { ProfilePictureStatus, Role, UserEntity } from '@/types/user-entity'; | ||||
| import Image from 'next/image'; | ||||
| import { LuPencil, LuUser, LuUserCheck, LuUserMinus, LuUserSearch } from 'react-icons/lu'; | ||||
| import api from '@/components/network/apiSetup'; | ||||
| import { toast } from '@/lib/use-toast'; | ||||
| import { Button } from '@/components/ui/button'; | ||||
|
|
||||
| /*admin component*/ | ||||
| export default function UserCard(props: { user: UserEntity; onChange: (newRole: Role) => Promise<void> }) { | ||||
| export default function UserCard(props: { | ||||
| user: UserEntity; | ||||
| onChange: (newRole: Role) => Promise<void>; | ||||
| mutateUsers: () => Promise<void>; | ||||
| }) { | ||||
| async function sendStatusChange(string: string) { | ||||
| const resp = await api.patch('/users/' + props.user.authSchId + '/profile-picture/' + string); | ||||
| toast({ | ||||
| title: 'Profilkép státusz módosítva!', | ||||
| description: resp.statusText, | ||||
| }); | ||||
| await props.mutateUsers(); | ||||
| } | ||||
|
|
||||
| // @ts-ignore | ||||
|
||||
| // @ts-ignore |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The archiver is configured with compression level 0 (
zlib: { level: 0 }), which means no compression. This will result in larger ZIP files. While this may be intentional for faster processing, consider using a moderate compression level (e.g., level 6) to balance file size and performance, especially if many profile pictures are being exported.