diff --git a/.claude/settings.local.json b/.claude/settings.local.json
index 8359675..b863243 100644
--- a/.claude/settings.local.json
+++ b/.claude/settings.local.json
@@ -9,7 +9,14 @@
"Bash(npx eslint *)",
"Bash(python -c ' *)",
"PowerShell(Get-ChildItem -Path \"c:\\\\Users\\\\bruno\\\\Documents\\\\GitHub\\\\vontor-cz\\\\frontend\\\\src\\\\components\\\\social\" -File -Recurse | Select-Object FullName, @{n='Lines';e={\\(Get-Content $_.FullName | Measure-Object -Line\\).Lines}} | Format-Table -AutoSize)",
- "Bash(grep -E \"\\\\.\\(ts|tsx\\)$\")"
+ "Bash(grep -E \"\\\\.\\(ts|tsx\\)$\")",
+ "Bash(grep -v \"^$\")",
+ "Bash(node -e \"const r = require\\('react-icons/fa'\\); console.log\\('FaMigrateAlt' in r, 'FaBrain' in r, 'FaBolt' in r, 'FaCode' in r, 'FaCreditCard' in r, 'FaServer' in r\\);\")",
+ "Bash(node -e \"const r = require\\('react-icons/fa'\\); const keys = Object.keys\\(r\\).filter\\(k => k.toLowerCase\\(\\).includes\\('migrat'\\) || k.toLowerCase\\(\\).includes\\('sync'\\) || k.toLowerCase\\(\\).includes\\('exchange'\\) || k.toLowerCase\\(\\).includes\\('arrow'\\)\\).slice\\(0,15\\); console.log\\(keys.join\\('\\\\n'\\)\\);\")",
+ "Bash(node -e \"const r = require\\('react-icons/fa'\\); console.log\\('FaExchangeAlt' in r, 'FaSyncAlt' in r, 'FaCloudUploadAlt' in r, 'FaRandom' in r, 'FaDatabase' in r\\);\")",
+ "Bash(node -e \"const r = require\\('react-icons/gi'\\); console.log\\('GiStabilizer' in r, 'GiDroneBoy' in r, 'GiCctvCamera' in r, 'GiFilmProjector' in r, 'GiGyroscope' in r\\);\")",
+ "Bash(node -e \"const r = require\\('react-icons/si'\\); const celery = Object.keys\\(r\\).filter\\(k => k.toLowerCase\\(\\).includes\\('celery'\\) || k.toLowerCase\\(\\).includes\\('worker'\\) || k.toLowerCase\\(\\).includes\\('task'\\)\\).slice\\(0,10\\); console.log\\(celery\\);\")",
+ "Bash(Get-ChildItem -Path \"c:\\\\Users\\\\bruno\\\\Documents\\\\GitHub\\\\vontor-cz\\\\backend\\\\\" -Directory | Select-Object -ExpandProperty Name)"
]
}
}
diff --git a/backend/advertisement/tasks.py b/backend/advertisement/tasks.py
index d6b7845..30a7527 100644
--- a/backend/advertisement/tasks.py
+++ b/backend/advertisement/tasks.py
@@ -14,8 +14,10 @@ def send_contact_me_email_task(client_email, message_content):
"client_email": client_email,
"message_content": message_content
}
+ config_email = SiteConfiguration.get_solo().contact_email
+ recipient = config_email if config_email else "brunovontor@gmail.com"
send_email_with_context(
- recipients=SiteConfiguration.get_solo().contact_email,
+ recipients=recipient,
subject="Poptávka z kontaktního formuláře!!!",
template_path="email/contact_me.html",
context=context,
diff --git a/backend/social/chat/apps.py b/backend/social/chat/apps.py
index bdfbeb0..dd7ad1f 100644
--- a/backend/social/chat/apps.py
+++ b/backend/social/chat/apps.py
@@ -6,3 +6,6 @@ class ChatConfig(AppConfig):
name = 'social.chat'
label = "chat"
+
+ def ready(self):
+ import social.chat.signals # noqa: F401
diff --git a/backend/social/chat/permissions.py b/backend/social/chat/permissions.py
index a449178..3dadbf2 100644
--- a/backend/social/chat/permissions.py
+++ b/backend/social/chat/permissions.py
@@ -9,7 +9,11 @@ class IsChatMember(IsAuthenticated):
"""
def has_object_permission(self, request, view, obj):
- return request.user.is_superuser or obj.members.filter(pk=request.user.pk).exists()
+ return (
+ request.user.is_superuser
+ or obj.owner == request.user
+ or obj.members.filter(pk=request.user.pk).exists()
+ )
class CanManageChat(IsAuthenticated):
diff --git a/backend/social/chat/serializers.py b/backend/social/chat/serializers.py
index 7349059..1031c79 100644
--- a/backend/social/chat/serializers.py
+++ b/backend/social/chat/serializers.py
@@ -43,11 +43,12 @@ class MessageHistorySerializer(serializers.ModelSerializer):
class ReplyToSerializer(serializers.ModelSerializer):
sender = MessageSenderSerializer(read_only=True)
+ media_files = MessageFileSerializer(many=True, read_only=True)
class Meta:
model = Message
- fields = ['id', 'content', 'sender']
- read_only_fields = ['id', 'content', 'sender']
+ fields = ['id', 'content', 'sender', 'created_at', 'media_files']
+ read_only_fields = ['id', 'content', 'sender', 'created_at', 'media_files']
class MessageSerializer(serializers.ModelSerializer):
@@ -69,23 +70,34 @@ class MessageSerializer(serializers.ModelSerializer):
if not reply_to_id:
return None
try:
- msg = Message.all_objects.select_related('sender').get(pk=reply_to_id)
+ msg = Message.all_objects.select_related('sender').prefetch_related('media_files').get(pk=reply_to_id)
except Message.DoesNotExist:
return None
+ from django.conf import settings
sender_data = None
if msg.sender:
- from django.conf import settings
avatar = (settings.MEDIA_URL + msg.sender.avatar.name) if msg.sender.avatar else None
sender_data = {'id': msg.sender.id, 'username': msg.sender.username, 'avatar': avatar}
else:
sender_data = {'id': 0, 'username': '…', 'avatar': None}
+ media_files_data = []
+ if not msg.is_deleted:
+ for f in msg.media_files.all():
+ media_files_data.append({
+ 'id': f.id,
+ 'file': settings.MEDIA_URL + f.file.name if f.file else '',
+ 'media_type': f.media_type,
+ 'uploaded_at': f.uploaded_at.isoformat(),
+ })
+
return {
'id': msg.id,
- # content=None signals the frontend to show the deleted tombstone
'content': None if msg.is_deleted else msg.content,
'sender': sender_data,
+ 'created_at': msg.created_at.isoformat(),
+ 'media_files': media_files_data,
}
class Meta:
diff --git a/backend/social/chat/signals.py b/backend/social/chat/signals.py
new file mode 100644
index 0000000..8f20c3d
--- /dev/null
+++ b/backend/social/chat/signals.py
@@ -0,0 +1,35 @@
+from django.conf import settings
+from django.db.models.signals import post_save
+from django.dispatch import receiver
+
+
+@receiver(post_save, sender=settings.AUTH_USER_MODEL)
+def sync_dm_chat_identity(sender, instance, created, update_fields, **kwargs):
+ """Keep DM chat name/icon in sync when a user updates their username or avatar."""
+ if created:
+ return
+
+ changed = set(update_fields) if update_fields else None # None = full save
+ username_changed = changed is None or 'username' in changed
+ avatar_changed = changed is None or 'avatar' in changed
+
+ if not (username_changed or avatar_changed):
+ return
+
+ from .models import Chat
+
+ dm_chats = Chat.objects.filter(
+ chat_type=Chat.ChatType.DM,
+ members=instance,
+ ).exclude(owner=instance)
+
+ if not dm_chats.exists():
+ return
+
+ update_kwargs = {}
+ if username_changed:
+ update_kwargs['name'] = instance.username
+ if avatar_changed:
+ update_kwargs['icon'] = instance.avatar.name if instance.avatar else None
+
+ dm_chats.update(**update_kwargs)
diff --git a/backend/social/chat/views.py b/backend/social/chat/views.py
index 0128422..a09b79a 100644
--- a/backend/social/chat/views.py
+++ b/backend/social/chat/views.py
@@ -53,7 +53,23 @@ class ChatViewSet(viewsets.ModelViewSet):
return Chat.objects.filter(Q(members=user) | Q(owner=user)).distinct()
def perform_create(self, serializer):
- serializer.save(owner=self.request.user)
+ chat = serializer.save(owner=self.request.user)
+
+ # Ensure the creator is always a member so they pass membership checks.
+ chat.members.add(self.request.user)
+
+ if chat.chat_type == Chat.ChatType.DM:
+ other = chat.members.exclude(pk=self.request.user.pk).first()
+ if other:
+ update_fields = []
+ if not chat.name:
+ chat.name = other.username
+ update_fields.append('name')
+ if not chat.icon and other.avatar:
+ chat.icon = other.avatar
+ update_fields.append('icon')
+ if update_fields:
+ chat.save(update_fields=update_fields)
# ------------------------------------------------------------------
# Member management
@@ -180,8 +196,7 @@ class MessageViewSet(viewsets.ModelViewSet):
qs = Message.objects.select_related('sender', 'chat').prefetch_related('media_files', 'reactions')
if user.is_superuser:
return qs
- # Only messages from chats the user is a member of
- return qs.filter(chat__members=user)
+ return qs.filter(Q(chat__members=user) | Q(chat__owner=user)).distinct()
def perform_update(self, serializer):
message = serializer.instance
@@ -221,7 +236,7 @@ class MessageViewSet(viewsets.ModelViewSet):
ser.is_valid(raise_exception=True)
chat = ser.validated_data['chat']
- if not request.user.is_superuser and not chat.members.filter(pk=request.user.pk).exists():
+ if not request.user.is_superuser and not chat.members.filter(pk=request.user.pk).exists() and chat.owner != request.user:
raise PermissionDenied('You are not a member of this chat.')
message = Message.objects.create(
diff --git a/backend/social/hubs/urls.py b/backend/social/hubs/urls.py
index b86e968..7a47a87 100644
--- a/backend/social/hubs/urls.py
+++ b/backend/social/hubs/urls.py
@@ -1,9 +1,19 @@
+from django.urls import include, path
from rest_framework.routers import DefaultRouter
from .views import HubViewSet, HubPermissionViewSet, TagsViewSet
-router = DefaultRouter()
-router.register('', HubViewSet, basename='hub')
-router.register('moderators', HubPermissionViewSet, basename='hub-moderator')
-router.register('tags', TagsViewSet, basename='hub-tag')
+hub_router = DefaultRouter()
+hub_router.register('', HubViewSet, basename='hub')
-urlpatterns = router.urls
+moderators_router = DefaultRouter()
+moderators_router.register('', HubPermissionViewSet, basename='hub-moderator')
+
+tags_router = DefaultRouter()
+tags_router.register('', TagsViewSet, basename='hub-tag')
+
+# moderators/ and tags/ must be declared BEFORE the hub router urls so that
+# Django resolves them before the hub's generic /{pk}/ pattern can swallow them.
+urlpatterns = [
+ path('moderators/', include(moderators_router.urls)),
+ path('tags/', include(tags_router.urls)),
+] + hub_router.urls
diff --git a/backend/social/hubs/views.py b/backend/social/hubs/views.py
index dc8cfbf..a55f447 100644
--- a/backend/social/hubs/views.py
+++ b/backend/social/hubs/views.py
@@ -58,6 +58,7 @@ from .serializers import HubPermissionSerializer, HubSerializer, TagsSerializer,
class HubViewSet(viewsets.ModelViewSet):
serializer_class = HubSerializer
permission_classes = [CanEditHub]
+ lookup_field = 'name'
filterset_fields = ['is_public', 'owner']
search_fields = ['name', 'description']
ordering_fields = ['name']
@@ -223,7 +224,11 @@ class HubPermissionViewSet(viewsets.ModelViewSet):
filterset_fields = ['user', 'changing_name', 'changing_description', 'changing_icon', 'changing_banner', 'managing_members', 'managing_posts', 'managing_chats']
def _get_hub(self):
- hub_id = self.kwargs.get('hub_pk') or self.request.query_params.get('hub')
+ hub_id = (
+ self.kwargs.get('hub_pk')
+ or self.request.query_params.get('hub')
+ or self.request.data.get('hub')
+ )
return Hub.objects.get(pk=hub_id)
def get_queryset(self):
@@ -279,10 +284,16 @@ class TagsViewSet(viewsets.ModelViewSet):
ordering = ['name']
def _get_hub(self):
- hub_id = self.kwargs.get('hub_pk') or self.request.query_params.get('hub')
+ hub_id = (
+ self.kwargs.get('hub_pk')
+ or self.request.query_params.get('hub')
+ or self.request.data.get('hub')
+ )
return Hub.objects.get(pk=hub_id)
def get_queryset(self):
+ if self.kwargs.get('pk'):
+ return Tags.objects.all()
return Tags.objects.filter(hub=self._get_hub())
def perform_create(self, serializer):
diff --git a/backend/social/posts/serializers.py b/backend/social/posts/serializers.py
index f93a413..ceb34fd 100644
--- a/backend/social/posts/serializers.py
+++ b/backend/social/posts/serializers.py
@@ -2,6 +2,7 @@ from django.contrib.auth import get_user_model
from rest_framework import serializers
from .models import Post, PostContent, PostVote, PostSave
from social.hubs.serializers import TagsSerializer
+from social.hubs.models import Hub
User = get_user_model()
@@ -21,10 +22,17 @@ class PostContentSerializer(serializers.ModelSerializer):
read_only_fields = ['mime_type']
+class PostHubSerializer(serializers.ModelSerializer):
+ class Meta:
+ model = Hub
+ fields = ['id', 'name', 'icon']
+
+
class PostSerializer(serializers.ModelSerializer):
contents = PostContentSerializer(many=True, read_only=True)
tags = TagsSerializer(many=True, read_only=True)
author_detail = AuthorMinimalSerializer(source='author', read_only=True)
+ hub_detail = PostHubSerializer(source='hub', read_only=True)
vote_score = serializers.SerializerMethodField()
user_vote = serializers.SerializerMethodField()
reply_count = serializers.IntegerField(read_only=True, default=0)
@@ -36,7 +44,7 @@ class PostSerializer(serializers.ModelSerializer):
fields = [
'id', 'content', 'created_at', 'updated_at',
'author', 'author_detail',
- 'hub', 'reply_to',
+ 'hub', 'hub_detail', 'reply_to',
'tags', 'contents',
'vote_score', 'user_vote', 'reply_count', 'is_saved', 'save_count',
]
diff --git a/backend/social/posts/views.py b/backend/social/posts/views.py
index b23f967..0f6e068 100644
--- a/backend/social/posts/views.py
+++ b/backend/social/posts/views.py
@@ -1,4 +1,8 @@
-from django.db.models import Count, Q
+from datetime import timedelta
+
+from django.db.models import Count, Q, Sum
+from django.db.models.functions import Coalesce
+from django.utils import timezone
from rest_framework import status, viewsets
from rest_framework.decorators import action
from rest_framework.exceptions import PermissionDenied, ValidationError
@@ -8,7 +12,7 @@ from drf_spectacular.utils import extend_schema, extend_schema_view, OpenApiPara
from rest_framework.permissions import IsAuthenticated
from social.hubs.models import Tags
-from vontor_cz.pagination import CreatedCursorPagination
+from vontor_cz.pagination import CreatedCursorPagination, TopPostsCursorPagination
from .models import Post, PostContent, PostVote, PostSave
from .permissions import CanDeletePost, IsPostAuthorOnly
from .serializers import PostSerializer, PostContentSerializer, PostVoteSerializer, TagAttachSerializer
@@ -78,6 +82,51 @@ class PostViewSet(viewsets.ModelViewSet):
def perform_create(self, serializer):
serializer.save(author=self.request.user)
+ _TIME_WINDOWS = {
+ '1h': timedelta(hours=1),
+ '6h': timedelta(hours=6),
+ 'day': timedelta(days=1),
+ 'week': timedelta(weeks=1),
+ 'month': timedelta(days=30),
+ 'year': timedelta(days=365),
+ }
+
+ def _get_cutoff(self, time_param):
+ """Return a datetime cutoff for the given time window, or None for 'all'."""
+ if time_param in self._TIME_WINDOWS:
+ return timezone.now() - self._TIME_WINDOWS[time_param]
+ return None
+
+ def list(self, request, *args, **kwargs):
+ sort = request.query_params.get('sort', 'newest')
+ time_param = request.query_params.get('time', 'all')
+
+ qs = self.filter_queryset(self.get_queryset())
+
+ # Time filter
+ if time_param == 'custom':
+ start = request.query_params.get('start')
+ end = request.query_params.get('end')
+ if start:
+ qs = qs.filter(created_at__date__gte=start)
+ if end:
+ qs = qs.filter(created_at__date__lte=end)
+ else:
+ cutoff = self._get_cutoff(time_param)
+ if cutoff:
+ qs = qs.filter(created_at__gte=cutoff)
+
+ if sort == 'top':
+ qs = qs.annotate(vote_score=Coalesce(Sum('votes__vote'), 0)).order_by('-vote_score', '-id')
+ paginator = TopPostsCursorPagination()
+ else:
+ qs = qs.order_by('-created_at')
+ paginator = CreatedCursorPagination()
+
+ page = paginator.paginate_queryset(qs, request, view=self)
+ ser = PostSerializer(page, many=True, context={'request': request})
+ return paginator.get_paginated_response(ser.data)
+
# ------------------------------------------------------------------
# Media upload action
# ------------------------------------------------------------------
diff --git a/backend/templates/email/contact_me.html b/backend/templates/email/contact_me.html
new file mode 100644
index 0000000..1f9d9ab
--- /dev/null
+++ b/backend/templates/email/contact_me.html
@@ -0,0 +1,45 @@
+
+
+
+
+
+ 📬 Nová zpráva z kontaktního formuláře
+
+
+ Přišla poptávka přes vontor.cz
+
+
+
+
+
+
+
+ Od
+
+ {{ client_email }}
+
+
+
+
+
+
+
+
+
+ Zpráva
+ {{ message_content }}
+
+
+
+
+
+
+
+
+
+ Odpovědět
+
+
+
+
diff --git a/backend/vontor_cz/pagination.py b/backend/vontor_cz/pagination.py
index 790370d..c0cd6f5 100644
--- a/backend/vontor_cz/pagination.py
+++ b/backend/vontor_cz/pagination.py
@@ -18,6 +18,15 @@ class CreatedCursorPagination(CursorPagination):
max_page_size = 100
+class TopPostsCursorPagination(CursorPagination):
+ """Cursor pagination ordered by vote score descending, then by id descending as tiebreaker."""
+ page_size = 20
+ ordering = ('-vote_score', '-id')
+ cursor_query_param = 'cursor'
+ page_size_query_param = 'page_size'
+ max_page_size = 100
+
+
class CreatedAscCursorPagination(CursorPagination):
"""Cursor pagination ordered by `created_at` (oldest first).
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index f41b1e4..d65995f 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -26,6 +26,8 @@ import FeedPage from "./pages/social/FeedPage";
import PostPage from "./pages/social/PostPage";
import HubsPage from "./pages/social/HubsPage";
import HubPage from "./pages/social/HubPage";
+import HubCreatePage from "./pages/social/hub/Create";
+import HubSettingsPage from "./pages/social/hub/Settings";
import ProfilePage from "./pages/social/ProfilePage";
import UserProfilePage from "./pages/social/UserProfilePage";
import SavedPage from "./pages/social/SavedPage";
@@ -63,7 +65,9 @@ export default function App() {
} />
} />
} />
- } />
+ } />
+ } />
+ } />
} />
} />
} />
diff --git a/frontend/src/api/generated/private/hubs/hubs.ts b/frontend/src/api/generated/private/hubs/hubs.ts
index fe9f2fc..4edbfd5 100644
--- a/frontend/src/api/generated/private/hubs/hubs.ts
+++ b/frontend/src/api/generated/private/hubs/hubs.ts
@@ -313,23 +313,23 @@ export const useApiSocialHubsCreate = (
/**
* @summary Retrieve a hub
*/
-export const apiSocialHubsRetrieve = (id: string, signal?: AbortSignal) => {
+export const apiSocialHubsRetrieve = (name: string, signal?: AbortSignal) => {
return privateMutator({
- url: `/api/social/hubs/${id}/`,
+ url: `/api/social/hubs/${name}/`,
method: "GET",
signal,
});
};
-export const getApiSocialHubsRetrieveQueryKey = (id: string) => {
- return [`/api/social/hubs/${id}/`] as const;
+export const getApiSocialHubsRetrieveQueryKey = (name: string) => {
+ return [`/api/social/hubs/${name}/`] as const;
};
export const getApiSocialHubsRetrieveQueryOptions = <
TData = Awaited>,
TError = unknown,
>(
- id: string,
+ name: string,
options?: {
query?: Partial<
UseQueryOptions<
@@ -343,16 +343,16 @@ export const getApiSocialHubsRetrieveQueryOptions = <
const { query: queryOptions } = options ?? {};
const queryKey =
- queryOptions?.queryKey ?? getApiSocialHubsRetrieveQueryKey(id);
+ queryOptions?.queryKey ?? getApiSocialHubsRetrieveQueryKey(name);
const queryFn: QueryFunction<
Awaited>
- > = ({ signal }) => apiSocialHubsRetrieve(id, signal);
+ > = ({ signal }) => apiSocialHubsRetrieve(name, signal);
return {
queryKey,
queryFn,
- enabled: !!id,
+ enabled: !!name,
...queryOptions,
} as UseQueryOptions<
Awaited>,
@@ -370,7 +370,7 @@ export function useApiSocialHubsRetrieve<
TData = Awaited>,
TError = unknown,
>(
- id: string,
+ name: string,
options: {
query: Partial<
UseQueryOptions<
@@ -396,7 +396,7 @@ export function useApiSocialHubsRetrieve<
TData = Awaited>,
TError = unknown,
>(
- id: string,
+ name: string,
options?: {
query?: Partial<
UseQueryOptions<
@@ -422,7 +422,7 @@ export function useApiSocialHubsRetrieve<
TData = Awaited>,
TError = unknown,
>(
- id: string,
+ name: string,
options?: {
query?: Partial<
UseQueryOptions<
@@ -444,7 +444,7 @@ export function useApiSocialHubsRetrieve<
TData = Awaited>,
TError = unknown,
>(
- id: string,
+ name: string,
options?: {
query?: Partial<
UseQueryOptions<
@@ -458,7 +458,7 @@ export function useApiSocialHubsRetrieve<
): UseQueryResult & {
queryKey: DataTag;
} {
- const queryOptions = getApiSocialHubsRetrieveQueryOptions(id, options);
+ const queryOptions = getApiSocialHubsRetrieveQueryOptions(name, options);
const query = useQuery(queryOptions, queryClient) as UseQueryResult<
TData,
@@ -473,12 +473,12 @@ export function useApiSocialHubsRetrieve<
* @summary Replace a hub
*/
export const apiSocialHubsUpdate = (
- id: string,
+ name: string,
hub: NonReadonly,
signal?: AbortSignal,
) => {
return privateMutator({
- url: `/api/social/hubs/${id}/`,
+ url: `/api/social/hubs/${name}/`,
method: "PUT",
headers: { "Content-Type": "application/json" },
data: hub,
@@ -493,13 +493,13 @@ export const getApiSocialHubsUpdateMutationOptions = <
mutation?: UseMutationOptions<
Awaited>,
TError,
- { id: string; data: NonReadonly },
+ { name: string; data: NonReadonly },
TContext
>;
}): UseMutationOptions<
Awaited>,
TError,
- { id: string; data: NonReadonly },
+ { name: string; data: NonReadonly },
TContext
> => {
const mutationKey = ["apiSocialHubsUpdate"];
@@ -513,11 +513,11 @@ export const getApiSocialHubsUpdateMutationOptions = <
const mutationFn: MutationFunction<
Awaited>,
- { id: string; data: NonReadonly }
+ { name: string; data: NonReadonly }
> = (props) => {
- const { id, data } = props ?? {};
+ const { name, data } = props ?? {};
- return apiSocialHubsUpdate(id, data);
+ return apiSocialHubsUpdate(name, data);
};
return { mutationFn, ...mutationOptions };
@@ -537,7 +537,7 @@ export const useApiSocialHubsUpdate = (
mutation?: UseMutationOptions<
Awaited>,
TError,
- { id: string; data: NonReadonly },
+ { name: string; data: NonReadonly },
TContext
>;
},
@@ -545,7 +545,7 @@ export const useApiSocialHubsUpdate = (
): UseMutationResult<
Awaited>,
TError,
- { id: string; data: NonReadonly },
+ { name: string; data: NonReadonly },
TContext
> => {
return useMutation(
@@ -558,12 +558,12 @@ export const useApiSocialHubsUpdate = (
* @summary Update a hub
*/
export const apiSocialHubsPartialUpdate = (
- id: string,
+ name: string,
patchedHub: NonReadonly,
signal?: AbortSignal,
) => {
return privateMutator({
- url: `/api/social/hubs/${id}/`,
+ url: `/api/social/hubs/${name}/`,
method: "PATCH",
headers: { "Content-Type": "application/json" },
data: patchedHub,
@@ -578,13 +578,13 @@ export const getApiSocialHubsPartialUpdateMutationOptions = <
mutation?: UseMutationOptions<
Awaited>,
TError,
- { id: string; data: NonReadonly },
+ { name: string; data: NonReadonly },
TContext
>;
}): UseMutationOptions<
Awaited>,
TError,
- { id: string; data: NonReadonly },
+ { name: string; data: NonReadonly },
TContext
> => {
const mutationKey = ["apiSocialHubsPartialUpdate"];
@@ -598,11 +598,11 @@ export const getApiSocialHubsPartialUpdateMutationOptions = <
const mutationFn: MutationFunction<
Awaited>,
- { id: string; data: NonReadonly }
+ { name: string; data: NonReadonly }
> = (props) => {
- const { id, data } = props ?? {};
+ const { name, data } = props ?? {};
- return apiSocialHubsPartialUpdate(id, data);
+ return apiSocialHubsPartialUpdate(name, data);
};
return { mutationFn, ...mutationOptions };
@@ -625,7 +625,7 @@ export const useApiSocialHubsPartialUpdate = <
mutation?: UseMutationOptions<
Awaited>,
TError,
- { id: string; data: NonReadonly },
+ { name: string; data: NonReadonly },
TContext
>;
},
@@ -633,7 +633,7 @@ export const useApiSocialHubsPartialUpdate = <
): UseMutationResult<
Awaited>,
TError,
- { id: string; data: NonReadonly },
+ { name: string; data: NonReadonly },
TContext
> => {
return useMutation(
@@ -645,9 +645,9 @@ export const useApiSocialHubsPartialUpdate = <
* Soft-deletes the hub. Owner or admin only.
* @summary Delete a hub
*/
-export const apiSocialHubsDestroy = (id: string, signal?: AbortSignal) => {
+export const apiSocialHubsDestroy = (name: string, signal?: AbortSignal) => {
return privateMutator({
- url: `/api/social/hubs/${id}/`,
+ url: `/api/social/hubs/${name}/`,
method: "DELETE",
signal,
});
@@ -660,13 +660,13 @@ export const getApiSocialHubsDestroyMutationOptions = <
mutation?: UseMutationOptions<
Awaited>,
TError,
- { id: string },
+ { name: string },
TContext
>;
}): UseMutationOptions<
Awaited>,
TError,
- { id: string },
+ { name: string },
TContext
> => {
const mutationKey = ["apiSocialHubsDestroy"];
@@ -680,11 +680,11 @@ export const getApiSocialHubsDestroyMutationOptions = <
const mutationFn: MutationFunction<
Awaited>,
- { id: string }
+ { name: string }
> = (props) => {
- const { id } = props ?? {};
+ const { name } = props ?? {};
- return apiSocialHubsDestroy(id);
+ return apiSocialHubsDestroy(name);
};
return { mutationFn, ...mutationOptions };
@@ -704,7 +704,7 @@ export const useApiSocialHubsDestroy = (
mutation?: UseMutationOptions<
Awaited>,
TError,
- { id: string },
+ { name: string },
TContext
>;
},
@@ -712,7 +712,7 @@ export const useApiSocialHubsDestroy = (
): UseMutationResult<
Awaited>,
TError,
- { id: string },
+ { name: string },
TContext
> => {
return useMutation(
@@ -724,9 +724,9 @@ export const useApiSocialHubsDestroy = (
* Adds the authenticated user as a member. Private hubs reject this request.
* @summary Join a hub
*/
-export const apiSocialHubsJoinCreate = (id: string, signal?: AbortSignal) => {
+export const apiSocialHubsJoinCreate = (name: string, signal?: AbortSignal) => {
return privateMutator({
- url: `/api/social/hubs/${id}/join/`,
+ url: `/api/social/hubs/${name}/join/`,
method: "POST",
signal,
});
@@ -739,13 +739,13 @@ export const getApiSocialHubsJoinCreateMutationOptions = <
mutation?: UseMutationOptions<
Awaited>,
TError,
- { id: string },
+ { name: string },
TContext
>;
}): UseMutationOptions<
Awaited>,
TError,
- { id: string },
+ { name: string },
TContext
> => {
const mutationKey = ["apiSocialHubsJoinCreate"];
@@ -759,11 +759,11 @@ export const getApiSocialHubsJoinCreateMutationOptions = <
const mutationFn: MutationFunction<
Awaited>,
- { id: string }
+ { name: string }
> = (props) => {
- const { id } = props ?? {};
+ const { name } = props ?? {};
- return apiSocialHubsJoinCreate(id);
+ return apiSocialHubsJoinCreate(name);
};
return { mutationFn, ...mutationOptions };
@@ -786,7 +786,7 @@ export const useApiSocialHubsJoinCreate = <
mutation?: UseMutationOptions<
Awaited>,
TError,
- { id: string },
+ { name: string },
TContext
>;
},
@@ -794,7 +794,7 @@ export const useApiSocialHubsJoinCreate = <
): UseMutationResult<
Awaited>,
TError,
- { id: string },
+ { name: string },
TContext
> => {
return useMutation(
@@ -806,9 +806,12 @@ export const useApiSocialHubsJoinCreate = <
* Removes the authenticated user from the hub's members.
* @summary Leave a hub
*/
-export const apiSocialHubsLeaveCreate = (id: string, signal?: AbortSignal) => {
+export const apiSocialHubsLeaveCreate = (
+ name: string,
+ signal?: AbortSignal,
+) => {
return privateMutator({
- url: `/api/social/hubs/${id}/leave/`,
+ url: `/api/social/hubs/${name}/leave/`,
method: "POST",
signal,
});
@@ -821,13 +824,13 @@ export const getApiSocialHubsLeaveCreateMutationOptions = <
mutation?: UseMutationOptions<
Awaited>,
TError,
- { id: string },
+ { name: string },
TContext
>;
}): UseMutationOptions<
Awaited>,
TError,
- { id: string },
+ { name: string },
TContext
> => {
const mutationKey = ["apiSocialHubsLeaveCreate"];
@@ -841,11 +844,11 @@ export const getApiSocialHubsLeaveCreateMutationOptions = <
const mutationFn: MutationFunction<
Awaited>,
- { id: string }
+ { name: string }
> = (props) => {
- const { id } = props ?? {};
+ const { name } = props ?? {};
- return apiSocialHubsLeaveCreate(id);
+ return apiSocialHubsLeaveCreate(name);
};
return { mutationFn, ...mutationOptions };
@@ -868,7 +871,7 @@ export const useApiSocialHubsLeaveCreate = <
mutation?: UseMutationOptions<
Awaited>,
TError,
- { id: string },
+ { name: string },
TContext
>;
},
@@ -876,7 +879,7 @@ export const useApiSocialHubsLeaveCreate = <
): UseMutationResult<
Awaited>,
TError,
- { id: string },
+ { name: string },
TContext
> => {
return useMutation(
@@ -889,11 +892,11 @@ export const useApiSocialHubsLeaveCreate = <
* @summary Cancel ownership transfer
*/
export const apiSocialHubsTransferCancelCreate = (
- id: string,
+ name: string,
signal?: AbortSignal,
) => {
return privateMutator({
- url: `/api/social/hubs/${id}/transfer/cancel/`,
+ url: `/api/social/hubs/${name}/transfer/cancel/`,
method: "POST",
signal,
});
@@ -906,13 +909,13 @@ export const getApiSocialHubsTransferCancelCreateMutationOptions = <
mutation?: UseMutationOptions<
Awaited>,
TError,
- { id: string },
+ { name: string },
TContext
>;
}): UseMutationOptions<
Awaited>,
TError,
- { id: string },
+ { name: string },
TContext
> => {
const mutationKey = ["apiSocialHubsTransferCancelCreate"];
@@ -926,11 +929,11 @@ export const getApiSocialHubsTransferCancelCreateMutationOptions = <
const mutationFn: MutationFunction<
Awaited>,
- { id: string }
+ { name: string }
> = (props) => {
- const { id } = props ?? {};
+ const { name } = props ?? {};
- return apiSocialHubsTransferCancelCreate(id);
+ return apiSocialHubsTransferCancelCreate(name);
};
return { mutationFn, ...mutationOptions };
@@ -953,7 +956,7 @@ export const useApiSocialHubsTransferCancelCreate = <
mutation?: UseMutationOptions<
Awaited>,
TError,
- { id: string },
+ { name: string },
TContext
>;
},
@@ -961,7 +964,7 @@ export const useApiSocialHubsTransferCancelCreate = <
): UseMutationResult<
Awaited>,
TError,
- { id: string },
+ { name: string },
TContext
> => {
return useMutation(
@@ -974,12 +977,12 @@ export const useApiSocialHubsTransferCancelCreate = <
* @summary Initiate ownership transfer
*/
export const apiSocialHubsTransferInitiateCreate = (
- id: string,
+ name: string,
transferInit: TransferInit,
signal?: AbortSignal,
) => {
return privateMutator({
- url: `/api/social/hubs/${id}/transfer/initiate/`,
+ url: `/api/social/hubs/${name}/transfer/initiate/`,
method: "POST",
headers: { "Content-Type": "application/json" },
data: transferInit,
@@ -994,13 +997,13 @@ export const getApiSocialHubsTransferInitiateCreateMutationOptions = <
mutation?: UseMutationOptions<
Awaited>,
TError,
- { id: string; data: TransferInit },
+ { name: string; data: TransferInit },
TContext
>;
}): UseMutationOptions<
Awaited>,
TError,
- { id: string; data: TransferInit },
+ { name: string; data: TransferInit },
TContext
> => {
const mutationKey = ["apiSocialHubsTransferInitiateCreate"];
@@ -1014,11 +1017,11 @@ export const getApiSocialHubsTransferInitiateCreateMutationOptions = <
const mutationFn: MutationFunction<
Awaited>,
- { id: string; data: TransferInit }
+ { name: string; data: TransferInit }
> = (props) => {
- const { id, data } = props ?? {};
+ const { name, data } = props ?? {};
- return apiSocialHubsTransferInitiateCreate(id, data);
+ return apiSocialHubsTransferInitiateCreate(name, data);
};
return { mutationFn, ...mutationOptions };
@@ -1041,7 +1044,7 @@ export const useApiSocialHubsTransferInitiateCreate = <
mutation?: UseMutationOptions<
Awaited>,
TError,
- { id: string; data: TransferInit },
+ { name: string; data: TransferInit },
TContext
>;
},
@@ -1049,7 +1052,7 @@ export const useApiSocialHubsTransferInitiateCreate = <
): UseMutationResult<
Awaited>,
TError,
- { id: string; data: TransferInit },
+ { name: string; data: TransferInit },
TContext
> => {
return useMutation(
@@ -1062,12 +1065,12 @@ export const useApiSocialHubsTransferInitiateCreate = <
* @summary Verify ownership transfer
*/
export const apiSocialHubsTransferVerifyCreate = (
- id: string,
+ name: string,
transferVerify: TransferVerify,
signal?: AbortSignal,
) => {
return privateMutator({
- url: `/api/social/hubs/${id}/transfer/verify/`,
+ url: `/api/social/hubs/${name}/transfer/verify/`,
method: "POST",
headers: { "Content-Type": "application/json" },
data: transferVerify,
@@ -1082,13 +1085,13 @@ export const getApiSocialHubsTransferVerifyCreateMutationOptions = <
mutation?: UseMutationOptions<
Awaited>,
TError,
- { id: string; data: TransferVerify },
+ { name: string; data: TransferVerify },
TContext
>;
}): UseMutationOptions<
Awaited>,
TError,
- { id: string; data: TransferVerify },
+ { name: string; data: TransferVerify },
TContext
> => {
const mutationKey = ["apiSocialHubsTransferVerifyCreate"];
@@ -1102,11 +1105,11 @@ export const getApiSocialHubsTransferVerifyCreateMutationOptions = <
const mutationFn: MutationFunction<
Awaited>,
- { id: string; data: TransferVerify }
+ { name: string; data: TransferVerify }
> = (props) => {
- const { id, data } = props ?? {};
+ const { name, data } = props ?? {};
- return apiSocialHubsTransferVerifyCreate(id, data);
+ return apiSocialHubsTransferVerifyCreate(name, data);
};
return { mutationFn, ...mutationOptions };
@@ -1129,7 +1132,7 @@ export const useApiSocialHubsTransferVerifyCreate = <
mutation?: UseMutationOptions<
Awaited>,
TError,
- { id: string; data: TransferVerify },
+ { name: string; data: TransferVerify },
TContext
>;
},
@@ -1137,7 +1140,7 @@ export const useApiSocialHubsTransferVerifyCreate = <
): UseMutationResult<
Awaited>,
TError,
- { id: string; data: TransferVerify },
+ { name: string; data: TransferVerify },
TContext
> => {
return useMutation(
diff --git a/frontend/src/api/generated/private/models/chat.ts b/frontend/src/api/generated/private/models/chat.ts
index 0f22475..4f4ca43 100644
--- a/frontend/src/api/generated/private/models/chat.ts
+++ b/frontend/src/api/generated/private/models/chat.ts
@@ -4,6 +4,7 @@
* OpenAPI spec version: 0.0.0
*/
import type { ChatTypeEnum } from "./chatTypeEnum";
+import type { MessageSender } from "./messageSender";
export interface Chat {
readonly id: number;
@@ -23,4 +24,5 @@ export interface Chat {
readonly created_at: Date;
readonly updated_at: Date;
readonly unread_count: number;
+ readonly members_detail: readonly MessageSender[];
}
diff --git a/frontend/src/api/generated/private/models/index.ts b/frontend/src/api/generated/private/models/index.ts
index 6939f94..027d857 100644
--- a/frontend/src/api/generated/private/models/index.ts
+++ b/frontend/src/api/generated/private/models/index.ts
@@ -126,6 +126,7 @@ export * from "./paymentRead";
export * from "./platformCount";
export * from "./post";
export * from "./postContent";
+export * from "./postHub";
export * from "./postVote";
export * from "./product";
export * from "./productImage";
@@ -134,6 +135,7 @@ export * from "./productMiniForWishlist";
export * from "./qualityCount";
export * from "./reasonChoiceEnum";
export * from "./refund";
+export * from "./replyTo";
export * from "./reviewSerializerPublic";
export * from "./roleEnum";
export * from "./shippingMethodEnum";
diff --git a/frontend/src/api/generated/private/models/message.ts b/frontend/src/api/generated/private/models/message.ts
index d822c44..e97c425 100644
--- a/frontend/src/api/generated/private/models/message.ts
+++ b/frontend/src/api/generated/private/models/message.ts
@@ -6,19 +6,13 @@
import type { MessageFile } from "./messageFile";
import type { MessageReaction } from "./messageReaction";
import type { MessageSender } from "./messageSender";
-
-export interface ReplyToPreview {
- readonly id: number;
- content?: string;
- readonly sender: MessageSender;
-}
+import type { ReplyTo } from "./replyTo";
export interface Message {
readonly id: number;
readonly chat: number;
readonly sender: MessageSender;
- /** @nullable */
- readonly reply_to: ReplyToPreview | null;
+ readonly reply_to: ReplyTo;
content?: string;
readonly is_edited: boolean;
/** @nullable */
diff --git a/frontend/src/api/generated/private/models/patchedChat.ts b/frontend/src/api/generated/private/models/patchedChat.ts
index 0bf062e..614e834 100644
--- a/frontend/src/api/generated/private/models/patchedChat.ts
+++ b/frontend/src/api/generated/private/models/patchedChat.ts
@@ -4,6 +4,7 @@
* OpenAPI spec version: 0.0.0
*/
import type { ChatTypeEnum } from "./chatTypeEnum";
+import type { MessageSender } from "./messageSender";
export interface PatchedChat {
readonly id?: number;
@@ -23,4 +24,5 @@ export interface PatchedChat {
readonly created_at?: Date;
readonly updated_at?: Date;
readonly unread_count?: number;
+ readonly members_detail?: readonly MessageSender[];
}
diff --git a/frontend/src/api/generated/private/models/patchedMessage.ts b/frontend/src/api/generated/private/models/patchedMessage.ts
index bb75e9d..6bebc41 100644
--- a/frontend/src/api/generated/private/models/patchedMessage.ts
+++ b/frontend/src/api/generated/private/models/patchedMessage.ts
@@ -6,13 +6,13 @@
import type { MessageFile } from "./messageFile";
import type { MessageReaction } from "./messageReaction";
import type { MessageSender } from "./messageSender";
+import type { ReplyTo } from "./replyTo";
export interface PatchedMessage {
readonly id?: number;
readonly chat?: number;
readonly sender?: MessageSender;
- /** @nullable */
- readonly reply_to?: number | null;
+ readonly reply_to?: ReplyTo;
content?: string;
readonly is_edited?: boolean;
/** @nullable */
diff --git a/frontend/src/api/generated/private/models/patchedPost.ts b/frontend/src/api/generated/private/models/patchedPost.ts
index f7cade1..f3e6b13 100644
--- a/frontend/src/api/generated/private/models/patchedPost.ts
+++ b/frontend/src/api/generated/private/models/patchedPost.ts
@@ -5,6 +5,7 @@
*/
import type { AuthorMinimal } from "./authorMinimal";
import type { PostContent } from "./postContent";
+import type { PostHub } from "./postHub";
import type { Tags } from "./tags";
export interface PatchedPost {
@@ -16,6 +17,7 @@ export interface PatchedPost {
readonly author_detail?: AuthorMinimal;
/** @nullable */
hub?: number | null;
+ readonly hub_detail?: PostHub;
/** @nullable */
reply_to?: number | null;
readonly tags?: readonly Tags[];
diff --git a/frontend/src/api/generated/private/models/post.ts b/frontend/src/api/generated/private/models/post.ts
index 071edc2..aaf8e03 100644
--- a/frontend/src/api/generated/private/models/post.ts
+++ b/frontend/src/api/generated/private/models/post.ts
@@ -5,6 +5,7 @@
*/
import type { AuthorMinimal } from "./authorMinimal";
import type { PostContent } from "./postContent";
+import type { PostHub } from "./postHub";
import type { Tags } from "./tags";
export interface Post {
@@ -16,6 +17,7 @@ export interface Post {
readonly author_detail: AuthorMinimal;
/** @nullable */
hub?: number | null;
+ readonly hub_detail: PostHub;
/** @nullable */
reply_to?: number | null;
readonly tags: readonly Tags[];
diff --git a/frontend/src/api/generated/private/models/postHub.ts b/frontend/src/api/generated/private/models/postHub.ts
new file mode 100644
index 0000000..21c9adc
--- /dev/null
+++ b/frontend/src/api/generated/private/models/postHub.ts
@@ -0,0 +1,13 @@
+/**
+ * Generated by orval v8.8.0 🍺
+ * Do not edit manually.
+ * OpenAPI spec version: 0.0.0
+ */
+
+export interface PostHub {
+ readonly id: number;
+ /** @maxLength 255 */
+ name: string;
+ /** @nullable */
+ icon?: string | null;
+}
diff --git a/frontend/src/api/generated/private/models/replyTo.ts b/frontend/src/api/generated/private/models/replyTo.ts
new file mode 100644
index 0000000..2ddb48d
--- /dev/null
+++ b/frontend/src/api/generated/private/models/replyTo.ts
@@ -0,0 +1,15 @@
+/**
+ * Generated by orval v8.8.0 🍺
+ * Do not edit manually.
+ * OpenAPI spec version: 0.0.0
+ */
+import type { MessageFile } from "./messageFile";
+import type { MessageSender } from "./messageSender";
+
+export interface ReplyTo {
+ readonly id: number;
+ readonly content: string;
+ readonly sender: MessageSender;
+ readonly created_at: Date;
+ readonly media_files: readonly MessageFile[];
+}
diff --git a/frontend/src/api/generated/public/models/chat.ts b/frontend/src/api/generated/public/models/chat.ts
index 0f22475..4f4ca43 100644
--- a/frontend/src/api/generated/public/models/chat.ts
+++ b/frontend/src/api/generated/public/models/chat.ts
@@ -4,6 +4,7 @@
* OpenAPI spec version: 0.0.0
*/
import type { ChatTypeEnum } from "./chatTypeEnum";
+import type { MessageSender } from "./messageSender";
export interface Chat {
readonly id: number;
@@ -23,4 +24,5 @@ export interface Chat {
readonly created_at: Date;
readonly updated_at: Date;
readonly unread_count: number;
+ readonly members_detail: readonly MessageSender[];
}
diff --git a/frontend/src/api/generated/public/models/index.ts b/frontend/src/api/generated/public/models/index.ts
index 0378a13..80f288a 100644
--- a/frontend/src/api/generated/public/models/index.ts
+++ b/frontend/src/api/generated/public/models/index.ts
@@ -108,6 +108,7 @@ export * from "./paymentRead";
export * from "./platformCount";
export * from "./post";
export * from "./postContent";
+export * from "./postHub";
export * from "./postVote";
export * from "./product";
export * from "./productImage";
@@ -116,6 +117,7 @@ export * from "./productMiniForWishlist";
export * from "./qualityCount";
export * from "./reasonChoiceEnum";
export * from "./refund";
+export * from "./replyTo";
export * from "./reviewSerializerPublic";
export * from "./roleEnum";
export * from "./shippingMethodEnum";
diff --git a/frontend/src/api/generated/public/models/message.ts b/frontend/src/api/generated/public/models/message.ts
index 21ae00b..e97c425 100644
--- a/frontend/src/api/generated/public/models/message.ts
+++ b/frontend/src/api/generated/public/models/message.ts
@@ -6,13 +6,13 @@
import type { MessageFile } from "./messageFile";
import type { MessageReaction } from "./messageReaction";
import type { MessageSender } from "./messageSender";
+import type { ReplyTo } from "./replyTo";
export interface Message {
readonly id: number;
readonly chat: number;
readonly sender: MessageSender;
- /** @nullable */
- readonly reply_to: number | null;
+ readonly reply_to: ReplyTo;
content?: string;
readonly is_edited: boolean;
/** @nullable */
diff --git a/frontend/src/api/generated/public/models/patchedChat.ts b/frontend/src/api/generated/public/models/patchedChat.ts
index 0bf062e..614e834 100644
--- a/frontend/src/api/generated/public/models/patchedChat.ts
+++ b/frontend/src/api/generated/public/models/patchedChat.ts
@@ -4,6 +4,7 @@
* OpenAPI spec version: 0.0.0
*/
import type { ChatTypeEnum } from "./chatTypeEnum";
+import type { MessageSender } from "./messageSender";
export interface PatchedChat {
readonly id?: number;
@@ -23,4 +24,5 @@ export interface PatchedChat {
readonly created_at?: Date;
readonly updated_at?: Date;
readonly unread_count?: number;
+ readonly members_detail?: readonly MessageSender[];
}
diff --git a/frontend/src/api/generated/public/models/patchedMessage.ts b/frontend/src/api/generated/public/models/patchedMessage.ts
index bb75e9d..6bebc41 100644
--- a/frontend/src/api/generated/public/models/patchedMessage.ts
+++ b/frontend/src/api/generated/public/models/patchedMessage.ts
@@ -6,13 +6,13 @@
import type { MessageFile } from "./messageFile";
import type { MessageReaction } from "./messageReaction";
import type { MessageSender } from "./messageSender";
+import type { ReplyTo } from "./replyTo";
export interface PatchedMessage {
readonly id?: number;
readonly chat?: number;
readonly sender?: MessageSender;
- /** @nullable */
- readonly reply_to?: number | null;
+ readonly reply_to?: ReplyTo;
content?: string;
readonly is_edited?: boolean;
/** @nullable */
diff --git a/frontend/src/api/generated/public/models/patchedPost.ts b/frontend/src/api/generated/public/models/patchedPost.ts
index f7cade1..f3e6b13 100644
--- a/frontend/src/api/generated/public/models/patchedPost.ts
+++ b/frontend/src/api/generated/public/models/patchedPost.ts
@@ -5,6 +5,7 @@
*/
import type { AuthorMinimal } from "./authorMinimal";
import type { PostContent } from "./postContent";
+import type { PostHub } from "./postHub";
import type { Tags } from "./tags";
export interface PatchedPost {
@@ -16,6 +17,7 @@ export interface PatchedPost {
readonly author_detail?: AuthorMinimal;
/** @nullable */
hub?: number | null;
+ readonly hub_detail?: PostHub;
/** @nullable */
reply_to?: number | null;
readonly tags?: readonly Tags[];
diff --git a/frontend/src/api/generated/public/models/post.ts b/frontend/src/api/generated/public/models/post.ts
index 071edc2..aaf8e03 100644
--- a/frontend/src/api/generated/public/models/post.ts
+++ b/frontend/src/api/generated/public/models/post.ts
@@ -5,6 +5,7 @@
*/
import type { AuthorMinimal } from "./authorMinimal";
import type { PostContent } from "./postContent";
+import type { PostHub } from "./postHub";
import type { Tags } from "./tags";
export interface Post {
@@ -16,6 +17,7 @@ export interface Post {
readonly author_detail: AuthorMinimal;
/** @nullable */
hub?: number | null;
+ readonly hub_detail: PostHub;
/** @nullable */
reply_to?: number | null;
readonly tags: readonly Tags[];
diff --git a/frontend/src/api/generated/public/models/postHub.ts b/frontend/src/api/generated/public/models/postHub.ts
new file mode 100644
index 0000000..21c9adc
--- /dev/null
+++ b/frontend/src/api/generated/public/models/postHub.ts
@@ -0,0 +1,13 @@
+/**
+ * Generated by orval v8.8.0 🍺
+ * Do not edit manually.
+ * OpenAPI spec version: 0.0.0
+ */
+
+export interface PostHub {
+ readonly id: number;
+ /** @maxLength 255 */
+ name: string;
+ /** @nullable */
+ icon?: string | null;
+}
diff --git a/frontend/src/api/generated/public/models/replyTo.ts b/frontend/src/api/generated/public/models/replyTo.ts
new file mode 100644
index 0000000..2ddb48d
--- /dev/null
+++ b/frontend/src/api/generated/public/models/replyTo.ts
@@ -0,0 +1,15 @@
+/**
+ * Generated by orval v8.8.0 🍺
+ * Do not edit manually.
+ * OpenAPI spec version: 0.0.0
+ */
+import type { MessageFile } from "./messageFile";
+import type { MessageSender } from "./messageSender";
+
+export interface ReplyTo {
+ readonly id: number;
+ readonly content: string;
+ readonly sender: MessageSender;
+ readonly created_at: Date;
+ readonly media_files: readonly MessageFile[];
+}
diff --git a/frontend/src/api/social/hubFeed.ts b/frontend/src/api/social/hubFeed.ts
new file mode 100644
index 0000000..85624e4
--- /dev/null
+++ b/frontend/src/api/social/hubFeed.ts
@@ -0,0 +1,43 @@
+import { privateMutator } from "../privateClient";
+import type { Post } from "../generated/private/models/post";
+import type { CursorPaginated } from "./feed";
+
+export type HubSortOption = "newest" | "top";
+export type HubTimeOption = "1h" | "6h" | "day" | "week" | "month" | "year" | "all" | "custom";
+
+export interface HubPostsParams {
+ hub: number;
+ cursor?: string | null;
+ tag?: number | null;
+ sort?: HubSortOption;
+ time?: HubTimeOption;
+ start?: string;
+ end?: string;
+}
+
+export const apiSocialHubPostsCursor = (
+ params: HubPostsParams,
+ signal?: AbortSignal,
+) =>
+ privateMutator>({
+ url: `/api/social/posts/`,
+ method: "GET",
+ params: {
+ hub: params.hub,
+ cursor: params.cursor ?? undefined,
+ tag: params.tag ?? undefined,
+ sort: params.sort ?? undefined,
+ time: params.time ?? undefined,
+ start: params.start ?? undefined,
+ end: params.end ?? undefined,
+ },
+ signal,
+ });
+
+export const hubPostsQueryKey = (
+ hubId: number,
+ sort: HubSortOption,
+ time: HubTimeOption,
+ start?: string,
+ end?: string,
+) => ["social", "hubs", hubId, "posts", sort, time, start ?? null, end ?? null] as const;
diff --git a/frontend/src/components/home/ContactMe/ContactMeForm.tsx b/frontend/src/components/home/ContactMe/ContactMeForm.tsx
index ff4387b..f4ff139 100644
--- a/frontend/src/components/home/ContactMe/ContactMeForm.tsx
+++ b/frontend/src/components/home/ContactMe/ContactMeForm.tsx
@@ -1,35 +1,50 @@
import React, { useState, useRef } from "react"
import styles from "./contact-me.module.css"
import { LuMousePointerClick } from "react-icons/lu";
+import { publicApi } from "@/api/publicClient";
export default function ContactMeForm() {
- const [opened, setOpened] = useState(false)
- const [contentMoveUp, setContentMoveUp] = useState(false)
- const [openingBehind, setOpeningBehind] = useState(false)
- // const [success, setSuccess] = useState(false)
+ const [opened, setOpened] = useState(true)
+ const [contentMoveUp, setContentMoveUp] = useState(true)
+ const [openingBehind, setOpeningBehind] = useState(true)
+ const [email, setEmail] = useState("")
+ const [message, setMessage] = useState("")
+ const [loading, setLoading] = useState(false)
+ const [success, setSuccess] = useState(false)
+ const [error, setError] = useState("")
const openingRef = useRef(null)
- function handleSubmit() {
- // form submission logic here
+ async function handleSubmit(e: React.FormEvent) {
+ e.preventDefault()
+ setLoading(true)
+ setError("")
+ try {
+ await publicApi.post("/api/advertisement/contact-me/", { email, message, hp: "" })
+ setSuccess(true)
+ setEmail("")
+ setMessage("")
+ } catch {
+ setError("Nepodařilo se odeslat zprávu. Zkuste to prosím znovu.")
+ } finally {
+ setLoading(false)
+ }
}
const toggleOpen = () => {
if (!opened) {
setOpened(true)
setOpeningBehind(false)
- setContentMoveUp(false)
- // Wait for the rotate-opening animation to finish before moving content up
- // The actual moveUp will be handled in onTransitionEnd
+ setContentMoveUp(false)
} else {
setContentMoveUp(false)
setOpeningBehind(false)
- setTimeout(() => setOpened(false), 1000) // match transition duration
+ setTimeout(() => setOpened(false), 1000)
}
}
const handleTransitionEnd = (e: React.TransitionEvent) => {
if (opened && e.propertyName === "transform") {
- setContentMoveUp(true)
+ setContentMoveUp(true)
setTimeout(() => setOpeningBehind(true), 10)
}
if (!opened && e.propertyName === "transform") {
@@ -38,7 +53,6 @@ export default function ContactMeForm() {
}
return (
-
-
+
-
+ {success ? (
+
+
✓
+
Zpráva odeslána!
+
+ Ozvu se vám do 24 hodin.
+
+
setSuccess(false)}
+ style={{
+ marginTop: "0.5rem", background: "none", border: "1px solid var(--c-lines)",
+ color: "var(--c-text)", padding: "0.4em 1.2em", borderRadius: "0.5em",
+ cursor: "pointer", fontSize: "0.82rem",
+ }}
+ >
+ Nová zpráva
+
+
+ ) : (
+
+ setEmail(e.target.value)}
+ />
+ setMessage(e.target.value)}
+ />
+ {error && (
+ {error}
+ )}
+
+
+ )}
diff --git a/frontend/src/components/home/drone/DroneSection.tsx b/frontend/src/components/home/drone/DroneSection.tsx
new file mode 100644
index 0000000..22fc5ac
--- /dev/null
+++ b/frontend/src/components/home/drone/DroneSection.tsx
@@ -0,0 +1,213 @@
+import { motion } from "framer-motion";
+import { Link } from "react-router-dom";
+import { FaPlay } from "react-icons/fa";
+import { MdFlightTakeoff, MdRadio } from "react-icons/md";
+import { GiFilmProjector } from "react-icons/gi";
+
+const fadeLeft = {
+ initial: { opacity: 0, x: -50 },
+ whileInView: { opacity: 1, x: 0 },
+ transition: { duration: 0.7, ease: "easeOut" },
+ viewport: { once: true },
+};
+
+const fadeRight = {
+ initial: { opacity: 0, x: 50 },
+ whileInView: { opacity: 1, x: 0 },
+ transition: { duration: 0.7, ease: "easeOut" },
+ viewport: { once: true },
+};
+
+const stagger = {
+ initial: "hidden",
+ whileInView: "visible",
+ viewport: { once: true },
+ variants: {
+ hidden: {},
+ visible: { transition: { staggerChildren: 0.12 } },
+ },
+};
+
+const staggerItem = {
+ variants: {
+ hidden: { opacity: 0, y: 20 },
+ visible: { opacity: 1, y: 0, transition: { duration: 0.5, ease: "easeOut" } },
+ },
+};
+
+export default function DroneSection() {
+ return (
+
+
+
+ {/* Left: visual */}
+
+
+ {/* Rotating ring */}
+
+
+
+ {/* Drone icon */}
+
+
+
+ DJI · Sony · Gyroscope Stabilized
+
+
+ {/* Play button overlay hint */}
+
+ Showreel coming soon
+
+
+
+
+ {/* Right: text */}
+
+
+
+ Filmmaking & Aerial
+
+
+
+
+ Stunning Visuals —{" "}
+ Ground to Sky
+
+
+
+ Professional gyroscope-stabilized camera rigs deliver buttery-smooth footage at ground level. Pair that with DJI drone aerials and you get a complete cinematic package — from tracking shots through forests to sweeping panoramas at altitude.
+
+
+ {/* Feature list */}
+
+ {[
+ { icon: , label: "3-axis gyroscope stabilization", sub: "Cinema-grade smooth ground footage" },
+ { icon: , label: "Licensed drone operator", sub: "EU A1 · A2 · A3 certified" },
+ { icon: , label: "Omezený průkaz radiotelefonisty", sub: "Authorized for restricted & controlled airspaces" },
+ ].map(({ icon, label, sub }) => (
+
+ ))}
+
+
+ {/* Cert badges */}
+
+ {["EU A1", "EU A2", "EU A3", "Restricted Airspace"].map((cert) => (
+
+ {cert}
+
+ ))}
+
+
+
+ {
+ e.currentTarget.style.transform = "scale(1.04)";
+ e.currentTarget.style.boxShadow = "0 0 1.5rem color-mix(in hsl, var(--c-other), transparent 45%)";
+ }}
+ onMouseLeave={(e) => {
+ e.currentTarget.style.transform = "scale(1)";
+ e.currentTarget.style.boxShadow = "none";
+ }}
+ >
+ View Portfolio
+
+
+
+
+
+ {/* Mobile responsive */}
+
+
+ );
+}
diff --git a/frontend/src/components/home/hero/HeroSection.tsx b/frontend/src/components/home/hero/HeroSection.tsx
new file mode 100644
index 0000000..5b64dfc
--- /dev/null
+++ b/frontend/src/components/home/hero/HeroSection.tsx
@@ -0,0 +1,192 @@
+import { useEffect, useState } from "react";
+import { Link } from "react-router-dom";
+import { motion } from "framer-motion";
+import { FaChevronDown } from "react-icons/fa";
+
+const ROLES = ["Web Developer", "Drone Pilot", "Systems Architect", "Real-Time Engineer"];
+
+// Fixed seed so no hydration mismatch & no layout shift on re-render
+const PARTICLES = [
+ { id: 0, left: "12%", top: "18%", size: 5, delay: 0, dur: 5.2 },
+ { id: 1, left: "82%", top: "14%", size: 7, delay: 0.8, dur: 6.1 },
+ { id: 2, left: "25%", top: "72%", size: 4, delay: 1.5, dur: 4.8 },
+ { id: 3, left: "68%", top: "55%", size: 9, delay: 0.3, dur: 7.0 },
+ { id: 4, left: "90%", top: "40%", size: 5, delay: 2.1, dur: 5.5 },
+ { id: 5, left: "44%", top: "85%", size: 6, delay: 0.6, dur: 6.3 },
+ { id: 6, left: "8%", top: "60%", size: 4, delay: 1.9, dur: 4.5 },
+ { id: 7, left: "57%", top: "22%", size: 8, delay: 0.2, dur: 5.8 },
+ { id: 8, left: "75%", top: "78%", size: 5, delay: 1.2, dur: 6.6 },
+ { id: 9, left: "35%", top: "45%", size: 6, delay: 2.4, dur: 5.0 },
+];
+
+const stagger = {
+ container: {
+ animate: { transition: { staggerChildren: 0.18 } },
+ },
+ item: {
+ initial: { opacity: 0, y: 28 },
+ animate: { opacity: 1, y: 0, transition: { duration: 0.65, ease: "easeOut" } },
+ },
+};
+
+export default function HeroSection() {
+ const [roleIdx, setRoleIdx] = useState(0);
+ const [displayed, setDisplayed] = useState("");
+ const [typing, setTyping] = useState(true);
+
+ useEffect(() => {
+ const target = ROLES[roleIdx];
+ let i = typing ? 0 : target.length;
+
+ const timer = setInterval(() => {
+ if (typing) {
+ i++;
+ setDisplayed(target.slice(0, i));
+ if (i >= target.length) { clearInterval(timer); setTimeout(() => setTyping(false), 1800); }
+ } else {
+ i--;
+ setDisplayed(target.slice(0, i));
+ if (i <= 0) { clearInterval(timer); setRoleIdx(p => (p + 1) % ROLES.length); setTyping(true); }
+ }
+ }, typing ? 55 : 28);
+
+ return () => clearInterval(timer);
+ }, [roleIdx, typing]);
+
+ return (
+
+
+ {/* Video background — shows when file is present */}
+
+
+ {/* Animated mesh gradient fallback / overlay */}
+
+
+ {/* Subtle grid lines */}
+
+
+ {/* Floating particles */}
+ {PARTICLES.map(p => (
+
+ ))}
+
+ {/* Content */}
+
+ {/* Badge */}
+
+
+ ● Available for projects
+
+
+
+ {/* Name — gradient text via inline WebkitTextFillColor so no class conflict */}
+
+
+ Bruno Novotný
+
+
+
+ {/* Typewriter role */}
+
+ I am a{" "}
+ {displayed}
+ |
+
+
+ {/* Subtitle */}
+
+ From cinematic drone footage to complex real-time web platforms — I craft digital experiences that work at scale.
+
+
+ {/* CTAs */}
+
+ { e.currentTarget.style.transform = "scale(1.06)"; e.currentTarget.style.boxShadow = "0 0 2rem color-mix(in hsl, var(--c-other), transparent 35%)"; }}
+ onMouseLeave={e => { e.currentTarget.style.transform = "scale(1)"; e.currentTarget.style.boxShadow = "0 0 1.5rem color-mix(in hsl, var(--c-other), transparent 55%)"; }}
+ >
+ Explore My Work
+
+ { e.currentTarget.style.transform = "scale(1.06)"; e.currentTarget.style.borderColor = "var(--c-other)"; e.currentTarget.style.background = "color-mix(in hsl, var(--c-background-light), transparent 5%)"; }}
+ onMouseLeave={e => { e.currentTarget.style.transform = "scale(1)"; e.currentTarget.style.borderColor = "color-mix(in hsl, var(--c-lines), transparent 55%)"; e.currentTarget.style.background = "color-mix(in hsl, var(--c-background-light), transparent 25%)"; }}
+ >
+ Let's Talk
+
+
+
+
+ {/* Scroll indicator */}
+
+
+
+
+ );
+}
diff --git a/frontend/src/components/home/navbar/SiteNav.tsx b/frontend/src/components/home/navbar/SiteNav.tsx
index 5a77b5b..9f8184b 100644
--- a/frontend/src/components/home/navbar/SiteNav.tsx
+++ b/frontend/src/components/home/navbar/SiteNav.tsx
@@ -1,5 +1,5 @@
import { useEffect, useRef, useState } from "react";
-import { Link, useNavigate } from "react-router-dom";
+import { Link, useNavigate, useLocation } from "react-router-dom";
import {
FaSignOutAlt,
FaSignInAlt,
@@ -7,13 +7,10 @@ import {
FaChevronDown,
FaGlobe,
FaWrench,
- FaDownload,
- FaGitAlt,
- FaPlayCircle,
FaUsers,
- FaHandsHelping,
+ FaTimes,
} from "react-icons/fa";
-import { FaClapperboard, FaCubes } from "react-icons/fa6";
+import { FaClapperboard } from "react-icons/fa6";
import { useAuth } from "@/hooks/useAuth";
import Avatar from "@/components/ui/Avatar";
import styles from "./navbar.module.css";
@@ -21,33 +18,32 @@ import styles from "./navbar.module.css";
export default function Navbar() {
const { user, isAuthenticated, logout } = useAuth();
const navigate = useNavigate();
+ const location = useLocation();
const handleLogin = () => navigate("/social/login");
const handleLogout = async () => {
await logout();
navigate("/");
};
+
const [mobileMenu, setMobileMenu] = useState(false);
+ const [scrolled, setScrolled] = useState(false);
const navRef = useRef
(null);
- // close on outside click
useEffect(() => {
- function handleClick(e: MouseEvent) {
- if (!navRef.current) return;
- if (!navRef.current.contains(e.target as Node)) {
- // close only mobile menu here; dropdowns are CSS-controlled
- }
- }
- window.addEventListener("click", handleClick);
- return () => window.removeEventListener("click", handleClick);
+ const onScroll = () => setScrolled(window.scrollY > 40);
+ window.addEventListener("scroll", onScroll, { passive: true });
+ return () => window.removeEventListener("scroll", onScroll);
}, []);
- // close dropdowns on Escape
+ // Close mobile menu on route change
+ useEffect(() => {
+ setMobileMenu(false);
+ }, [location.pathname]);
+
useEffect(() => {
function onKey(e: KeyboardEvent) {
- if (e.key === "Escape") {
- setMobileMenu(false);
- }
+ if (e.key === "Escape") setMobileMenu(false);
}
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
@@ -55,34 +51,22 @@ export default function Navbar() {
return (
- {/* mobile burger */}
- setMobileMenu((p) => !p)}
- aria-expanded={mobileMenu}
- aria-label="Otevřít menu"
- >
-
-
-
- {/* left: brand */}
+ {/* Brand */}
vontor.cz
- {/* center links */}
+ {/* Center links */}
- {/* Services with submenu */}
+ {/* Služby dropdown */}
- Služby{" "}
-
+ Služby
-
Weby
@@ -96,73 +80,52 @@ export default function Navbar() {
- {/* Aplikace standalone submenu */}
-
-
- Aplikace{" "}
-
-
-
-
- Downloader
-
-
- Git
-
-
- Dema
-
-
-
-
- {/* Social entry — top-level link to the social area */}
-
+
Social
-
-
Kontakt
+
+ Kontakt
- {/* right: user area */}
+ {/* User area */}
{!isAuthenticated || !user ? (
-
+ Přihlásit
) : (
-
+
{user.username}
-
Profil
Feed
Zprávy
-
-
+
Odhlásit se
)}
+
+ {/* Mobile burger — right side */}
+ setMobileMenu((p) => !p)}
+ aria-expanded={mobileMenu}
+ aria-label={mobileMenu ? "Zavřít menu" : "Otevřít menu"}
+ >
+ {mobileMenu ? : }
+
);
}
diff --git a/frontend/src/components/home/navbar/navbar.module.css b/frontend/src/components/home/navbar/navbar.module.css
index c0212c9..aa736c6 100644
--- a/frontend/src/components/home/navbar/navbar.module.css
+++ b/frontend/src/components/home/navbar/navbar.module.css
@@ -1,376 +1,292 @@
+/* ── Navbar ── */
.navbar {
- width: 50%;
width: max-content;
- margin: 0;
- margin-left: auto;
- margin-right: auto;
- padding: 0 2em;
- background-color: var(--c-boxes);
+ max-width: calc(100% - 2rem);
+ margin: 0 auto;
+ padding: 0.6em 2em;
+ /* Glass pill */
+ background: color-mix(in hsl, var(--c-background-light), transparent 35%);
+ backdrop-filter: blur(20px) saturate(1.4);
+ -webkit-backdrop-filter: blur(20px) saturate(1.4);
+ border: 1px solid color-mix(in hsl, var(--c-lines), transparent 65%);
color: white;
- font-family: "Roboto Mono", monospace;
+ font-family: "Inter", ui-sans-serif, system-ui, sans-serif;
display: flex;
justify-content: space-between;
align-items: center;
position: sticky;
+ top: 1rem;
+ z-index: 100;
+ gap: 0.5em;
+ border-radius: 9999px;
+ --nav-margin-y: 0.75em;
+ transition: background 0.4s ease, box-shadow 0.4s ease, border-color 0.4s ease;
+}
+
+.scrolled {
+ background: color-mix(in hsl, var(--c-background-light), transparent 10%);
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
+ border-color: color-mix(in hsl, var(--c-lines), transparent 40%);
+}
+
+.mobileNavOpen {
+ border-radius: 1.5rem;
top: 0;
- z-index: 50;
- gap: 1em;
- border-bottom-left-radius: 2em;
- border-bottom-right-radius: 2em;
-
- --nav-margin-y: 1em;
- opacity: 0.95;
-
- transition: all 0.3s ease-in-out;
+ max-width: 100%;
+ width: 100%;
}
-.mobileNavOpen{
- border-radius: 0;
-}
-
-/* Brand */
+/* ── Brand ── */
.logo {
- padding-right: 1em;
- border-right: 0.2em solid var(--c-lines);
+ padding-right: 1.5em;
+ border-right: 1px solid color-mix(in hsl, var(--c-lines), transparent 55%);
+ flex-shrink: 0;
}
.logo a {
- font-size: 1.8em;
- font-weight: 700;
+ font-size: 1.5em;
+ font-weight: 800;
color: white;
text-decoration: none;
- transition: text-shadow 0.25s ease-in-out;
+ letter-spacing: -0.02em;
+ transition: color 0.25s ease, text-shadow 0.25s ease;
}
.logo a:hover {
- text-shadow: 0.25em 0.25em 0.2em var(--c-text);
+ color: var(--c-text);
+ text-shadow: 0 0 1rem color-mix(in hsl, var(--c-text), transparent 40%);
}
-/* Burger */
+/* ── Burger ── */
.burger {
display: none;
background: none;
- border: none;
- color: white;
- font-size: 1.6em;
+ border: 1px solid color-mix(in hsl, var(--c-lines), transparent 60%);
+ border-radius: 0.6rem;
+ color: var(--c-text);
+ font-size: 1.2em;
+ padding: 0.3em 0.5em;
cursor: pointer;
+ flex-shrink: 0;
+ transition: background 0.2s;
+}
+.burger:hover {
+ background: color-mix(in hsl, var(--c-boxes), transparent 60%);
}
-/* Links container */
+/* ── Links container ── */
.links {
display: flex;
- gap: 3em;
+ gap: 0.5em;
align-items: center;
- justify-content: space-around;
- width: -webkit-fill-available;
+ justify-content: center;
}
-/* Simple link */
+/* ── Simple link ── */
.linkSimple {
- color: var(--c-text);
+ color: color-mix(in hsl, var(--c-text), transparent 20%);
text-decoration: none;
- font-size: 1.05em;
- transition: transform 0.15s;
-
- display: flex;
- flex-direction: row;
+ font-size: 0.95em;
+ font-weight: 500;
+ display: inline-flex;
align-items: center;
-}
-
-
-/* TEXT SIZE UNIFICATION */
-.linkSimple,
-.user,
-.linkButton {
- font-size: 1.25em;
- color: white;
-}
-
-.dropdown a {
- font-size: 1.1em;
- color: var(--c-text);
-}
-
-
-
-.linkSimple:hover {
- transform: scale(1.08);
-}
-
-/* Link item with dropdown */
-.linkItem {
+ gap: 0.35em;
+ padding: 0.45em 0.9em;
+ border-radius: 9999px;
+ transition: color 0.2s ease, background 0.2s ease;
position: relative;
}
-/* Unified dropdown container */
+.linkSimple:hover {
+ color: white;
+ background: color-mix(in hsl, var(--c-boxes), transparent 70%);
+}
+
+/* ── Dropdown item wrapper ── */
.dropdownItem {
position: relative;
}
+/* ── Dropdown trigger button ── */
.linkButton {
background: none;
border: none;
+ color: color-mix(in hsl, var(--c-text), transparent 20%);
+ font-size: 0.95em;
+ font-weight: 500;
cursor: pointer;
- display: flex;
- flex-direction: row;
+ display: inline-flex;
align-items: center;
- gap: 0.4rem;
- margin: var(--nav-margin-y) auto;
- width: max-content;
+ gap: 0.35em;
+ padding: 0.45em 0.9em;
+ border-radius: 9999px;
+ transition: color 0.2s ease, background 0.2s ease;
+ white-space: nowrap;
}
.linkButton:hover {
- transform: scale(1.05);
+ color: white;
+ background: color-mix(in hsl, var(--c-boxes), transparent 70%);
+ transform: none;
}
-/* chevron icons */
.chev {
- margin-left: 0.25rem;
- font-size: 0.9rem;
+ font-size: 0.7em;
+ transition: transform 0.25s ease;
+}
+.dropdownItem:hover .chev,
+.dropdownItem:focus-within .chev {
+ transform: rotate(180deg);
}
-.chevSmall {
- margin-left: 0.25rem;
- font-size: 0.75rem;
-}
-
-/* dropdown */
+/* ── Dropdown panel ── */
.dropdown {
position: absolute;
- top: auto;
- left: 0;
- width: -moz-max-content;
+ top: calc(100% + 0.5rem);
+ left: 50%;
+ transform: translateX(-50%) translateY(-6px);
width: max-content;
- background-color: var(--c-background-light);
- /* border: 1px solid var(--c-text); */
- padding: 0.6rem;
- /* border-radius: 0.45rem; */
- border-bottom-left-radius: 1em;
- border-bottom-right-radius: 1em;
- display: none;
+ min-width: 10rem;
+ background: color-mix(in hsl, var(--c-background-light), transparent 10%);
+ backdrop-filter: blur(20px);
+ -webkit-backdrop-filter: blur(20px);
+ border: 1px solid color-mix(in hsl, var(--c-lines), transparent 60%);
+ padding: 0.5rem;
+ border-radius: 1rem;
+ display: flex;
flex-direction: column;
- gap: 0.35rem;
- box-shadow: 0px 20px 24px 6px rgba(0, 0, 0, 0.35);
- z-index: 49;
+ gap: 0.2rem;
+ box-shadow: 0 16px 40px rgba(0, 0, 0, 0.4);
+ z-index: 200;
+ /* Animated show */
+ opacity: 0;
+ pointer-events: none;
+ transition: opacity 0.2s ease, transform 0.2s ease;
}
-/* show dropdown on hover or keyboard focus within */
-.linkItem:hover .dropdown,
-.linkItem:focus-within .dropdown,
.dropdownItem:hover .dropdown,
.dropdownItem:focus-within .dropdown {
- display: flex;
+ opacity: 1;
+ pointer-events: auto;
+ transform: translateX(-50%) translateY(0);
}
-/* nested wrapper for submenu items */
-.nestedWrapper {
- display: flex;
- flex-direction: column;
-}
-
-/* nested toggle (button that opens nested submenu) */
-.nestedToggle {
- background: none;
- border: none;
- color: white !important;
- text-align: left;
- padding: 0;
- cursor: pointer;
- display: inline-flex;
- align-items: center;
- gap: 0.45rem;
- width: 100%;
-}
-
-.nestedToggle:hover {
- transform: scale(1.03);
-}
-
-/* Unified dropdown toggle */
-.dropdownToggle {
- background: none;
- border: none;
- color: white !important;
- text-align: left;
- padding: 0;
- cursor: pointer;
- display: inline-flex;
- align-items: center;
- gap: 0.45rem;
- width: 100%;
-}
-
-.dropdownToggle:hover {
- transform: scale(1.03);
-}
-
-/* nested submenu */
-.nested {
- margin-top: 0.25rem;
- margin-left: 1.1rem;
- display: none;
- /* hidden until hover/focus within */
- flex-direction: column;
- gap: 0.25rem;
-}
-
-/* show nested submenu on hover/focus within */
-.nestedWrapper:hover .nested,
-.nestedWrapper:focus-within .nested {
- display: flex;
-}
-
-/* Nested dropdown (dropdown inside dropdown) */
-.dropdown .dropdown {
- position: static;
- border: none;
- box-shadow: none;
- padding-left: 0.2rem;
- min-width: auto;
- margin-left: 1.1rem;
-}
-
-/* links inside dropdown / nested */
.dropdown a,
.dropdown button {
- color: white;
+ color: color-mix(in hsl, var(--c-text), transparent 15%);
text-decoration: none;
background: none;
border: none;
- padding: 0.35rem 0.25rem;
+ padding: 0.5rem 0.75rem;
+ border-radius: 0.6rem;
text-align: left;
cursor: pointer;
- transition: transform 0.12s;
-
+ font-size: 0.9em;
+ font-weight: 500;
display: inline-flex;
- flex-direction: row;
align-items: center;
+ gap: 0.45rem;
+ transition: background 0.15s ease, color 0.15s ease;
+ width: 100%;
}
.dropdown a:hover,
.dropdown button:hover {
- transform: scale(1.04);
-}
-
-/* small icons next to dropdown links */
-.iconSmall {
- margin-right: 0.45rem;
- font-size: 0.95rem;
- vertical-align: middle;
-}
-
-/* User area */
-.user {
- display: flex;
- align-items: center;
- gap: 0.6rem;
- height: -webkit-fill-available;
-}
-
-.loginBtn {
- width: max-content;
- background: none;
- border: none;
- border-radius: 0;
- padding: 1em;
+ background: color-mix(in hsl, var(--c-boxes), transparent 60%);
color: white;
- font-size: 0.98rem;
+ transform: none;
+}
+
+/* ── Icons ── */
+.iconSmall {
+ font-size: 0.9em;
+ flex-shrink: 0;
+}
+
+/* ── Login button ── */
+.loginBtn {
+ background: linear-gradient(135deg, var(--c-other), color-mix(in hsl, var(--c-other), var(--c-boxes) 40%));
+ border: none;
+ border-radius: 9999px;
+ padding: 0.45em 1.1em;
+ color: #031D44;
+ font-size: 0.9em;
+ font-weight: 700;
cursor: pointer;
display: inline-flex;
align-items: center;
- gap: 0.45rem;
-
-}
-.loginBtn svg {
- font-size: 1.5rem;
+ gap: 0.4em;
+ transition: opacity 0.2s ease, transform 0.15s ease;
}
.loginBtn:hover {
- background: var(--c-text);
- transform: scale(1.03);
-}
-
-/* user dropdown */
-.userWrapper {
- height: -webkit-fill-available;
- position: relative;
- display: flex;
- align-items: center;
-}
-
-.userWrapper .dropdown{
- position: absolute;
- top: 0;
- left: 0;
- margin-top: 3.5em;
- width: max-content;
- border-top-right-radius: 1em;
-}
-.userWrapper .dropdown a, button{
- font-size: 0.9em;
-}
-
-.userButton {
- display: flex;
- align-items: center;
- width: max-content;
- gap: 0.6rem;
- background: none;
- border: none;
- color: white;
- cursor: pointer;
- font-size: 1rem;
- flex-wrap: wrap;
- justify-content: space-between;
-}
-
-.userIcon {
- font-size: inherit;
+ opacity: 0.9;
+ transform: scale(1.04);
+ box-shadow: 0 0 1rem color-mix(in hsl, var(--c-other), transparent 50%);
}
+/* ── User avatar + username ── */
.avatar {
- width: 1.8rem;
- height: 1.8rem;
border-radius: 50%;
object-fit: cover;
+ flex-shrink: 0;
}
.username {
font-weight: 600;
+ font-size: 0.9em;
+ max-width: 8rem;
+ overflow: hidden;
text-overflow: ellipsis;
- max-width: max-content;
- text-overflow: ellipsis;
+ white-space: nowrap;
}
-/* logout button */
+/* ── Logout button ── */
.logoutBtn {
display: inline-flex;
align-items: center;
gap: 0.5rem;
background: none;
border: none;
- color: white;
+ color: color-mix(in hsl, #ff6b6b, var(--c-text) 30%);
cursor: pointer;
+ border-radius: 0.6rem;
+ padding: 0.5rem 0.75rem;
+ font-size: 0.9em;
+ font-weight: 500;
+ width: 100%;
+ transition: background 0.15s ease, color 0.15s ease;
}
-/* Responsive: mobile */
-@media (max-width: 1010px) {
+.logoutBtn:hover {
+ background: color-mix(in hsl, #ff6b6b, transparent 80%);
+ color: #ff9898;
+ transform: none;
+}
+
+/* ── Mobile ── */
+@media (max-width: 900px) {
.navbar {
width: 100%;
+ max-width: 100%;
+ top: 0;
+ border-radius: 0;
+ padding: 0.7em 1.2em;
+ border-left: none;
+ border-right: none;
+ border-top: none;
}
- .navbar .logo{
- margin: auto;
- text-align: center;
- border: none;
+ .logo {
+ border-right: none;
+ padding-right: 0;
+ flex: 1;
}
.burger {
- display: inline-block;
- }
-
- .burger svg {
- width: auto;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
}
.links {
@@ -378,82 +294,71 @@
left: 0;
right: 0;
top: 100%;
-
flex-direction: column;
- gap: 0.6rem;
- padding: 1rem 1.2rem;
- display: none;
- z-index: 40;
- border-top: 1px solid rgba(255, 255, 255, 0.03);
-
- border-bottom-left-radius: 2em;
- border-bottom-right-radius: 2em;
-
- transition: all 0.5s ease-in-out;
- max-height: 0;
-
- display: flex;
- overflow: hidden;
+ align-items: stretch;
+ gap: 0.3rem;
padding: 0;
+ background: color-mix(in hsl, var(--c-background-light), transparent 5%);
+ backdrop-filter: blur(20px);
+ -webkit-backdrop-filter: blur(20px);
+ border-bottom: 1px solid color-mix(in hsl, var(--c-lines), transparent 60%);
+ border-bottom-left-radius: 1.5rem;
+ border-bottom-right-radius: 1.5rem;
+ overflow: hidden;
+ max-height: 0;
opacity: 0;
-
+ transition: max-height 0.4s ease, opacity 0.3s ease, padding 0.3s ease;
}
+
.links.show {
- max-height: 100vh;
- padding: 1rem 1.2rem;
- background-color: var(--c-boxes);
+ max-height: 80vh;
opacity: 1;
+ padding: 0.75rem;
+ overflow-y: auto;
}
-
- .linkButton{
- background-color: var(--c-background-light);
+ .dropdownItem {
width: 100%;
- align-items: center;
- margin:auto;
-
- display: flex;
- align-items: center;
- justify-content: center;
- padding: 1em;
-
- transition: all 0.2s ease-in-out;
}
- .linkButton:hover{
- transform: none !important;
- border-bottom-left-radius: 0;
- border-bottom-right-radius: 0;
+ .linkButton {
+ width: 100%;
+ justify-content: space-between;
+ padding: 0.75em 1em;
+ border-radius: 0.75rem;
+ background: color-mix(in hsl, var(--c-background-light), transparent 40%);
+ color: var(--c-text);
}
- .linkSimple{
- margin: var(--nav-margin-y) auto;
+ .linkSimple {
+ width: 100%;
+ padding: 0.75em 1em;
+ border-radius: 0.75rem;
+ justify-content: flex-start;
}
-
+
.dropdown {
position: relative;
top: 0;
left: 0;
+ transform: none;
+ opacity: 1;
+ pointer-events: auto;
border: none;
box-shadow: none;
- padding-left: 0.2rem;
+ background: color-mix(in hsl, var(--c-background), transparent 20%);
+ border-radius: 0.75rem;
+ margin-top: 0.25rem;
+ }
+ .dropdownItem:hover .dropdown,
+ .dropdownItem:focus-within .dropdown {
+ transform: none;
+ }
+
+ .loginBtn {
width: 100%;
- align-items: center;
+ justify-content: center;
+ padding: 0.75em 1em;
}
- .dropdownItem{
- width: 100%;
- }
-
- .nested {
- margin-left: 0.6rem;
- }
-
- .dropdown .dropdown {
- margin-left: 0.6rem;
- }
-
- .userButton .username{
- display: none;
- }
-}
\ No newline at end of file
+}
diff --git a/frontend/src/components/home/projects/DemoModal.tsx b/frontend/src/components/home/projects/DemoModal.tsx
new file mode 100644
index 0000000..a4bc441
--- /dev/null
+++ b/frontend/src/components/home/projects/DemoModal.tsx
@@ -0,0 +1,183 @@
+import { useState, useEffect } from "react";
+import { createPortal } from "react-dom";
+import { motion, AnimatePresence } from "framer-motion";
+import { FaCopy, FaCheck, FaExternalLinkAlt, FaTimes } from "react-icons/fa";
+
+interface Props {
+ opened: boolean;
+ onClose: () => void;
+}
+
+function CredRow({ label, value }: { label: string; value: string }) {
+ const [copied, setCopied] = useState(false);
+
+ const copy = () => {
+ navigator.clipboard.writeText(value).then(() => {
+ setCopied(true);
+ setTimeout(() => setCopied(false), 2000);
+ });
+ };
+
+ return (
+
+
+
+ {label}
+
+
+ {value}
+
+
+
+ {copied ? : }
+
+
+ );
+}
+
+export default function DemoModal({ opened, onClose }: Props) {
+ useEffect(() => {
+ if (!opened) return;
+ const handler = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); };
+ window.addEventListener("keydown", handler);
+ return () => window.removeEventListener("keydown", handler);
+ }, [opened, onClose]);
+
+ useEffect(() => {
+ document.body.style.overflow = opened ? "hidden" : "";
+ return () => { document.body.style.overflow = ""; };
+ }, [opened]);
+
+ return createPortal(
+
+ {opened && (
+ <>
+ {/* Backdrop */}
+
+
+ {/* Panel */}
+
+ {/* Header */}
+
+ E-Commerce Demo Access
+ (e.currentTarget.style.color = "var(--c-text)")}
+ onMouseLeave={(e) => (e.currentTarget.style.color = "color-mix(in hsl, var(--c-text), transparent 40%)")}
+ >
+
+
+
+
+ {/* Body */}
+
+
+ >
+ )}
+ ,
+ document.body,
+ );
+}
diff --git a/frontend/src/components/home/projects/ProjectsSection.tsx b/frontend/src/components/home/projects/ProjectsSection.tsx
new file mode 100644
index 0000000..0970b8b
--- /dev/null
+++ b/frontend/src/components/home/projects/ProjectsSection.tsx
@@ -0,0 +1,386 @@
+import { useState } from "react";
+import { motion } from "framer-motion";
+import { Link } from "react-router-dom";
+import { FaUsers, FaDownload, FaShoppingCart, FaArrowRight } from "react-icons/fa";
+import {
+ SiDjango, SiReact, SiRedis, SiPython, SiStripe,
+} from "react-icons/si";
+import DemoModal from "./DemoModal";
+
+const containerVariants = {
+ hidden: {},
+ visible: { transition: { staggerChildren: 0.15 } },
+};
+
+const cardVariants = {
+ hidden: { opacity: 0, y: 50 },
+ visible: { opacity: 1, y: 0, transition: { duration: 0.6, ease: "easeOut" } },
+};
+
+function TechBadge({ icon, label, color }: { icon?: React.ReactNode; label: string; color?: string }) {
+ return (
+
+ {icon && {icon} }
+ {label}
+
+ );
+}
+
+export default function ProjectsSection() {
+ const [demoOpen, setDemoOpen] = useState(false);
+
+ return (
+
+
+ {/* Header */}
+
+
+ Live Projects
+
+
+ Things I've Shipped
+
+
+ Real applications running in production — not demos, not tutorials.
+
+
+
+ {/* Cards */}
+
+ {/* Social Network */}
+
+ {/* Glow accent top */}
+
+
+
+
+
+
+
+
Vontor Social
+ Social network
+
+
+
+
+ Full social network — posts, hubs (communities), reactions, real-time direct messaging, user profiles, and live notifications.
+
+
+
+ } label="Django" color="#09d3ac" />
+ } label="React" color="#61dafb" />
+ } label="Redis" color="#ff4438" />
+
+
+
+ { e.currentTarget.style.background = "var(--c-boxes)"; e.currentTarget.style.transform = "scale(1.03)"; }}
+ onMouseLeave={(e) => { e.currentTarget.style.background = "color-mix(in hsl, var(--c-boxes), transparent 55%)"; e.currentTarget.style.transform = "scale(1)"; }}
+ >
+ Open App
+
+
+
+ {/* Media Downloader */}
+
+
+
+
+
+
+
+
+
Media Downloader
+ yt-dlp powered
+
+
+
+
+ Download video and audio from 1000+ sites. Select quality, format, and subtitles. Async processing via Celery — no timeouts, even for long videos.
+
+
+
+ } label="yt-dlp" color="#3776ab" />
+
+ } label="Redis" color="#ff4438" />
+ } label="React" color="#61dafb" />
+
+
+ { e.currentTarget.style.background = "color-mix(in hsl, var(--c-other), transparent 50%)"; e.currentTarget.style.transform = "scale(1.03)"; }}
+ onMouseLeave={(e) => { e.currentTarget.style.background = "color-mix(in hsl, var(--c-other), transparent 75%)"; e.currentTarget.style.transform = "scale(1)"; }}
+ >
+ Try It
+
+
+
+ {/* E-Commerce */}
+
+
+
+
+
+
+
+
+
E-Commerce Store
+ Demo available
+
+
+
+
+ Full product catalog, order management, customer accounts, and payment processing. Separate admin dashboard for store management.
+
+
+
+ } label="Django" color="#09d3ac" />
+ } label="Stripe" color="#635bff" />
+
+
+
+
+ setDemoOpen(true)}
+ style={{
+ display: "inline-flex", alignItems: "center", gap: "0.5rem",
+ padding: "0.65em 1.4em", borderRadius: "9999px",
+ background: "color-mix(in hsl, #635bff, transparent 70%)",
+ border: "1px solid color-mix(in hsl, #635bff, transparent 45%)",
+ color: "var(--c-text)", fontWeight: 600, fontSize: "0.88rem",
+ cursor: "pointer", marginTop: "auto",
+ transition: "background 0.2s ease, transform 0.2s ease",
+ }}
+ onMouseEnter={(e) => { e.currentTarget.style.background = "color-mix(in hsl, #635bff, transparent 45%)"; e.currentTarget.style.transform = "scale(1.03)"; }}
+ onMouseLeave={(e) => { e.currentTarget.style.background = "color-mix(in hsl, #635bff, transparent 70%)"; e.currentTarget.style.transform = "scale(1)"; }}
+ >
+ View Demo
+
+
+
+
+ {/* Client websites divider */}
+
+
+
+ Client Websites
+
+
+
+
+
+ {/* davo1.cz — advertising firm, white+gold logo → dark bg */}
+
+
+
+ {/* Logo preview — dark so white+gold shows */}
+
+
+
+
+
+
+
+ {/* Epinger — fences & gates, black+orange logo → white bg */}
+
+
+
+ {/* Logo preview — white so black text shows */}
+
+
+
+
+
+
+
Epinger — Vstupní systémy
+ Construction & access systems
+
+
+
+ Business website for a company specializing in electronic entry systems, automated gates, and classic fencing — product catalog with inquiry forms.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ setDemoOpen(false)} />
+
+ );
+}
diff --git a/frontend/src/components/home/tech/TechMarquee.tsx b/frontend/src/components/home/tech/TechMarquee.tsx
new file mode 100644
index 0000000..27f7c75
--- /dev/null
+++ b/frontend/src/components/home/tech/TechMarquee.tsx
@@ -0,0 +1,141 @@
+import { motion } from "framer-motion";
+import {
+ SiDocker, SiNginx, SiPython, SiDjango, SiReact,
+ SiDebian, SiPostgresql, SiRedis, SiCelery,
+} from "react-icons/si";
+import { FaBrain } from "react-icons/fa";
+
+const TECHS = [
+ { icon: , label: "Docker", color: "#2496ed" },
+ { icon: , label: "Nginx", color: "#009900" },
+ { icon: , label: "Python", color: "#3776ab" },
+ { icon: , label: "Django", color: "#09d3ac" },
+ { icon: , label: "React", color: "#61dafb" },
+ { icon: , label: "Debian", color: "#d70a53" },
+ { icon: , label: "PostgreSQL", color: "#336791" },
+ { icon: , label: "Redis", color: "#ff4438" },
+ { icon: , label: "Celery", color: "#37b24d" },
+ { icon: null, label: "Gorse.io", color: "var(--c-other)" },
+ { icon: , label: "Ollama", color: "var(--c-lines)", experimental: true },
+];
+
+// Four copies so the loop is seamless at any viewport width
+const TRACK = [...TECHS, ...TECHS, ...TECHS, ...TECHS];
+
+export default function TechMarquee() {
+ return (
+
+ {/* Header */}
+
+
+
+ Technology Stack
+
+
+ Built With
+
+
+
+
+ {/* Marquee */}
+
+ {/* Fade edges */}
+
+
+
+ {/* Track wrapper — pause on hover */}
+
+ {/* Scrolling track — CSS animation, no Framer Motion here */}
+
+ {TRACK.map((tech, i) => (
+
+ {tech.experimental && (
+
+ exp
+
+ )}
+
+ {tech.icon ?? (
+
+ {tech.label.split(".")[0]}
+
+ )}
+
+
+ {tech.label}
+
+
+ ))}
+
+
+
+
+ {/* Inline styles for hover pause + item hover */}
+
+
+ );
+}
diff --git a/frontend/src/components/home/webdev/WebDevSection.tsx b/frontend/src/components/home/webdev/WebDevSection.tsx
new file mode 100644
index 0000000..4507a94
--- /dev/null
+++ b/frontend/src/components/home/webdev/WebDevSection.tsx
@@ -0,0 +1,161 @@
+import { motion } from "framer-motion";
+import { FaCode, FaCreditCard, FaBolt, FaServer, FaExchangeAlt, FaBrain } from "react-icons/fa";
+import { SiStripe } from "react-icons/si";
+
+const FEATURES = [
+ {
+ icon: ,
+ color: "#87a9da",
+ title: "Custom Web Development",
+ desc: "From polished brochure sites with custom graphics to complex multi-tenant web applications — built clean, fast, and maintainable.",
+ },
+ {
+ icon: ,
+ color: "#70A288",
+ title: "Payment Gateways",
+ desc: "Stripe for international clients — handles VAT/tax automatically across EU. ČSOB payment page for Czech clients — cheapest on the market, trusted by government sites.",
+ },
+ {
+ icon: ,
+ color: "#CAF0F8",
+ title: "Real-Time Applications",
+ desc: "Redis pub/sub + WebSockets power live features: chat, activity feeds, notifications, collaborative tools — no polling, true push.",
+ },
+ {
+ icon: ,
+ color: "#24719f",
+ title: "Server & Hosting",
+ desc: "I can acquire, configure, and manage a server on-premise at your site — or you can start on my managed hosting and scale later.",
+ },
+ {
+ icon: ,
+ color: "#70A288",
+ title: "Easy Migration",
+ desc: "Already have a Linux server with SSH? I can migrate the entire stack — app, DB, configs — in roughly a day with zero downtime.",
+ },
+ {
+ icon: ,
+ color: "#87a9da",
+ title: "Recommendation Engine",
+ desc: "Gorse.io integration delivers personalized content, product, or user recommendations — open-source, self-hosted, privacy-first.",
+ },
+];
+
+const containerVariants = {
+ hidden: {},
+ visible: { transition: { staggerChildren: 0.1 } },
+};
+
+const cardVariants = {
+ hidden: { opacity: 0, y: 40 },
+ visible: { opacity: 1, y: 0, transition: { duration: 0.55, ease: "easeOut" } },
+};
+
+export default function WebDevSection() {
+ return (
+
+
+ {/* Header */}
+
+
+ Web Development
+
+
+ What I Build
+
+
+ Full-stack solutions from concept to deployment — reliable, secure, and ready to scale.
+
+
+
+ {/* Grid */}
+
+ {FEATURES.map(({ icon, color, title, desc }) => (
+
+
+ {icon}
+
+
+
+ ))}
+
+
+ {/* Payment logos strip */}
+
+ Payment integrations:
+
+
+ Stripe
+ (international)
+
+
+ ČSOB
+ (Czech market)
+
+
+
+
+ );
+}
diff --git a/frontend/src/components/social/chat/ChatMediaGallery.tsx b/frontend/src/components/social/chat/ChatMediaGallery.tsx
index 7ea5371..ae9a36f 100644
--- a/frontend/src/components/social/chat/ChatMediaGallery.tsx
+++ b/frontend/src/components/social/chat/ChatMediaGallery.tsx
@@ -188,7 +188,8 @@ function ChatMediaItem({
src={url}
muted
playsInline
- preload="none"
+ preload="metadata"
+ onLoadedMetadata={(e) => { (e.target as HTMLVideoElement).currentTime = 0.001; }}
className="h-full w-full object-cover pointer-events-none"
/>
diff --git a/frontend/src/components/social/chat/Message.tsx b/frontend/src/components/social/chat/Message.tsx
index 6067746..d19dd0d 100644
--- a/frontend/src/components/social/chat/Message.tsx
+++ b/frontend/src/components/social/chat/Message.tsx
@@ -10,9 +10,12 @@ import IconButton from "@/components/ui/IconButton";
import { useAuth } from "@/hooks/useAuth";
import { canDeleteMessage } from "@/hooks/usePermissions";
import { formatRelative } from "@/utils/relativeTime";
+import { mediaUrl } from "@/utils/mediaUrl";
import { apiSocialMessagesDestroy } from "@/api/generated/private/chat/chat";
type MemberInfo = { id: number; username: string; avatar: string | null };
+type ReplyMedia = { id: number; file: string; media_type: string };
+type ExtendedReply = { id: number; content?: string; sender?: MemberInfo; created_at?: string; media_files?: ReplyMedia[] };
interface Props {
message: MessageModel;
@@ -90,100 +93,153 @@ export default function Message({ message, chat, onReply, onReact, highlighted,
- {/* Row 1 – reply preview */}
- {replyTo && (
-
-
e.key === "Enter" && handleJump()}
- className={[
- "w-fit select-none rounded-lg border-l-2 px-3 py-1.5 text-xs",
- replyDeleted ? "cursor-default" : "cursor-pointer",
- isOwn
- ? "border-white/30 bg-brand-lines/10 text-brand-text/50"
- : "border-brand-accent/40 bg-brand-lines/10 text-brand-text/50",
- ].join(" ")}
- >
- {replyDeleted ? (
- {t("chat.room.deletedMessage")}
- ) : (
-
- @{replyTo.sender?.username ?? "…"}
- {(replyTo.content ?? "").slice(0, 80) || "…"}
-
- )}
-
-
- {!isOwn && sender?.username && (
-
{sender.username}
- )}
-
- )}
-
- {/* Row 2 – avatar */}
-
+ {/* Avatar */}
+
- {/* Row 2 – bubble */}
+ {/* Message body column: reply → bubble → timestamp → reactions */}
- {hasText &&
{message.content}
}
- {hasMedia && (
-
-
+ {/* Reply preview */}
+ {replyTo && (() => {
+ const ext = replyTo as unknown as ExtendedReply;
+ const firstMedia = ext.media_files?.[0];
+ return (
+
e.key === "Enter" && handleJump()}
+ className={[
+ "max-w-[260px] select-none rounded-lg border-l-2 px-3 py-1.5 opacity-50 transition-opacity hover:opacity-90",
+ replyDeleted ? "cursor-default" : "cursor-pointer",
+ isOwn
+ ? "border-white/30 bg-brand-lines/10"
+ : "border-brand-accent/40 bg-brand-lines/10",
+ ].join(" ")}
+ >
+ {replyDeleted ? (
+
{t("chat.room.deletedMessage")}
+ ) : (
+ <>
+ {/* Sender + time */}
+
+
+ {replyTo.sender?.username ?? "…"}
+
+ {ext.created_at && (
+
+ · {formatRelative(ext.created_at)}
+
+ )}
+
+ {/* Content: thumbnail + text */}
+
+ {firstMedia?.media_type === "IMAGE" && (
+
+ )}
+ {firstMedia && firstMedia.media_type !== "IMAGE" && (
+
📎
+ )}
+
+ {replyTo.content
+ ? replyTo.content.slice(0, 80)
+ : firstMedia
+ ? firstMedia.media_type === "IMAGE" ? "Fotka" : "Soubor"
+ : "…"}
+
+
+ >
+ )}
+
+ );
+ })()}
+
+ {/* Bubble */}
+
+ {hasText &&
{message.content}
}
+ {hasMedia && (
+
+
+
+ )}
+
+
+ {/* Timestamp */}
+
+ {formatRelative(message.created_at)}
+ {message.is_edited && · {t("chat.room.edited")} }
+
+
+ {/* Reactions */}
+ {Object.keys(reactionGroups).length > 0 && (
+
+ {Object.entries(reactionGroups).map(([emoji, { count, mine }]) => (
+ onReact?.(message, ej)}
+ />
+ ))}
)}
- {/* Row 2 – ⋮ trigger: actions slide left, ⋮ morphs to ✕ */}
+ {/* ⋮ trigger — always sits directly next to the bubble */}
- {/* Emoji picker — floats above the trigger row */}
+ {/* Emoji picker */}
{pickerOpen && (
{ onReact?.(message, emoji); }}
onClose={() => setPickerOpen(false)}
/>
)}
- {/* Actions slide out to the left */}
+ {/* Actions slide out away from the bubble */}
@@ -206,7 +262,7 @@ export default function Message({ message, chat, onReply, onReact, highlighted,
)}
- {/* Toggle button — ⋮ rotates into ✕ */}
+ {/* Toggle ⋮ / ✕ */}
{ setMenuOpen((v) => !v); if (menuOpen) setPickerOpen(false); }}
className="relative flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-brand-text/60 hover:bg-brand-lines/20 hover:text-brand-text transition-colors"
@@ -222,40 +278,6 @@ export default function Message({ message, chat, onReply, onReact, highlighted,
/>
-
- {/* Row 3 – timestamp */}
-
- {formatRelative(message.created_at)}
- {message.is_edited && · {t("chat.room.edited")} }
-
-
- {/* Row 4 – reactions grouped by emoji */}
- {Object.keys(reactionGroups).length > 0 && (
-
- {Object.entries(reactionGroups).map(([emoji, { count, mine }]) => (
- onReact?.(message, ej)}
- />
- ))}
-
- )}
);
}
diff --git a/frontend/src/components/social/chat/MessageComposer.tsx b/frontend/src/components/social/chat/MessageComposer.tsx
index af57fc6..5d90b02 100644
--- a/frontend/src/components/social/chat/MessageComposer.tsx
+++ b/frontend/src/components/social/chat/MessageComposer.tsx
@@ -1,8 +1,9 @@
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
-import { FiSend, FiX, FiPaperclip, FiFile } from "react-icons/fi";
+import { FiSend, FiX, FiPaperclip, FiFile, FiCornerUpLeft } from "react-icons/fi";
import type { Message } from "@/api/generated/private/models/message";
import Button from "@/components/ui/Button";
+import { mediaUrl } from "@/utils/mediaUrl";
interface Props {
disabled?: boolean;
@@ -98,16 +99,46 @@ export default function MessageComposer({
return (
- {replyTo && (
-
-
- {t("chat.composer.replyTo", { snippet: (replyTo.content ?? "").slice(0, 60) })}
-
-
-
-
-
- )}
+ {replyTo && (() => {
+ const replySender = replyTo.sender as { id: number; username: string; avatar: string | null } | null;
+ const replyMedia = (replyTo.media_files ?? []) as { id: number; file: string; media_type: string }[];
+ const firstMedia = replyMedia[0];
+ return (
+
+
+ {firstMedia?.media_type === "IMAGE" && (
+
+ )}
+ {firstMedia && firstMedia.media_type !== "IMAGE" && (
+
📎
+ )}
+
+
+ @{replySender?.username ?? "…"}
+
+
+ {replyTo.content
+ ? replyTo.content.slice(0, 80)
+ : firstMedia
+ ? firstMedia.media_type === "IMAGE" ? "Fotka" : "Soubor"
+ : "…"}
+
+
+
+
+
+
+ );
+ })()}
{/* File previews */}
{files.length > 0 && (
diff --git a/frontend/src/components/social/hub/HubCard.tsx b/frontend/src/components/social/hub/HubCard.tsx
new file mode 100644
index 0000000..18c5062
--- /dev/null
+++ b/frontend/src/components/social/hub/HubCard.tsx
@@ -0,0 +1,39 @@
+import { Link } from "react-router-dom";
+import { FiUsers, FiCheck } from "react-icons/fi";
+import type { Hub } from "@/api/generated/private/models/hub";
+import Avatar from "@/components/ui/Avatar";
+
+interface Props {
+ hub: Hub;
+ isMember: boolean;
+}
+
+export default function HubCard({ hub, isMember }: Props) {
+ return (
+
+
+
+
+ {hub.name}
+ {isMember && (
+
+ Člen
+
+ )}
+
+ {hub.description && (
+
+ {hub.description}
+
+ )}
+
+
+ {hub.members?.length ?? 0} členů
+
+
+
+ );
+}
diff --git a/frontend/src/components/social/hub/HubHeader.tsx b/frontend/src/components/social/hub/HubHeader.tsx
new file mode 100644
index 0000000..57c165d
--- /dev/null
+++ b/frontend/src/components/social/hub/HubHeader.tsx
@@ -0,0 +1,83 @@
+import { Link } from "react-router-dom";
+import { FiSettings, FiUsers, FiGlobe, FiLock } from "react-icons/fi";
+import type { Hub } from "@/api/generated/private/models/hub";
+import Avatar from "@/components/ui/Avatar";
+import Button from "@/components/ui/Button";
+import { mediaUrl } from "@/utils/mediaUrl";
+
+interface Props {
+ hub: Hub;
+ isMember: boolean;
+ isOwner: boolean;
+ isModerator: boolean;
+ joining?: boolean;
+ onJoin: () => void;
+ onLeave: () => void;
+}
+
+export default function HubHeader({ hub, isMember, isOwner, isModerator, joining, onJoin, onLeave }: Props) {
+ const canManage = isOwner || isModerator;
+
+ return (
+
+ {/* Banner */}
+
+ {hub.banner && (
+
+ )}
+
+
+ {/* Avatar + info row */}
+
+ {/* Avatar overlapping banner */}
+
+
+ {/* Action buttons — top-right */}
+
+ {canManage && (
+
+ }>
+ Nastavení
+
+
+ )}
+ {isOwner ? null : isMember ? (
+
+ Odejít
+
+ ) : (
+
+ Připojit se
+
+ )}
+
+
+ {/* Name + meta — with left margin to clear the avatar */}
+
+
+ h/ {hub.name}
+
+
+
+
+ {hub.members?.length ?? 0} členů
+
+
+ {hub.is_public ? : }
+ {hub.is_public ? "Veřejný" : "Soukromý"}
+
+
+ {hub.description && (
+
{hub.description}
+ )}
+
+
+
+ );
+}
diff --git a/frontend/src/components/social/hub/Tags.tsx b/frontend/src/components/social/hub/Tags.tsx
index 0debb65..f968e61 100644
--- a/frontend/src/components/social/hub/Tags.tsx
+++ b/frontend/src/components/social/hub/Tags.tsx
@@ -1 +1,32 @@
-/* TAGS created inside hub */
\ No newline at end of file
+import type { Tags } from "@/api/generated/private/models/tags";
+
+interface Props {
+ tags: Tags[];
+ activeTag?: number;
+ onSelect: (id: number | undefined) => void;
+}
+
+export default function HubTags({ tags, activeTag, onSelect }: Props) {
+ return (
+
+ {tags.map((tag) => {
+ const active = activeTag === tag.id;
+ return (
+ onSelect(active ? undefined : tag.id)}
+ className="rounded-full border px-3 py-0.5 text-xs font-medium transition-colors"
+ style={{
+ borderColor: tag.color ?? undefined,
+ color: active ? "#fff" : (tag.color ?? undefined),
+ backgroundColor: active ? (tag.color ?? "#6366f1") : (tag.color ?? "#6366f1") + "22",
+ }}
+ >
+ {tag.name}
+
+ );
+ })}
+
+ );
+}
diff --git a/frontend/src/components/social/posts/MediaGallery.tsx b/frontend/src/components/social/posts/MediaGallery.tsx
index 0c359e8..30c082e 100644
--- a/frontend/src/components/social/posts/MediaGallery.tsx
+++ b/frontend/src/components/social/posts/MediaGallery.tsx
@@ -193,7 +193,7 @@ function MediaItem({
if (mime.startsWith("video/")) {
return (
{ e.stopPropagation(); onOpen(); }}>
-
+
{ (e.target as HTMLVideoElement).currentTime = 0.001; }} className="h-full w-full object-cover pointer-events-none" />
diff --git a/frontend/src/components/social/posts/Post.tsx b/frontend/src/components/social/posts/Post.tsx
index c793aec..5bce603 100644
--- a/frontend/src/components/social/posts/Post.tsx
+++ b/frontend/src/components/social/posts/Post.tsx
@@ -35,6 +35,7 @@ interface Props {
post: EnrichedPost;
variant?: "compact" | "default" | "focused";
clickable?: boolean;
+ hideHubBadge?: boolean;
onReplyClick?: () => void;
}
@@ -42,6 +43,7 @@ export default function Post({
post,
variant = "default",
clickable = true,
+ hideHubBadge = false,
onReplyClick,
}: Props) {
const { t } = useTranslation("social");
@@ -122,13 +124,13 @@ export default function Post({
>
@{displayName}
- {post.hub != null && (
+ {post.hub_detail && !hideHubBadge && (
e.stopPropagation()}
- className="rounded-full bg-brand-boxes/40 px-2 py-0.5 text-xs text-brand-text hover:bg-brand-boxes/60"
+ className="text-xs font-medium text-brand-accent/70 hover:text-brand-accent hover:underline"
>
- {t("hub.badge")}
+ h/{post.hub_detail.name}
)}
·
diff --git a/frontend/src/components/social/posts/PostComposer.tsx b/frontend/src/components/social/posts/PostComposer.tsx
index afbfaef..30a1356 100644
--- a/frontend/src/components/social/posts/PostComposer.tsx
+++ b/frontend/src/components/social/posts/PostComposer.tsx
@@ -1,19 +1,21 @@
import { useRef, useState } from "react";
import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
-import { FiSend, FiPaperclip, FiX, FiPlay, FiFile } from "react-icons/fi";
+import { FiSend, FiPaperclip, FiX, FiPlay, FiFile, FiTag, FiSearch } from "react-icons/fi";
import { useQueryClient } from "@tanstack/react-query";
import Textarea from "@/components/ui/Textarea";
import Button from "@/components/ui/Button";
import Spinner from "@/components/ui/Spinner";
import FormErrorBanner from "@/components/ui/FormErrorBanner";
import { applyServerErrors } from "@/utils/formErrors";
-import { apiSocialPostsCreate } from "@/api/generated/private/posts/posts";
+import { apiSocialPostsCreate, apiSocialPostsTagsAttachCreate } from "@/api/generated/private/posts/posts";
+import { useApiSocialHubsTagsList } from "@/api/generated/private/hubs/hubs";
import { privateApi } from "@/api/privateClient";
interface Props {
parentId?: number;
hubId?: number | null;
+ hubName?: string;
onPosted?: () => void;
}
@@ -21,14 +23,23 @@ interface ComposerForm {
content: string;
}
-export default function PostComposer({ parentId, hubId, onPosted }: Props) {
+export default function PostComposer({ parentId, hubId, hubName, onPosted }: Props) {
const { t } = useTranslation("social");
const queryClient = useQueryClient();
const [rootError, setRootError] = useState
();
const [files, setFiles] = useState([]);
const [previews, setPreviews] = useState([]);
+ const [selectedTags, setSelectedTags] = useState>(new Set());
+ const [tagModalOpen, setTagModalOpen] = useState(false);
+ const [tagSearch, setTagSearch] = useState("");
const fileInputRef = useRef(null);
+ const { data: tagsData } = useApiSocialHubsTagsList(
+ hubId ? { hub: hubId } : undefined,
+ { query: { enabled: !!hubId } },
+ );
+ const hubTags = tagsData?.results ?? [];
+
const form = useForm({
defaultValues: { content: "" },
});
@@ -51,6 +62,15 @@ export default function PostComposer({ parentId, hubId, onPosted }: Props) {
setPreviews((prev) => prev.filter((_, i) => i !== index));
}
+ function toggleTag(id: number) {
+ setSelectedTags((prev) => {
+ const next = new Set(prev);
+ if (next.has(id)) next.delete(id);
+ else next.add(id);
+ return next;
+ });
+ }
+
async function onSubmit(values: ComposerForm) {
setRootError(undefined);
clearErrors();
@@ -61,16 +81,20 @@ export default function PostComposer({ parentId, hubId, onPosted }: Props) {
reply_to: parentId ?? null,
} as Parameters[0]);
- // Upload each file to the new post
for (const file of files) {
const fd = new FormData();
fd.append("file", file);
await privateApi.post(`/api/social/posts/${created.id}/media/`, fd);
}
+ for (const tagId of selectedTags) {
+ await apiSocialPostsTagsAttachCreate(String(created.id), { tag_id: tagId });
+ }
+
previews.forEach((url) => URL.revokeObjectURL(url));
setFiles([]);
setPreviews([]);
+ setSelectedTags(new Set());
reset({ content: "" });
await queryClient.invalidateQueries({ queryKey: ["social", "posts"] });
onPosted?.();
@@ -84,112 +108,270 @@ export default function PostComposer({ parentId, hubId, onPosted }: Props) {
}
const hasContent = !!content?.trim() || files.length > 0;
+ const filteredTags = hubTags.filter((t) =>
+ t.name.toLowerCase().includes(tagSearch.toLowerCase()),
+ );
+ const selectedTagObjects = hubTags.filter((t) => selectedTags.has(t.id));
return (
-
-
+ <>
+
+ {hubName && (
+
+ Příspěvek v h/{hubName}
+
+ )}
- files.length > 0 || v.trim().length > 0 || true,
- })}
- />
+
- {/* File previews */}
- {previews.length > 0 && (
-
- {previews.map((src, i) => {
- const file = files[i];
- const isVideo = file?.type.startsWith("video/");
- const isImage = file?.type.startsWith("image/");
- return (
-
- {isVideo ? (
-
-
-
-
+
files.length > 0 || v.trim().length > 0 || true,
+ })}
+ />
+
+ {/* File previews */}
+ {previews.length > 0 && (
+
+ {previews.map((src, i) => {
+ const file = files[i];
+ const isVideo = file?.type.startsWith("video/");
+ const isImage = file?.type.startsWith("image/");
+ return (
+
+ {isVideo ? (
+
+
{ (e.target as HTMLVideoElement).currentTime = 0.001; }}
+ />
+
+
+
-
- ) : isImage ? (
-
- ) : (
-
-
-
- {file?.name}
-
-
- )}
+ ) : isImage ? (
+
+ ) : (
+
+
+
+ {file?.name}
+
+
+ )}
+
removeFile(i)}
+ className="absolute -right-1.5 -top-1.5 flex h-5 w-5 items-center justify-center rounded-full bg-brand-bg border border-brand-lines/30 text-brand-text/70 hover:text-brand-text shadow"
+ aria-label={t("post.compose.removeImage")}
+ >
+
+
+
+ );
+ })}
+
+ )}
+
+ {/* Selected tags display */}
+ {selectedTagObjects.length > 0 && (
+
+ {selectedTagObjects.map((tag) => (
+
+
+ {tag.name}
removeFile(i)}
- className="absolute -right-1.5 -top-1.5 flex h-5 w-5 items-center justify-center rounded-full bg-brand-bg border border-brand-lines/30 text-brand-text/70 hover:text-brand-text shadow"
- aria-label={t("post.compose.removeImage")}
+ onClick={() => toggleTag(tag.id)}
+ className="ml-0.5 opacity-60 hover:opacity-100"
>
-
+
+
+ ))}
+
+ )}
+
+
+
+ fileInputRef.current?.click()}
+ className="inline-flex items-center justify-center h-9 w-9 rounded-xl border border-brand-lines/25 bg-brand-bgLight/40 text-brand-text/60 hover:bg-brand-lines/10 hover:text-brand-accent transition-colors disabled:opacity-40"
+ aria-label={t("post.compose.attachImage")}
+ title={t("post.compose.attachImage")}
+ >
+
+
+
+
+ {/* Tag picker button — only when inside a hub with tags */}
+ {hubTags.length > 0 && (
+ { setTagSearch(""); setTagModalOpen(true); }}
+ className="inline-flex items-center gap-1.5 h-9 rounded-xl border border-brand-lines/25 bg-brand-bgLight/40 px-3 text-xs font-medium text-brand-text/60 hover:bg-brand-lines/10 hover:text-brand-accent transition-colors disabled:opacity-40"
+ >
+
+ Přidat tagy
+ {selectedTags.size > 0 && (
+
+ {selectedTags.size}
+
+ )}
+
+ )}
+
+
+
:
}
+ >
+ {isSubmitting
+ ? t("post.compose.submitting")
+ : t("post.compose.submit")}
+
+
+
+
+ {/* Tag picker modal */}
+ {tagModalOpen && (
+
setTagModalOpen(false)}
+ >
+
e.stopPropagation()}
+ >
+ {/* Header */}
+
+
Přidat tagy
+ setTagModalOpen(false)}
+ className="rounded-full p-1.5 text-brand-text/50 hover:bg-brand-lines/10 hover:text-brand-text transition-colors"
+ >
+
+
+
+
+ {/* Search */}
+
+
+
+ setTagSearch(e.target.value)}
+ placeholder="Hledat..."
+ className="flex-1 bg-transparent text-sm text-brand-text placeholder:text-brand-text/40 focus:outline-none"
+ />
+ {tagSearch && (
+ setTagSearch("")} className="text-brand-text/40 hover:text-brand-text/70">
+
+
+ )}
- );
- })}
+
+
+ {/* Tag list */}
+
+ {filteredTags.length === 0 && (
+
Žádné tagy.
+ )}
+ {filteredTags.map((tag) => {
+ const active = selectedTags.has(tag.id);
+ return (
+
+ toggleTag(tag.id)}
+ className="hidden"
+ />
+ {/* Custom checkbox */}
+
+ {active && (
+
+
+
+ )}
+
+ {/* Tag pill preview */}
+
+ {tag.name}
+
+ {tag.description && (
+ {tag.description}
+ )}
+
+ );
+ })}
+
+
+ {/* Footer */}
+
+ setTagModalOpen(false)}>
+ Zrušit
+
+ setTagModalOpen(false)}>
+ Přidat
+
+
+
)}
-
-
-
- {/* Image attach button */}
- fileInputRef.current?.click()}
- className="inline-flex items-center justify-center h-9 w-9 rounded-xl border border-brand-lines/25 bg-brand-bgLight/40 text-brand-text/60 hover:bg-brand-lines/10 hover:text-brand-accent transition-colors disabled:opacity-40"
- aria-label={t("post.compose.attachImage")}
- title={t("post.compose.attachImage")}
- >
-
-
-
-
-
-
:
}
- >
- {isSubmitting
- ? t("post.compose.submitting")
- : t("post.compose.submit")}
-
-
-
+ >
);
}
diff --git a/frontend/src/hooks/useInfiniteHubPosts.ts b/frontend/src/hooks/useInfiniteHubPosts.ts
new file mode 100644
index 0000000..5e817d0
--- /dev/null
+++ b/frontend/src/hooks/useInfiniteHubPosts.ts
@@ -0,0 +1,36 @@
+import { useInfiniteQuery } from "@tanstack/react-query";
+import { apiSocialHubPostsCursor, hubPostsQueryKey } from "@/api/social/hubFeed";
+import type { HubSortOption, HubTimeOption } from "@/api/social/hubFeed";
+
+function extractCursor(nextUrl: string | null): string | null {
+ if (!nextUrl) return null;
+ try {
+ const url = new URL(nextUrl, "http://placeholder");
+ return url.searchParams.get("cursor");
+ } catch {
+ return null;
+ }
+}
+
+interface Opts {
+ hubId: number;
+ sort?: HubSortOption;
+ time?: HubTimeOption;
+ start?: string;
+ end?: string;
+ enabled?: boolean;
+}
+
+export function useInfiniteHubPosts({ hubId, sort = "newest", time = "all", start, end, enabled = true }: Opts) {
+ const query = useInfiniteQuery({
+ queryKey: hubPostsQueryKey(hubId, sort, time, start, end),
+ enabled: enabled && Number.isFinite(hubId),
+ initialPageParam: null as string | null,
+ queryFn: ({ pageParam, signal }) =>
+ apiSocialHubPostsCursor({ hub: hubId, cursor: pageParam, sort, time, start, end }, signal),
+ getNextPageParam: (last) => extractCursor(last.next),
+ });
+
+ const posts = query.data?.pages.flatMap((p) => p.results) ?? [];
+ return { ...query, posts };
+}
diff --git a/frontend/src/hooks/usePermissions.ts b/frontend/src/hooks/usePermissions.ts
index 33306d4..d8babed 100644
--- a/frontend/src/hooks/usePermissions.ts
+++ b/frontend/src/hooks/usePermissions.ts
@@ -45,7 +45,7 @@ export function canDeleteMessage(
chat?: Chat | null,
): boolean {
if (!user) return false;
- if (message.sender != null && user.id === message.sender) return true;
+ if (message.sender?.id === user.id) return true;
if (isSuperuser(user)) return true;
if (chat?.owner === user.id) return true;
if (chat?.moderators?.includes(user.id)) return true;
diff --git a/frontend/src/index.css b/frontend/src/index.css
index 02085fd..c261408 100644
--- a/frontend/src/index.css
+++ b/frontend/src/index.css
@@ -247,3 +247,45 @@ li.custom-marker::before {
width: 1em;
margin-left: -1em;
}
+
+/* ── Landing page animations ── */
+@keyframes marquee {
+ from { transform: translateX(0); }
+ to { transform: translateX(-50%); }
+}
+
+@keyframes float {
+ 0%, 100% { transform: translateY(0px); }
+ 50% { transform: translateY(-18px); }
+}
+
+@keyframes spin-slow {
+ from { transform: rotate(0deg); }
+ to { transform: rotate(360deg); }
+}
+
+@keyframes pulse-glow {
+ 0%, 100% { box-shadow: 0 0 0.5rem color-mix(in hsl, var(--c-other), transparent 60%); }
+ 50% { box-shadow: 0 0 1.5rem color-mix(in hsl, var(--c-other), transparent 30%); }
+}
+
+@keyframes bounce-y {
+ 0%, 100% { transform: translateY(0); }
+ 50% { transform: translateY(8px); }
+}
+
+@keyframes gradient-shift {
+ 0% { background-position: 0% 50%; }
+ 50% { background-position: 100% 50%; }
+ 100% { background-position: 0% 50%; }
+}
+
+.animate-marquee { animation: marquee 30s linear infinite; }
+.animate-float { animation: float 5s ease-in-out infinite; }
+.animate-spin-slow { animation: spin-slow 20s linear infinite; }
+.animate-pulse-glow { animation: pulse-glow 2.5s ease-in-out infinite; }
+.animate-bounce-y { animation: bounce-y 1.4s ease-in-out infinite; }
+.animate-gradient {
+ background-size: 200% 200%;
+ animation: gradient-shift 4s ease infinite;
+}
diff --git a/frontend/src/layouts/social/Chat.tsx b/frontend/src/layouts/social/Chat.tsx
index 5950d1a..7109cae 100644
--- a/frontend/src/layouts/social/Chat.tsx
+++ b/frontend/src/layouts/social/Chat.tsx
@@ -23,7 +23,7 @@ export default function ChatLayout() {
setOpen((v) => !v)}
- className="absolute left-3 top-3 z-20 flex h-7 w-7 items-center justify-center rounded-full bg-brand-bgLight/60 text-brand-text/60 hover:bg-brand-lines/20 hover:text-brand-text transition-colors"
+ className="absolute left-3 top-4 z-20 flex h-7 w-7 items-center justify-center rounded-full bg-brand-bgLight/60 text-brand-text/60 hover:bg-brand-lines/20 hover:text-brand-text transition-colors"
aria-label={open ? "Hide sidebar" : "Show sidebar"}
>
{open ? : }
diff --git a/frontend/src/pages/contact/ContactPage.tsx b/frontend/src/pages/contact/ContactPage.tsx
index 93e61ed..b35caed 100644
--- a/frontend/src/pages/contact/ContactPage.tsx
+++ b/frontend/src/pages/contact/ContactPage.tsx
@@ -1,39 +1,154 @@
+import { motion } from "framer-motion";
+import { FaEnvelope, FaPhone, FaGithub, FaLinkedin, FaInstagram } from "react-icons/fa";
import ContactMeForm from "../../components/home/ContactMe/ContactMeForm";
-export default function ContactPage(){
+const fadeUp = (delay = 0) => ({
+ initial: { opacity: 0, y: 28 },
+ animate: { opacity: 1, y: 0 },
+ transition: { duration: 0.6, ease: "easeOut", delay },
+});
+
+const CONTACT_ITEMS = [
+ { icon: , label: "Email", value: "brunovontor@gmail.com", href: "mailto:brunovontor@gmail.com", color: "var(--c-other)" },
+ { icon: , label: "Phone", value: "+420 605 512 624", href: "tel:+420605512624", color: "var(--c-lines)" },
+];
+
+const SOCIAL_LINKS = [
+ { icon: , href: "https://github.com/Brunobrno", label: "GitHub", color: "#e6edf3" },
+ { icon: , href: "https://linkedin.com", label: "LinkedIn", color: "#0a66c2" },
+ { icon: ,href: "https://instagram.com", label: "Instagram",color: "#e1306c" },
+];
+
+export default function ContactPage() {
return (
-
-
-
Get in Touch
-
Reach out via the form or use the details below.
+
- {/* Desktop/tablet: envelope animation + slide-out form */}
-
-
-
+ {/* Background glow blobs */}
+
+
- {/* Mobile: simple card version without envelope */}
-
-
-
-
-
Your email
-
+
+
+ {/* Header */}
+
+
+ Contact
+
+
+ Let's{" "}
+
+ Work Together
+
+
+
+ Have a project in mind? Click the envelope to open a message, or reach out directly below.
+
+
+
+ {/* Two-column layout */}
+
+
+ {/* Left — info */}
+
+
+ {/* Available badge */}
+
+
+
+
+
Available for new projects
+
Usually responds within 24 hours
+
-
- Message
-
-
- Send
-
+
+
+ {/* Contact details */}
+ {CONTACT_ITEMS.map(({ icon, label, value, href, color }, i) => (
+
+
+ {icon}
+
+
+
+ ))}
+
+ {/* Social links */}
+
+ {SOCIAL_LINKS.map(({ icon, href, label, color }) => (
+ {
+ e.currentTarget.style.color = color;
+ e.currentTarget.style.borderColor = color;
+ e.currentTarget.style.transform = "translateY(-3px)";
+ e.currentTarget.style.boxShadow = `0 4px 16px color-mix(in hsl, ${color}, transparent 55%)`;
+ }}
+ onMouseLeave={e => {
+ e.currentTarget.style.color = "color-mix(in hsl, var(--c-text), transparent 30%)";
+ e.currentTarget.style.borderColor = "color-mix(in hsl, var(--c-lines), transparent 60%)";
+ e.currentTarget.style.transform = "translateY(0)";
+ e.currentTarget.style.boxShadow = "none";
+ }}
+ >
+ {icon}
+
+ ))}
+
-
-
+
+ {/* Responsive: stack on mobile */}
+
);
-}
\ No newline at end of file
+}
diff --git a/frontend/src/pages/home/home.tsx b/frontend/src/pages/home/home.tsx
index 338e3af..8be230a 100644
--- a/frontend/src/pages/home/home.tsx
+++ b/frontend/src/pages/home/home.tsx
@@ -1,13 +1,15 @@
import { useEffect } from "react";
-import PortfolioGrid from "./components/Services/Services";
-import TradingGraph from "./components/Services/TradingGraph";
-import DonationShop from "./components/donate/DonationShop";
-import ContactMeForm from "../../components/home/ContactMe/ContactMeForm";
-import Services from "../../components/home/services/Services";
-
+import { motion } from "framer-motion";
+import { FaEnvelope, FaPhone } from "react-icons/fa";
+import { Link } from "react-router-dom";
+import HeroSection from "@/components/home/hero/HeroSection";
+import DroneSection from "@/components/home/drone/DroneSection";
+import WebDevSection from "@/components/home/webdev/WebDevSection";
+import ProjectsSection from "@/components/home/projects/ProjectsSection";
+import TechMarquee from "@/components/home/tech/TechMarquee";
+import ContactMeForm from "@/components/home/ContactMe/ContactMeForm";
export default function Home() {
- // Optional: keep spark effect for fun
useEffect(() => {
const handleClick = (event: MouseEvent) => {
const spark = document.createElement("div");
@@ -33,15 +35,135 @@ export default function Home() {
return (
-
+
-
+
-
+
-
+
-
+
+
+
+ {/* Contact section */}
+
);
}
diff --git a/frontend/src/pages/social/HubPage.tsx b/frontend/src/pages/social/HubPage.tsx
index 8173abe..7f0cf2b 100644
--- a/frontend/src/pages/social/HubPage.tsx
+++ b/frontend/src/pages/social/HubPage.tsx
@@ -1,66 +1,210 @@
-import { Link, useParams } from "react-router-dom";
-import { useTranslation } from "react-i18next";
-import { FiArrowLeft } from "react-icons/fi";
-import { useApiSocialHubsRetrieve } from "@/api/generated/private/hubs/hubs";
-import { useApiSocialPostsList } from "@/api/generated/private/posts/posts";
+import { useState } from "react";
+import { useNavigate, useParams } from "react-router-dom";
+import { useQueryClient } from "@tanstack/react-query";
+import { FiClock, FiTrendingUp } from "react-icons/fi";
+import {
+ useApiSocialHubsRetrieve,
+ getApiSocialHubsRetrieveQueryKey,
+ useApiSocialHubsJoinCreate,
+ useApiSocialHubsLeaveCreate,
+} from "@/api/generated/private/hubs/hubs";
+import { useAuth } from "@/hooks/useAuth";
+import { useInfiniteHubPosts } from "@/hooks/useInfiniteHubPosts";
+import { useIntersectionLoader } from "@/hooks/useIntersectionLoader";
+import type { HubSortOption, HubTimeOption } from "@/api/social/hubFeed";
+import HubHeader from "@/components/social/hub/HubHeader";
import Post from "@/components/social/posts/Post";
-import Avatar from "@/components/ui/Avatar";
+import PostComposer from "@/components/social/posts/PostComposer";
import Spinner from "@/components/ui/Spinner";
import EmptyState from "@/components/ui/EmptyState";
+const TIME_OPTIONS: { value: HubTimeOption; label: string }[] = [
+ { value: "1h", label: "1 h" },
+ { value: "6h", label: "6 h" },
+ { value: "day", label: "Dnes" },
+ { value: "week", label: "Týden" },
+ { value: "month", label: "Měsíc" },
+ { value: "year", label: "Rok" },
+ { value: "all", label: "Vše" },
+ { value: "custom",label: "Vlastní" },
+];
+
export default function HubPage() {
- const { t } = useTranslation("social");
- const { id } = useParams<{ id: string }>();
- const hubId = Number(id);
+ const { name } = useParams<{ name: string }>();
+ const navigate = useNavigate();
+ const queryClient = useQueryClient();
+ const { user } = useAuth();
- const { data: hub, isLoading } = useApiSocialHubsRetrieve(String(hubId));
- const { data: postsData, isLoading: postsLoading } = useApiSocialPostsList(
- Number.isFinite(hubId) ? { hub: hubId } : undefined,
+ const [sort, setSort] = useState
("newest");
+ const [time, setTime] = useState("all");
+ const [customStart, setCustomStart] = useState("");
+ const [customEnd, setCustomEnd] = useState("");
+
+ const { data: hub, isLoading: hubLoading } = useApiSocialHubsRetrieve(name ?? "");
+
+ const isMember = !!(user && hub?.members?.includes(user.id));
+ const isOwner = !!(user && hub?.owner === user.id);
+ const isModerator = !!(user && hub?.moderators?.some((m) => m.user === user.id));
+
+ const joinMutation = useApiSocialHubsJoinCreate({
+ mutation: {
+ onSuccess: () => {
+ void queryClient.invalidateQueries({ queryKey: getApiSocialHubsRetrieveQueryKey(name!) });
+ },
+ },
+ });
+ const leaveMutation = useApiSocialHubsLeaveCreate({
+ mutation: {
+ onSuccess: () => {
+ void queryClient.invalidateQueries({ queryKey: getApiSocialHubsRetrieveQueryKey(name!) });
+ },
+ },
+ });
+
+ const {
+ posts,
+ isLoading: postsLoading,
+ hasNextPage,
+ isFetchingNextPage,
+ fetchNextPage,
+ refetch,
+ } = useInfiniteHubPosts({
+ hubId: hub?.id ?? 0,
+ sort,
+ time,
+ start: time === "custom" ? customStart : undefined,
+ end: time === "custom" ? customEnd : undefined,
+ enabled: !!hub?.id,
+ });
+
+ const sentinelRef = useIntersectionLoader(
+ () => { if (hasNextPage && !isFetchingNextPage) void fetchNextPage(); },
+ { enabled: hasNextPage && !postsLoading },
);
- const posts = postsData?.results ?? [];
- if (isLoading) {
- return (
-
-
-
- );
+ if (hubLoading) {
+ return
;
}
-
if (!hub) {
return ;
}
return (
-
+
joinMutation.mutate({ name: name! })}
+ onLeave={() => leaveMutation.mutate({ name: name! })}
+ />
- {postsLoading && (
-
-
-
+ {/* Composer — members and owner */}
+ {(isMember || isOwner) && (
+ <>
+
+ void refetch()} />
+ >
)}
- {!postsLoading && posts.length === 0 && }
+ {/* Sort + time filter bar */}
+
+ {/* Sort toggle */}
+
+ setSort("newest")}
+ className={[
+ "flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-xs font-medium transition-colors",
+ sort === "newest"
+ ? "bg-brand-accent text-white"
+ : "text-brand-text/60 hover:bg-brand-lines/10 hover:text-brand-text",
+ ].join(" ")}
+ >
+
+ Nejnovější
+
+ setSort("top")}
+ className={[
+ "flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-xs font-medium transition-colors",
+ sort === "top"
+ ? "bg-brand-accent text-white"
+ : "text-brand-text/60 hover:bg-brand-lines/10 hover:text-brand-text",
+ ].join(" ")}
+ >
+
+ Nejlepší
+
+
+
+ {/* Time filter — only for "top" */}
+ {sort === "top" && (
+
+ {TIME_OPTIONS.map((opt) => (
+ setTime(opt.value)}
+ className={[
+ "rounded-full px-2.5 py-0.5 text-xs font-medium transition-colors",
+ time === opt.value
+ ? "bg-brand-accent/20 text-brand-accent"
+ : "text-brand-text/50 hover:bg-brand-lines/10 hover:text-brand-text",
+ ].join(" ")}
+ >
+ {opt.label}
+
+ ))}
+
+ )}
+
+ {/* Custom date range */}
+ {sort === "top" && time === "custom" && (
+
+ setCustomStart(e.target.value)}
+ className="rounded-lg border border-brand-lines/25 bg-brand-bgLight/40 px-2 py-1 text-xs text-brand-text focus:border-brand-accent focus:outline-none"
+ />
+ –
+ setCustomEnd(e.target.value)}
+ className="rounded-lg border border-brand-lines/25 bg-brand-bgLight/40 px-2 py-1 text-xs text-brand-text focus:border-brand-accent focus:outline-none"
+ />
+
+ )}
+
+
+ {/* Posts */}
+ {postsLoading && (
+
+ )}
+
+ {!postsLoading && posts.length === 0 && (
+
+ )}
{posts.map((p) => (
-
+ navigate(`/social/post/${p.id}`)}
+ />
))}
+
+ {hasNextPage && (
+
+ {isFetchingNextPage ? : "Načíst více"}
+
+ )}
);
}
diff --git a/frontend/src/pages/social/HubsPage.tsx b/frontend/src/pages/social/HubsPage.tsx
index 1296824..2746da5 100644
--- a/frontend/src/pages/social/HubsPage.tsx
+++ b/frontend/src/pages/social/HubsPage.tsx
@@ -1,54 +1,105 @@
-import { Link } from "react-router-dom";
-import { useTranslation } from "react-i18next";
-import { FiUsers } from "react-icons/fi";
+import { useState } from "react";
+import { useNavigate } from "react-router-dom";
+import { FiSearch, FiPlus, FiUsers } from "react-icons/fi";
import { useApiSocialHubsList } from "@/api/generated/private/hubs/hubs";
-import Avatar from "@/components/ui/Avatar";
+import { useAuth } from "@/hooks/useAuth";
+import HubCard from "@/components/social/hub/HubCard";
import Spinner from "@/components/ui/Spinner";
import EmptyState from "@/components/ui/EmptyState";
+import Button from "@/components/ui/Button";
export default function HubsPage() {
- const { t } = useTranslation("social");
+ const navigate = useNavigate();
+ const { user } = useAuth();
+ const [search, setSearch] = useState("");
+
const { data, isLoading } = useApiSocialHubsList(undefined);
const hubs = data?.results ?? [];
+ const joined = hubs.filter((h) => h.members?.includes(user?.id as number));
+ const discover = hubs.filter((h) => !h.members?.includes(user?.id as number));
+
+ const q = search.trim().toLowerCase();
+ const filteredDiscover = q
+ ? discover.filter(
+ (h) =>
+ h.name.toLowerCase().includes(q) ||
+ (h.description ?? "").toLowerCase().includes(q),
+ )
+ : discover;
+
return (