Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 29 additions & 1 deletion backend/app/DomainObjects/ImageDomainObject.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,34 @@

namespace HiEvents\DomainObjects;

class ImageDomainObject extends Generated\ImageDomainObjectAbstract
use HiEvents\DomainObjects\Interfaces\IsSortable;
use HiEvents\DomainObjects\SortingAndFiltering\AllowedSorts;

class ImageDomainObject extends Generated\ImageDomainObjectAbstract implements IsSortable
{
public static function getAllowedSorts(): AllowedSorts
{
return new AllowedSorts(
[
self::CREATED_AT => [
'asc' => __('Oldest First'),
'desc' => __('Newest First'),
],
self::FILENAME => [
'asc' => __('Filename A-Z'),
'desc' => __('Filename Z-A'),
],
],
);
}

public static function getDefaultSort(): string
{
return self::CREATED_AT;
}

public static function getDefaultSortDirection(): string
{
return 'desc';
}
}
37 changes: 37 additions & 0 deletions backend/app/Http/Actions/Images/GetAccountImagesAction.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
<?php

namespace HiEvents\Http\Actions\Images;

use HiEvents\DomainObjects\AccountDomainObject;
use HiEvents\DomainObjects\ImageDomainObject;
use HiEvents\Http\Actions\BaseAction;
use HiEvents\Http\DTO\QueryParamsDTO;
use HiEvents\Repository\Interfaces\ImageRepositoryInterface;
use HiEvents\Resources\Image\ImageResource;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;

class GetAccountImagesAction extends BaseAction
{
public function __construct(private readonly ImageRepositoryInterface $imageRepository)
{
}

public function __invoke(Request $request): JsonResponse
{
$accountId = $this->getAuthenticatedAccountId();

$this->isActionAuthorized($accountId, AccountDomainObject::class);

$images = $this->imageRepository->findByAccountId(
$accountId,
QueryParamsDTO::fromArray($request->query->all()),
);

return $this->filterableResourceResponse(
resource: ImageResource::class,
data: $images,
domainObject: ImageDomainObject::class,
);
}
}
28 changes: 28 additions & 0 deletions backend/app/Repository/Eloquent/ImageRepository.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,13 @@

namespace HiEvents\Repository\Eloquent;

use HiEvents\DomainObjects\Generated\ImageDomainObjectAbstract;
use HiEvents\DomainObjects\ImageDomainObject;
use HiEvents\Http\DTO\QueryParamsDTO;
use HiEvents\Models\Image;
use HiEvents\Repository\Interfaces\ImageRepositoryInterface;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Builder;

/**
* @extends BaseRepository<ImageDomainObject>
Expand All @@ -20,4 +24,28 @@ public function getDomainObject(): string
{
return ImageDomainObject::class;
}

public function findByAccountId(int $accountId, QueryParamsDTO $params): LengthAwarePaginator
{
$where = [
[ImageDomainObjectAbstract::ACCOUNT_ID, '=', $accountId],
];

if ($params->query) {
$where[] = static function (Builder $builder) use ($params) {
$builder->where(ImageDomainObjectAbstract::FILENAME, 'ilike', '%' . $params->query . '%');
};
}

$this->model = $this->model->orderBy(
column: $this->validateSortColumn($params->sort_by, ImageDomainObject::class),
direction: $this->validateSortDirection($params->sort_direction, ImageDomainObject::class),
);

return $this->paginateWhere(
where: $where,
limit: $params->per_page,
page: $params->page,
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,13 @@
namespace HiEvents\Repository\Interfaces;

use HiEvents\DomainObjects\ImageDomainObject;
use HiEvents\Http\DTO\QueryParamsDTO;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;

/**
* @extends RepositoryInterface<ImageDomainObject>
*/
interface ImageRepositoryInterface extends RepositoryInterface
{

public function findByAccountId(int $accountId, QueryParamsDTO $params): LengthAwarePaginator;
}
2 changes: 2 additions & 0 deletions backend/routes/api.php
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@
use HiEvents\Http\Actions\EventSettings\PartialEditEventSettingsAction;
use HiEvents\Http\Actions\Images\CreateImageAction;
use HiEvents\Http\Actions\Images\DeleteImageAction;
use HiEvents\Http\Actions\Images\GetAccountImagesAction;
use HiEvents\Http\Actions\Messages\CancelMessageAction;
use HiEvents\Http\Actions\Messages\GetMessageRecipientsAction;
use HiEvents\Http\Actions\Messages\GetMessagesAction;
Expand Down Expand Up @@ -442,6 +443,7 @@ function (Router $router): void {
$router->delete('/events/{event_id}/waitlist/{entry_id}', CancelWaitlistEntryAction::class);

// Images
$router->get('/images', GetAccountImagesAction::class);
$router->post('/images', CreateImageAction::class);
$router->delete('/images/{image_id}', DeleteImageAction::class);
}
Expand Down
9 changes: 8 additions & 1 deletion frontend/src/api/image.client.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,14 @@
import {GenericDataResponse, IdParam, Image, ImageType} from "../types.ts";
import {GenericDataResponse, GenericPaginatedResponse, IdParam, Image, ImageType, QueryFilters} from "../types.ts";
import {api} from "./client.ts";
import {queryParamsHelper} from "../utilites/queryParamsHelper.ts";

