diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 643568f..43f1d5b 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -19,7 +19,8 @@ "Bash(Get-ChildItem -Path \"c:\\\\Users\\\\bruno\\\\Documents\\\\GitHub\\\\vontor-cz\\\\backend\\\\\" -Directory | Select-Object -ExpandProperty Name)", "PowerShell(Get-Command python)", "PowerShell(python -c \"import django; print\\(django.__version__\\)\")", - "WebSearch" + "WebSearch", + "Bash(npm run *)" ] } } diff --git a/backend/notifications/migrations/0001_initial.py b/backend/notifications/migrations/0001_initial.py new file mode 100644 index 0000000..19307bc --- /dev/null +++ b/backend/notifications/migrations/0001_initial.py @@ -0,0 +1,40 @@ +# Generated by Django 5.2.7 on 2026-06-10 17:13 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Notification', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('is_deleted', models.BooleanField(default=False)), + ('deleted_at', models.DateTimeField(blank=True, null=True)), + ('title', models.CharField(help_text='Předmět oznámení.', max_length=200)), + ('text', models.TextField(help_text='Obsah oznámení.')), + ('notification_type', models.CharField(choices=[('system', 'Systém'), ('order', 'Objednávka'), ('payment', 'Platba'), ('social', 'Sociální'), ('chat', 'Chat'), ('advertisement', 'Inzerát')], default='system', help_text='Kategorie oznámení — používá se pro ikonky a filtrování na frontendu.', max_length=20)), + ('action_url', models.CharField(blank=True, help_text="Volitelný odkaz na detail (např. '/objednavky/123/').", max_length=500, null=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('bulk', models.BooleanField(default=False, help_text='True, pokud bylo oznámení vytvořeno hromadně pro více uživatelů.')), + ('is_read', models.BooleanField(default=False)), + ('read_at', models.DateTimeField(blank=True, null=True)), + ('send_email', models.BooleanField(default=False, help_text='True, pokud byl zároveň odeslán e-mail.')), + ('email_subject', models.CharField(blank=True, max_length=255, null=True)), + ('email_template_path', models.CharField(blank=True, max_length=255, null=True)), + ('user', models.ForeignKey(help_text='Příjemce oznámení.', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='notifications', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ['-created_at'], + }, + ), + ] diff --git a/backend/requirements.txt b/backend/requirements.txt index 0e53980..f990c14 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -24,6 +24,7 @@ django-constance #allows you to store and manage settings of page in the Django # -- OBJECT STORAGE -- Pillow #adds image processing capabilities to your Python interpreter +pillow-heif #HEIC/HEIF support for Pillow (iPhone photos) whitenoise #pomáha se spuštěním serveru a načítaní static files diff --git a/backend/social/chat/migrations/0003_alter_messagereaction_unique_together_and_more.py b/backend/social/chat/migrations/0003_alter_messagereaction_unique_together_and_more.py new file mode 100644 index 0000000..2dfe4a9 --- /dev/null +++ b/backend/social/chat/migrations/0003_alter_messagereaction_unique_together_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 5.2.7 on 2026-06-10 17:13 + +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('chat', '0002_chatreadstatus'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AlterUniqueTogether( + name='messagereaction', + unique_together=set(), + ), + migrations.AddConstraint( + model_name='messagereaction', + constraint=models.UniqueConstraint(condition=models.Q(('is_deleted', False)), fields=('message', 'user'), name='unique_active_reaction_per_user_message'), + ), + ] diff --git a/backend/vontor_cz/settings.py b/backend/vontor_cz/settings.py index 27ad317..368cc74 100644 --- a/backend/vontor_cz/settings.py +++ b/backend/vontor_cz/settings.py @@ -634,7 +634,7 @@ STATIC_ROOT = BASE_DIR / 'collectedstaticfiles' if not USE_S3: # Local filesystem — development only STORAGES = { - "default": {"BACKEND": "django.core.files.storage.FileSystemStorage"}, + "default": {"BACKEND": "vontor_cz.storage.LocalMediaStorage"}, "staticfiles": {"BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage"}, } MEDIA_URL = os.getenv("MEDIA_URL", "/media/") @@ -650,8 +650,8 @@ else: S3_PROTO = 'https' if S3_SSL else 'http' STORAGES = { - "default": {"BACKEND": "storages.backends.s3boto3.S3Boto3Storage"}, - "staticfiles": {"BACKEND": "storages.backends.s3boto3.S3StaticStorage"}, + "default": {"BACKEND": "vontor_cz.storage.MediaStorage"}, + "staticfiles": {"BACKEND": "vontor_cz.storage.StaticStorage"}, } AWS_ACCESS_KEY_ID = os.getenv('AWS_ACCESS_KEY_ID') diff --git a/backend/vontor_cz/storage.py b/backend/vontor_cz/storage.py new file mode 100644 index 0000000..17bf0d0 --- /dev/null +++ b/backend/vontor_cz/storage.py @@ -0,0 +1,82 @@ +import io +import os + +from django.core.files.base import ContentFile +from django.core.files.storage import FileSystemStorage +import pillow_heif +from PIL import Image, ImageSequence + +pillow_heif.register_heif_opener() +from storages.backends.s3boto3 import S3Boto3Storage, S3StaticStorage + +# All formats Pillow handles natively (no extra plugins needed). +# Add '.heic' / '.heif' if you install pillow-heif. +_IMAGE_EXTS = { + '.jpg', '.jpeg', '.jpe', '.jfif', # JPEG variants + '.png', # PNG + '.gif', # GIF (animated preserved) + '.bmp', '.dib', # BMP + '.tiff', '.tif', # TIFF + '.tga', # Truevision TGA + '.ico', # ICO (largest frame used) + '.ppm', '.pgm', '.pbm', '.pnm', # Portable pixmap family + '.pcx', # PCX + '.heic', '.heif', # Apple HEIC/HEIF (pillow-heif) +} + +def _to_webp(content, quality: int = 85) -> io.BytesIO: + img = Image.open(content) + frames = list(ImageSequence.Iterator(img)) + out = io.BytesIO() + if len(frames) > 1: + converted = [f.copy().convert('RGBA') for f in frames] + converted[0].save( + out, + format='WEBP', + save_all=True, + append_images=converted[1:], + loop=img.info.get('loop', 0), + quality=quality, + ) + else: + img.convert('RGBA').save(out, format='WEBP', quality=quality) + out.seek(0) + return out + + +class WebPConversionMixin: + """ + Intercepts image/GIF uploads and converts them to WebP before storage. + Videos are saved as-is; use `vontor_cz.tasks.convert_video_to_webm` async + to transcode them to WebM after save. + """ + WEBP_QUALITY: int = 85 + + def _save(self, name: str, content) -> str: + root, ext = os.path.splitext(name) + if ext.lower() in _IMAGE_EXTS: + try: + webp_data = _to_webp(content, self.WEBP_QUALITY) + content = ContentFile(webp_data.read()) + name = root + '.webp' + except Exception: + content.seek(0) + return super()._save(name, content) + + +class MediaStorage(WebPConversionMixin, S3Boto3Storage): + def exists(self, name): + if not name: + return False + return super().exists(name) + + +class LocalMediaStorage(WebPConversionMixin, FileSystemStorage): + pass + + +class StaticStorage(S3StaticStorage): + def exists(self, name): + if not name: + return False + return super().exists(name) diff --git a/docker-compose.yml b/docker-compose.yml index a04d2c1..ec6edeb 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -12,14 +12,13 @@ services: depends_on: - db - redis - - rustfs volumes: - ./backend:/app - static-data:/app/collectedstaticfiles - media-data:/app/media command: sh -c " python manage.py migrate --noinput && - python manage.py collectstatic --clear --noinput && + python manage.py collectstatic --noinput && python manage.py seed_app_config && gunicorn -k uvicorn.workers.UvicornWorker vontor_cz.asgi:application --bind 0.0.0.0:8000 --timeout 600 --graceful-timeout 30 --workers 2" ports: @@ -67,7 +66,6 @@ services: - redis - db - backend - - rustfs networks: - app_network @@ -86,7 +84,6 @@ services: - redis - db - backend - - rustfs networks: - app_network @@ -159,10 +156,4 @@ volumes: type: none o: bind device: ./volumes/media - rustfs-data: - driver: local - driver_opts: - type: none - o: bind - device: ./volumes/rustfs diff --git a/frontend/src/api/generated/private/models/apiNotificationsListParams.ts b/frontend/src/api/generated/private/models/apiNotificationsListParams.ts new file mode 100644 index 0000000..3da741a --- /dev/null +++ b/frontend/src/api/generated/private/models/apiNotificationsListParams.ts @@ -0,0 +1,20 @@ +/** + * Generated by orval v8.8.0 🍺 + * Do not edit manually. + * OpenAPI spec version: 0.0.0 + */ + +export type ApiNotificationsListParams = { + /** + * Which field to use when ordering the results. + */ + ordering?: string; + /** + * A page number within the paginated result set. + */ + page?: number; + /** + * A search term. + */ + search?: string; +}; diff --git a/frontend/src/api/generated/private/models/apiNotificationsReadAllCreate200.ts b/frontend/src/api/generated/private/models/apiNotificationsReadAllCreate200.ts new file mode 100644 index 0000000..67687fb --- /dev/null +++ b/frontend/src/api/generated/private/models/apiNotificationsReadAllCreate200.ts @@ -0,0 +1,9 @@ +/** + * Generated by orval v8.8.0 🍺 + * Do not edit manually. + * OpenAPI spec version: 0.0.0 + */ + +export type ApiNotificationsReadAllCreate200 = { + marked?: number; +}; diff --git a/frontend/src/api/generated/private/models/apiNotificationsUnreadCountRetrieve200.ts b/frontend/src/api/generated/private/models/apiNotificationsUnreadCountRetrieve200.ts new file mode 100644 index 0000000..b1e4e04 --- /dev/null +++ b/frontend/src/api/generated/private/models/apiNotificationsUnreadCountRetrieve200.ts @@ -0,0 +1,9 @@ +/** + * Generated by orval v8.8.0 🍺 + * Do not edit manually. + * OpenAPI spec version: 0.0.0 + */ + +export type ApiNotificationsUnreadCountRetrieve200 = { + unread_count?: number; +}; diff --git a/frontend/src/api/generated/private/models/index.ts b/frontend/src/api/generated/private/models/index.ts index 027d857..2d1c512 100644 --- a/frontend/src/api/generated/private/models/index.ts +++ b/frontend/src/api/generated/private/models/index.ts @@ -14,6 +14,9 @@ export * from "./apiConfigurationShopConfigurationListParams"; export * from "./apiConfigurationVatRatesListParams"; export * from "./apiDeutschepostBulkOrdersListParams"; export * from "./apiDeutschepostOrdersListParams"; +export * from "./apiNotificationsListParams"; +export * from "./apiNotificationsReadAllCreate200"; +export * from "./apiNotificationsUnreadCountRetrieve200"; export * from "./apiSchemaRetrieve200Four"; export * from "./apiSchemaRetrieve200One"; export * from "./apiSchemaRetrieve200Three"; @@ -68,6 +71,8 @@ export * from "./messageFile"; export * from "./messageReaction"; export * from "./messageSend"; export * from "./messageSender"; +export * from "./notification"; +export * from "./notificationTypeEnum"; export * from "./orderCarrier"; export * from "./orderCreate"; export * from "./orderItemCreate"; @@ -84,6 +89,7 @@ export * from "./paginatedHubList"; export * from "./paginatedHubPermissionList"; export * from "./paginatedChatList"; export * from "./paginatedMessageList"; +export * from "./paginatedNotificationList"; export * from "./paginatedOrderReadList"; export * from "./paginatedPostList"; export * from "./paginatedProductImageList"; diff --git a/frontend/src/api/generated/private/models/notification.ts b/frontend/src/api/generated/private/models/notification.ts new file mode 100644 index 0000000..1b97971 --- /dev/null +++ b/frontend/src/api/generated/private/models/notification.ts @@ -0,0 +1,36 @@ +/** + * Generated by orval v8.8.0 🍺 + * Do not edit manually. + * OpenAPI spec version: 0.0.0 + */ +import type { NotificationTypeEnum } from "./notificationTypeEnum"; + +export interface Notification { + readonly id: number; + /** Předmět oznámení. */ + readonly title: string; + /** Obsah oznámení. */ + readonly text: string; + /** Kategorie oznámení — používá se pro ikonky a filtrování na frontendu. + + * `system` - Systém + * `order` - Objednávka + * `payment` - Platba + * `social` - Sociální + * `chat` - Chat + * `advertisement` - Inzerát */ + readonly notification_type: NotificationTypeEnum; + /** + * Volitelný odkaz na detail (např. '/objednavky/123/'). + * @nullable + */ + readonly action_url: string | null; + readonly is_read: boolean; + /** @nullable */ + readonly read_at: Date | null; + readonly created_at: Date; + /** True, pokud bylo oznámení vytvořeno hromadně pro více uživatelů. */ + readonly bulk: boolean; + /** True, pokud byl zároveň odeslán e-mail. */ + readonly send_email: boolean; +} diff --git a/frontend/src/api/generated/private/models/notificationTypeEnum.ts b/frontend/src/api/generated/private/models/notificationTypeEnum.ts new file mode 100644 index 0000000..ea760eb --- /dev/null +++ b/frontend/src/api/generated/private/models/notificationTypeEnum.ts @@ -0,0 +1,25 @@ +/** + * Generated by orval v8.8.0 🍺 + * Do not edit manually. + * OpenAPI spec version: 0.0.0 + */ + +/** + * * `system` - Systém + * `order` - Objednávka + * `payment` - Platba + * `social` - Sociální + * `chat` - Chat + * `advertisement` - Inzerát + */ +export type NotificationTypeEnum = + (typeof NotificationTypeEnum)[keyof typeof NotificationTypeEnum]; + +export const NotificationTypeEnum = { + system: "system", + order: "order", + payment: "payment", + social: "social", + chat: "chat", + advertisement: "advertisement", +} as const; diff --git a/frontend/src/api/generated/private/models/paginatedNotificationList.ts b/frontend/src/api/generated/private/models/paginatedNotificationList.ts new file mode 100644 index 0000000..2ce3168 --- /dev/null +++ b/frontend/src/api/generated/private/models/paginatedNotificationList.ts @@ -0,0 +1,15 @@ +/** + * Generated by orval v8.8.0 🍺 + * Do not edit manually. + * OpenAPI spec version: 0.0.0 + */ +import type { Notification } from "./notification"; + +export interface PaginatedNotificationList { + count: number; + /** @nullable */ + next?: string | null; + /** @nullable */ + previous?: string | null; + results: Notification[]; +} diff --git a/frontend/src/api/generated/private/notifications/notifications.ts b/frontend/src/api/generated/private/notifications/notifications.ts new file mode 100644 index 0000000..3b85c4b --- /dev/null +++ b/frontend/src/api/generated/private/notifications/notifications.ts @@ -0,0 +1,684 @@ +/** + * Generated by orval v8.8.0 🍺 + * Do not edit manually. + * OpenAPI spec version: 0.0.0 + */ +import { useMutation, useQuery } from "@tanstack/react-query"; +import type { + DataTag, + DefinedInitialDataOptions, + DefinedUseQueryResult, + MutationFunction, + QueryClient, + QueryFunction, + QueryKey, + UndefinedInitialDataOptions, + UseMutationOptions, + UseMutationResult, + UseQueryOptions, + UseQueryResult, +} from "@tanstack/react-query"; + +import type { + ApiNotificationsListParams, + ApiNotificationsReadAllCreate200, + ApiNotificationsUnreadCountRetrieve200, + Notification, + PaginatedNotificationList, +} from "../models"; + +import { privateMutator } from "../../../privateClient"; + +// https://stackoverflow.com/questions/49579094/typescript-conditional-types-filter-out-readonly-properties-pick-only-requir/49579497#49579497 +type IfEquals = + (() => T extends X ? 1 : 2) extends () => T extends Y ? 1 : 2 ? A : B; + +type WritableKeys = { + [P in keyof T]-?: IfEquals< + { [Q in P]: T[P] }, + { -readonly [Q in P]: T[P] }, + P + >; +}[keyof T]; + +type UnionToIntersection = (U extends any ? (k: U) => void : never) extends ( + k: infer I, +) => void + ? I + : never; +type DistributeReadOnlyOverUnions = T extends any ? NonReadonly : never; + +type Writable = Pick>; +type NonReadonly = [T] extends [UnionToIntersection] + ? { + [P in keyof Writable]: T[P] extends object + ? NonReadonly> + : T[P]; + } + : DistributeReadOnlyOverUnions; + +export const apiNotificationsList = ( + params?: ApiNotificationsListParams, + signal?: AbortSignal, +) => { + return privateMutator({ + url: `/api/notifications/`, + method: "GET", + params, + signal, + }); +}; + +export const getApiNotificationsListQueryKey = ( + params?: ApiNotificationsListParams, +) => { + return [`/api/notifications/`, ...(params ? [params] : [])] as const; +}; + +export const getApiNotificationsListQueryOptions = < + TData = Awaited>, + TError = unknown, +>( + params?: ApiNotificationsListParams, + options?: { + query?: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + >; + }, +) => { + const { query: queryOptions } = options ?? {}; + + const queryKey = + queryOptions?.queryKey ?? getApiNotificationsListQueryKey(params); + + const queryFn: QueryFunction< + Awaited> + > = ({ signal }) => apiNotificationsList(params, signal); + + return { queryKey, queryFn, ...queryOptions } as UseQueryOptions< + Awaited>, + TError, + TData + > & { queryKey: DataTag }; +}; + +export type ApiNotificationsListQueryResult = NonNullable< + Awaited> +>; +export type ApiNotificationsListQueryError = unknown; + +export function useApiNotificationsList< + TData = Awaited>, + TError = unknown, +>( + params: undefined | ApiNotificationsListParams, + options: { + query: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + > & + Pick< + DefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + >, + "initialData" + >; + }, + queryClient?: QueryClient, +): DefinedUseQueryResult & { + queryKey: DataTag; +}; +export function useApiNotificationsList< + TData = Awaited>, + TError = unknown, +>( + params?: ApiNotificationsListParams, + options?: { + query?: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + > & + Pick< + UndefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + >, + "initialData" + >; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +}; +export function useApiNotificationsList< + TData = Awaited>, + TError = unknown, +>( + params?: ApiNotificationsListParams, + options?: { + query?: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + >; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +}; + +export function useApiNotificationsList< + TData = Awaited>, + TError = unknown, +>( + params?: ApiNotificationsListParams, + options?: { + query?: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + >; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +} { + const queryOptions = getApiNotificationsListQueryOptions(params, options); + + const query = useQuery(queryOptions, queryClient) as UseQueryResult< + TData, + TError + > & { queryKey: DataTag }; + + return { ...query, queryKey: queryOptions.queryKey }; +} + +export const apiNotificationsRetrieve = (id: string, signal?: AbortSignal) => { + return privateMutator({ + url: `/api/notifications/${id}/`, + method: "GET", + signal, + }); +}; + +export const getApiNotificationsRetrieveQueryKey = (id: string) => { + return [`/api/notifications/${id}/`] as const; +}; + +export const getApiNotificationsRetrieveQueryOptions = < + TData = Awaited>, + TError = unknown, +>( + id: string, + options?: { + query?: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + >; + }, +) => { + const { query: queryOptions } = options ?? {}; + + const queryKey = + queryOptions?.queryKey ?? getApiNotificationsRetrieveQueryKey(id); + + const queryFn: QueryFunction< + Awaited> + > = ({ signal }) => apiNotificationsRetrieve(id, signal); + + return { + queryKey, + queryFn, + enabled: !!id, + ...queryOptions, + } as UseQueryOptions< + Awaited>, + TError, + TData + > & { queryKey: DataTag }; +}; + +export type ApiNotificationsRetrieveQueryResult = NonNullable< + Awaited> +>; +export type ApiNotificationsRetrieveQueryError = unknown; + +export function useApiNotificationsRetrieve< + TData = Awaited>, + TError = unknown, +>( + id: string, + options: { + query: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + > & + Pick< + DefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + >, + "initialData" + >; + }, + queryClient?: QueryClient, +): DefinedUseQueryResult & { + queryKey: DataTag; +}; +export function useApiNotificationsRetrieve< + TData = Awaited>, + TError = unknown, +>( + id: string, + options?: { + query?: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + > & + Pick< + UndefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + >, + "initialData" + >; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +}; +export function useApiNotificationsRetrieve< + TData = Awaited>, + TError = unknown, +>( + id: string, + options?: { + query?: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + >; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +}; + +export function useApiNotificationsRetrieve< + TData = Awaited>, + TError = unknown, +>( + id: string, + options?: { + query?: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + >; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +} { + const queryOptions = getApiNotificationsRetrieveQueryOptions(id, options); + + const query = useQuery(queryOptions, queryClient) as UseQueryResult< + TData, + TError + > & { queryKey: DataTag }; + + return { ...query, queryKey: queryOptions.queryKey }; +} + +/** + * @summary Mark a single notification as read + */ +export const apiNotificationsReadCreate = ( + id: string, + notification: NonReadonly, + signal?: AbortSignal, +) => { + return privateMutator({ + url: `/api/notifications/${id}/read/`, + method: "POST", + headers: { "Content-Type": "application/json" }, + data: notification, + signal, + }); +}; + +export const getApiNotificationsReadCreateMutationOptions = < + TError = unknown, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { id: string; data: NonReadonly }, + TContext + >; +}): UseMutationOptions< + Awaited>, + TError, + { id: string; data: NonReadonly }, + TContext +> => { + const mutationKey = ["apiNotificationsReadCreate"]; + const { mutation: mutationOptions } = options + ? options.mutation && + "mutationKey" in options.mutation && + options.mutation.mutationKey + ? options + : { ...options, mutation: { ...options.mutation, mutationKey } } + : { mutation: { mutationKey } }; + + const mutationFn: MutationFunction< + Awaited>, + { id: string; data: NonReadonly } + > = (props) => { + const { id, data } = props ?? {}; + + return apiNotificationsReadCreate(id, data); + }; + + return { mutationFn, ...mutationOptions }; +}; + +export type ApiNotificationsReadCreateMutationResult = NonNullable< + Awaited> +>; +export type ApiNotificationsReadCreateMutationBody = NonReadonly; +export type ApiNotificationsReadCreateMutationError = unknown; + +/** + * @summary Mark a single notification as read + */ +export const useApiNotificationsReadCreate = < + TError = unknown, + TContext = unknown, +>( + options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { id: string; data: NonReadonly }, + TContext + >; + }, + queryClient?: QueryClient, +): UseMutationResult< + Awaited>, + TError, + { id: string; data: NonReadonly }, + TContext +> => { + return useMutation( + getApiNotificationsReadCreateMutationOptions(options), + queryClient, + ); +}; +/** + * @summary Mark all notifications as read + */ +export const apiNotificationsReadAllCreate = ( + notification: NonReadonly, + signal?: AbortSignal, +) => { + return privateMutator({ + url: `/api/notifications/read-all/`, + method: "POST", + headers: { "Content-Type": "application/json" }, + data: notification, + signal, + }); +}; + +export const getApiNotificationsReadAllCreateMutationOptions = < + TError = unknown, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { data: NonReadonly }, + TContext + >; +}): UseMutationOptions< + Awaited>, + TError, + { data: NonReadonly }, + TContext +> => { + const mutationKey = ["apiNotificationsReadAllCreate"]; + const { mutation: mutationOptions } = options + ? options.mutation && + "mutationKey" in options.mutation && + options.mutation.mutationKey + ? options + : { ...options, mutation: { ...options.mutation, mutationKey } } + : { mutation: { mutationKey } }; + + const mutationFn: MutationFunction< + Awaited>, + { data: NonReadonly } + > = (props) => { + const { data } = props ?? {}; + + return apiNotificationsReadAllCreate(data); + }; + + return { mutationFn, ...mutationOptions }; +}; + +export type ApiNotificationsReadAllCreateMutationResult = NonNullable< + Awaited> +>; +export type ApiNotificationsReadAllCreateMutationBody = + NonReadonly; +export type ApiNotificationsReadAllCreateMutationError = unknown; + +/** + * @summary Mark all notifications as read + */ +export const useApiNotificationsReadAllCreate = < + TError = unknown, + TContext = unknown, +>( + options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { data: NonReadonly }, + TContext + >; + }, + queryClient?: QueryClient, +): UseMutationResult< + Awaited>, + TError, + { data: NonReadonly }, + TContext +> => { + return useMutation( + getApiNotificationsReadAllCreateMutationOptions(options), + queryClient, + ); +}; +/** + * @summary Unread notification count + */ +export const apiNotificationsUnreadCountRetrieve = (signal?: AbortSignal) => { + return privateMutator({ + url: `/api/notifications/unread-count/`, + method: "GET", + signal, + }); +}; + +export const getApiNotificationsUnreadCountRetrieveQueryKey = () => { + return [`/api/notifications/unread-count/`] as const; +}; + +export const getApiNotificationsUnreadCountRetrieveQueryOptions = < + TData = Awaited>, + TError = unknown, +>(options?: { + query?: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + >; +}) => { + const { query: queryOptions } = options ?? {}; + + const queryKey = + queryOptions?.queryKey ?? getApiNotificationsUnreadCountRetrieveQueryKey(); + + const queryFn: QueryFunction< + Awaited> + > = ({ signal }) => apiNotificationsUnreadCountRetrieve(signal); + + return { queryKey, queryFn, ...queryOptions } as UseQueryOptions< + Awaited>, + TError, + TData + > & { queryKey: DataTag }; +}; + +export type ApiNotificationsUnreadCountRetrieveQueryResult = NonNullable< + Awaited> +>; +export type ApiNotificationsUnreadCountRetrieveQueryError = unknown; + +export function useApiNotificationsUnreadCountRetrieve< + TData = Awaited>, + TError = unknown, +>( + options: { + query: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + > & + Pick< + DefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + >, + "initialData" + >; + }, + queryClient?: QueryClient, +): DefinedUseQueryResult & { + queryKey: DataTag; +}; +export function useApiNotificationsUnreadCountRetrieve< + TData = Awaited>, + TError = unknown, +>( + options?: { + query?: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + > & + Pick< + UndefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + >, + "initialData" + >; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +}; +export function useApiNotificationsUnreadCountRetrieve< + TData = Awaited>, + TError = unknown, +>( + options?: { + query?: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + >; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +}; +/** + * @summary Unread notification count + */ + +export function useApiNotificationsUnreadCountRetrieve< + TData = Awaited>, + TError = unknown, +>( + options?: { + query?: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + >; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +} { + const queryOptions = + getApiNotificationsUnreadCountRetrieveQueryOptions(options); + + const query = useQuery(queryOptions, queryClient) as UseQueryResult< + TData, + TError + > & { queryKey: DataTag }; + + return { ...query, queryKey: queryOptions.queryKey }; +} diff --git a/frontend/src/api/generated/public/models/index.ts b/frontend/src/api/generated/public/models/index.ts index 80f288a..25ef3a6 100644 --- a/frontend/src/api/generated/public/models/index.ts +++ b/frontend/src/api/generated/public/models/index.ts @@ -50,6 +50,8 @@ export * from "./messageFile"; export * from "./messageReaction"; export * from "./messageSend"; export * from "./messageSender"; +export * from "./notification"; +export * from "./notificationTypeEnum"; export * from "./orderCarrier"; export * from "./orderCreate"; export * from "./orderItemCreate"; @@ -66,6 +68,7 @@ export * from "./paginatedHubList"; export * from "./paginatedHubPermissionList"; export * from "./paginatedChatList"; export * from "./paginatedMessageList"; +export * from "./paginatedNotificationList"; export * from "./paginatedOrderReadList"; export * from "./paginatedPostList"; export * from "./paginatedProductImageList"; diff --git a/frontend/src/api/generated/public/models/notification.ts b/frontend/src/api/generated/public/models/notification.ts new file mode 100644 index 0000000..1b97971 --- /dev/null +++ b/frontend/src/api/generated/public/models/notification.ts @@ -0,0 +1,36 @@ +/** + * Generated by orval v8.8.0 🍺 + * Do not edit manually. + * OpenAPI spec version: 0.0.0 + */ +import type { NotificationTypeEnum } from "./notificationTypeEnum"; + +export interface Notification { + readonly id: number; + /** Předmět oznámení. */ + readonly title: string; + /** Obsah oznámení. */ + readonly text: string; + /** Kategorie oznámení — používá se pro ikonky a filtrování na frontendu. + + * `system` - Systém + * `order` - Objednávka + * `payment` - Platba + * `social` - Sociální + * `chat` - Chat + * `advertisement` - Inzerát */ + readonly notification_type: NotificationTypeEnum; + /** + * Volitelný odkaz na detail (např. '/objednavky/123/'). + * @nullable + */ + readonly action_url: string | null; + readonly is_read: boolean; + /** @nullable */ + readonly read_at: Date | null; + readonly created_at: Date; + /** True, pokud bylo oznámení vytvořeno hromadně pro více uživatelů. */ + readonly bulk: boolean; + /** True, pokud byl zároveň odeslán e-mail. */ + readonly send_email: boolean; +} diff --git a/frontend/src/api/generated/public/models/notificationTypeEnum.ts b/frontend/src/api/generated/public/models/notificationTypeEnum.ts new file mode 100644 index 0000000..ea760eb --- /dev/null +++ b/frontend/src/api/generated/public/models/notificationTypeEnum.ts @@ -0,0 +1,25 @@ +/** + * Generated by orval v8.8.0 🍺 + * Do not edit manually. + * OpenAPI spec version: 0.0.0 + */ + +/** + * * `system` - Systém + * `order` - Objednávka + * `payment` - Platba + * `social` - Sociální + * `chat` - Chat + * `advertisement` - Inzerát + */ +export type NotificationTypeEnum = + (typeof NotificationTypeEnum)[keyof typeof NotificationTypeEnum]; + +export const NotificationTypeEnum = { + system: "system", + order: "order", + payment: "payment", + social: "social", + chat: "chat", + advertisement: "advertisement", +} as const; diff --git a/frontend/src/api/generated/public/models/paginatedNotificationList.ts b/frontend/src/api/generated/public/models/paginatedNotificationList.ts new file mode 100644 index 0000000..2ce3168 --- /dev/null +++ b/frontend/src/api/generated/public/models/paginatedNotificationList.ts @@ -0,0 +1,15 @@ +/** + * Generated by orval v8.8.0 🍺 + * Do not edit manually. + * OpenAPI spec version: 0.0.0 + */ +import type { Notification } from "./notification"; + +export interface PaginatedNotificationList { + count: number; + /** @nullable */ + next?: string | null; + /** @nullable */ + previous?: string | null; + results: Notification[]; +} diff --git a/frontend/src/components/downloader/Downloader.tsx b/frontend/src/components/downloader/Downloader.tsx index 0ac408c..d62e574 100644 --- a/frontend/src/components/downloader/Downloader.tsx +++ b/frontend/src/components/downloader/Downloader.tsx @@ -1,4 +1,5 @@ import { useState, useRef, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; import { apiDownloaderDownloadRetrieve, } from '@/api/generated/public/downloader'; @@ -20,6 +21,8 @@ const FILE_EXTENSIONS = [ ]; export default function Downloader() { + const { t } = useTranslation('downloader'); + const [videoUrl, setVideoUrl] = useState(''); const [videoInfo, setVideoInfo] = useState(null); const [isLoading, setIsLoading] = useState(false); @@ -71,6 +74,7 @@ export default function Downloader() { } }; }, []); + const getAllVideoQualities = () => { if (!videoInfo?.videos) return []; const allQualities = new Set(); @@ -121,12 +125,10 @@ export default function Downloader() { if (info.is_playlist && info.videos) { setSelectedVideos(info.videos.map((_, index) => index + 1)); } - } catch (err: any) { const errorMessage = err.response?.data?.error || err.message; setError({ error: errorMessage }); console.error('Retrieve video info error:', err); - } finally { setIsLoading(false); } @@ -134,12 +136,12 @@ export default function Downloader() { async function handleDownload() { if (!videoUrl) { - setError({ error: 'Please enter a URL first' }); + setError({ error: t('pleaseEnterUrl') }); return; } setIsDownloading(true); - setDownloadStatus('Connecting…'); + setDownloadStatus(t('connectingStatus')); setDownloadProgress(null); setError(null); @@ -158,7 +160,7 @@ export default function Downloader() { await new Promise((resolve, reject) => { ws.onopen = () => { - setDownloadStatus('Starting…'); + setDownloadStatus(t('startingStatus')); // Send download parameters ws.send(JSON.stringify({ url: videoUrl, @@ -181,36 +183,24 @@ export default function Downloader() { setDownloadStatus(data.message); setDownloadProgress(data.percent); } else if (data.type === 'done') { - - setDownloadStatus('Finalizing download...'); + setDownloadStatus(t('finalizing')); setDownloadProgress(100); - - // Přidáme responseType: 'blob', aby Axios/Fetch vrátil soubor správně - publicApi.post('/api/downloader/download/file/', - { token: data.token }, + + publicApi.post('/api/downloader/download/file/', + { token: data.token }, { responseType: 'blob' } ) .then((response) => { - // 1. Vytvoření dočasné URL z přijatého Blobu const url = window.URL.createObjectURL(new Blob([response.data])); - - // 2. Extrakce názvu souboru z hlaviček (pokud ho backend posílá) let filename = data.filename || `video.${selectedExtension}`; - - // 3. Vytvoření neviditelného odkazu pro spuštění stahování const link = document.createElement('a'); link.href = url; - link.setAttribute('download', filename); // Zajišťuje stáhnutí místo otevření + link.setAttribute('download', filename); document.body.appendChild(link); - - // 4. Simulace kliknutí link.click(); - - // 5. Úklid po stažení link.remove(); window.URL.revokeObjectURL(url); - - setDownloadStatus('Download complete!'); + setDownloadStatus(t('downloadComplete')); resolve(); }) .catch((err) => { @@ -251,18 +241,18 @@ export default function Downloader() { return (
-