export const imageClient = {
getAll: async (queryFilters: QueryFilters) => {
const response = await api.get<GenericPaginatedResponse<Image>>(
'images' + queryParamsHelper.buildQueryString(queryFilters),
);
return response.data;
},
uploadImage: async (image: File, imageType?: ImageType, entityId?: IdParam) => {
const formData = new FormData();
formData.append('image', image);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
.imageGrid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 8px;
max-height: 360px;
overflow-y: auto;
padding: 4px;
}

.thumbnail {
aspect-ratio: 1;
cursor: pointer;
border-radius: 4px;
overflow: hidden;
border: 2px solid transparent;
position: relative;

&:hover {
border-color: var(--mantine-color-gray-4);
}

img {
width: 100%;
height: 100%;
object-fit: cover;
}
}

.thumbnailSelected {
border-color: var(--mantine-primary-color-filled);

&:hover {
border-color: var(--mantine-primary-color-filled);
}
}

.fileName {
font-size: 11px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
text-align: center;
padding: 2px 4px;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import {useState} from "react";
import {t} from "@lingui/macro";
import {Loader, Stack, Text, TextInput} from "@mantine/core";
import {IconSearch} from "@tabler/icons-react";
import {useDebouncedValue} from "@mantine/hooks";
import {useGetAccountImages} from "../../../../../../queries/useGetAccountImages.ts";
import {Pagination} from "../../../../Pagination";
import {Image as ImageType} from "../../../../../../types.ts";
import classes from './index.module.scss';

interface BrowseImagesPanelProps {
onImageSelected: (url: string) => void;
selectedUrl: string | null;
}

export const BrowseImagesPanel = ({onImageSelected, selectedUrl}: BrowseImagesPanelProps) => {
const [page, setPage] = useState(1);
const [searchQuery, setSearchQuery] = useState('');
const [debouncedQuery] = useDebouncedValue(searchQuery, 300);

const {data, isLoading} = useGetAccountImages({
pageNumber: page,
perPage: 24,
query: debouncedQuery || undefined,
sortBy: 'created_at',
sortDirection: 'desc',
});

const handleSearchChange = (value: string) => {
setSearchQuery(value);
setPage(1);
};

return (
<Stack>
<TextInput
placeholder={t`Search by filename...`}
leftSection={<IconSearch size={16}/>}
value={searchQuery}
onChange={(e) => handleSearchChange(e.currentTarget.value)}
/>

{isLoading ? (
<Stack align="center" py="xl">
<Loader size="lg"/>
</Stack>
) : data?.data && data.data.length > 0 ? (
<>
<div className={classes.imageGrid}>
{data.data.map((image: ImageType) => (
<div key={image.id}>
<div
className={`${classes.thumbnail} ${selectedUrl === image.url ? classes.thumbnailSelected : ''}`}
onClick={() => onImageSelected(image.url)}
>
<img
src={image.url}
alt={image.file_name}
loading="lazy"
style={image.lqip_base64 ? {
backgroundImage: `url(${image.lqip_base64})`,
backgroundSize: 'cover',
} : undefined}
/>
</div>
<div className={classes.fileName} title={image.file_name}>
{image.file_name}
</div>
</div>
))}
</div>
<Pagination
total={data.meta.last_page}
value={page}
onChange={setPage}
marginTop={0}
/>
</>
) : (
<Text c="dimmed" ta="center" py="xl" size="sm">
{debouncedQuery
? t`No images matching "${debouncedQuery}"`
: t`No images yet. Upload an image using the Upload tab.`}
</Text>
)}
</Stack>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,18 @@ import {t} from "@lingui/macro";
import {IconPhotoPlus} from "@tabler/icons-react";
import {Button, FileButton, Group, Image, Loader, Modal, Portal, Stack, Tabs, Text, TextInput} from "@mantine/core";
import {useUploadImage} from "../../../../../mutations/useUploadImage.ts";
import {useQueryClient} from "@tanstack/react-query";
import {GET_ACCOUNT_IMAGES_QUERY_KEY} from "../../../../../queries/useGetAccountImages.ts";
import {BrowseImagesPanel} from "./BrowseImagesPanel";

export const InsertImageControl = () => {
const editor = useRichTextEditorContext();
const queryClient = useQueryClient();
const [isModalOpen, setModalOpen] = useState(false);
const [tab, setTab] = useState<string>('url');
const [imageUrl, setImageUrl] = useState('');
const [uploadedImageUrl, setUploadedImageUrl] = useState('');
const [browseSelectedUrl, setBrowseSelectedUrl] = useState('');
const [urlError, setUrlError] = useState<string | null>(null);
const [uploadError, setUploadError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
Expand All @@ -30,7 +35,7 @@ export const InsertImageControl = () => {
const handleImageInsert = async () => {
setLoading(true);
try {
const finalUrl = uploadedImageUrl || imageUrl;
const finalUrl = uploadedImageUrl || imageUrl || browseSelectedUrl;
if (!finalUrl) {
setUrlError(t`Please provide an image.`);
return;
Expand Down Expand Up @@ -61,6 +66,7 @@ export const InsertImageControl = () => {
setUploadedImageUrl(data.url);
setUploadError(null);
setIsUploading(false);
queryClient.invalidateQueries({queryKey: [GET_ACCOUNT_IMAGES_QUERY_KEY]});
},
onError: (error: any) => {
const message = error?.response?.data?.message ?? t`Failed to upload image.`;
Expand All @@ -74,6 +80,7 @@ export const InsertImageControl = () => {
setTab('url');
setImageUrl('');
setUploadedImageUrl('');
setBrowseSelectedUrl('');
setUrlError(null);
setUploadError(null);
setIsUploading(false);
Expand All @@ -97,11 +104,13 @@ export const InsertImageControl = () => {
resetState();
}}
title={t`Insert Image`}
size="lg"
>
<Tabs value={tab} onChange={setTab} variant="outline">
<Tabs value={tab} onChange={(value) => value && setTab(value)} variant="outline">
<Tabs.List grow>
<Tabs.Tab value="url">{t`Paste URL`}</Tabs.Tab>
<Tabs.Tab value="upload">{t`Upload Image`}</Tabs.Tab>
<Tabs.Tab value="browse">{t`Browse Images`}</Tabs.Tab>
</Tabs.List>

<Tabs.Panel value="url" pt="md">
Expand Down Expand Up @@ -168,6 +177,20 @@ export const InsertImageControl = () => {
)}
</Stack>
</Tabs.Panel>

<Tabs.Panel value="browse" pt="md">
<Stack>
<BrowseImagesPanel
onImageSelected={setBrowseSelectedUrl}
selectedUrl={browseSelectedUrl}
/>
{browseSelectedUrl && (
<Button onClick={handleImageInsert} loading={loading}>
{t`Insert Image`}
</Button>
)}
</Stack>
</Tabs.Panel>
</Tabs>
</Modal>
</Portal>
Expand Down
13 changes: 13 additions & 0 deletions frontend/src/queries/useGetAccountImages.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import {useQuery, keepPreviousData} from "@tanstack/react-query";
import {QueryFilters} from "../types.ts";
import {imageClient} from "../api/image.client.ts";

export const GET_ACCOUNT_IMAGES_QUERY_KEY = 'getAccountImages';

export const useGetAccountImages = (queryFilters: QueryFilters) => {
return useQuery({
queryKey: [GET_ACCOUNT_IMAGES_QUERY_KEY, queryFilters],
queryFn: async () => await imageClient.getAll(queryFilters),
placeholderData: keepPreviousData,
});
};
Loading