Video Downloader

+

{t('title')}

- Video URL + {t('urlLabel')}
setVideoUrl(e.target.value)} - placeholder="Paste video URL here (YouTube, TikTok, Vimeo, etc.)" + placeholder={t('urlPlaceholder')} className="w-full p-3 border rounded" />
@@ -272,12 +262,12 @@ export default function Downloader() { disabled={isLoading || !videoUrl} className="px-6 py-3 bg-blue-500 text-white rounded hover:bg-blue-600 disabled:bg-gray-300 disabled:cursor-not-allowed mx-2 mb-4" > - {isLoading ? 'Loading...' : 'Retrieve Options'} + {isLoading ? t('loading') : t('retrieveOptions')} {error && (
- Error: {error.error} + {t('error')}: {error.error}
)} @@ -286,28 +276,28 @@ export default function Downloader() { {videoInfo.is_playlist ? (

- 📋 {videoInfo.playlist_title || 'Playlist'} + 📋 {videoInfo.playlist_title || t('playlist')}

- {videoInfo.playlist_count} videos found + {t('videosFound', { count: videoInfo.playlist_count })}

{/* Playlist Video Selection */}
-

Select Videos to Download:

+

{t('selectVideos')}

@@ -338,12 +328,12 @@ export default function Downloader() {
{video.duration && (
- Duration: {Math.floor(video.duration / 60)}:{String(video.duration % 60).padStart(2, '0')} + {t('duration')}: {Math.floor(video.duration / 60)}:{String(video.duration % 60).padStart(2, '0')}
)}
- Quality: {video.video_resolutions.join(', ') || 'N/A'} | - Audio: {video.audio_resolutions.join(', ') || 'N/A'} + {t('quality')}: {video.video_resolutions.join(', ') || 'N/A'} | + {t('audio')}: {video.audio_resolutions.join(', ') || 'N/A'}
@@ -354,7 +344,7 @@ export default function Downloader() {
- {selectedVideos.length} of {videoInfo.videos.length} videos selected + {t('selected', { selected: selectedVideos.length, total: videoInfo.videos.length })}
@@ -374,7 +364,7 @@ export default function Downloader() { {videoInfo.videos[0]?.duration && (

- Duration: {Math.floor(videoInfo.videos[0].duration / 60)}:{String(videoInfo.videos[0].duration % 60).padStart(2, '0')} + {t('duration')}: {Math.floor(videoInfo.videos[0].duration / 60)}:{String(videoInfo.videos[0].duration % 60).padStart(2, '0')}

)} @@ -386,14 +376,14 @@ export default function Downloader() {
setSelectedAudioQuality(e.target.value)} className="w-full p-2 border rounded" > - + {getAllAudioQualities().map((res) => ( ))} @@ -422,7 +412,7 @@ export default function Downloader() {

- {subtitleLanguages.length} language{subtitleLanguages.length !== 1 ? 's' : ''} available + {t('subtitleLangsAvailable', { count: subtitleLanguages.length })}

); @@ -522,13 +512,13 @@ export default function Downloader() { type="text" value={subtitles} onChange={(e) => setSubtitles(e.target.value)} - placeholder={videoHasSubtitles ? "e.g., 'en', 'en,cs', or 'all'" : 'No subtitles available'} + placeholder={videoHasSubtitles ? "e.g., 'en', 'en,cs', or 'all'" : t('noSubtitlesAvailable')} disabled={!videoHasSubtitles} className={`w-full p-2 border rounded ${!videoHasSubtitles ? 'opacity-50 cursor-not-allowed bg-gray-100' : ''}`} /> {videoHasSubtitles && (

- Auto-captions available — enter language codes (e.g., 'en', 'cs') or 'all' + {t('autoCaptionsHint')}

)} @@ -550,11 +540,11 @@ export default function Downloader() { className="w-4 h-4" /> - Embed Subtitles + {t('embedSubtitles')} {!['mkv', 'mp4'].includes(selectedExtension) - ? (mkv/mp4 only) + ? {t('embedSubtitlesMkvMp4Only')} : !subtitles - ? (select subtitles first) + ? {t('embedSubtitlesSelectFirst')} : null} @@ -570,8 +560,8 @@ export default function Downloader() { className="w-4 h-4" /> - Embed Thumbnail - {!videoHasThumbnail && (no thumbnail available)} + {t('embedThumbnail')} + {!videoHasThumbnail && {t('noThumbnailAvailable')}}
@@ -580,18 +570,17 @@ export default function Downloader() {