From ad1f6a90b6cf4f991cc36a74185017b370bff86b Mon Sep 17 00:00:00 2001 From: Brunobrno Date: Sun, 7 Jun 2026 12:19:40 +0200 Subject: [PATCH] Use hub 'name' in routes & add top-post sorting Switch hub endpoints to use the hub `name` slug and update frontend routes/clients accordingly. Backend: HubViewSet now uses lookup_field='name'; PostViewSet list supports `sort=top` with vote_score annotation and time windows/custom ranges, and a new TopPostsCursorPagination was added. Frontend: routes changed from `/hub/:id` to `/h/:name`, the generated hubs API was updated from id->name, and the hub feed client accepts `sort`, `time`, `start`, and `end` params (query key updated). Also adds new homepage UI components (HeroSection, DroneSection) and navbar improvements (scroll state, auto-close mobile menu on route changes, and small icon/class tweaks). --- .claude/settings.local.json | 7 +- backend/social/hubs/views.py | 1 + backend/social/posts/views.py | 53 +- backend/vontor_cz/pagination.py | 9 + frontend/src/App.tsx | 6 +- .../src/api/generated/private/hubs/hubs.ts | 173 +++--- frontend/src/api/social/hubFeed.ts | 26 +- .../components/home/drone/DroneSection.tsx | 213 +++++++ .../src/components/home/hero/HeroSection.tsx | 196 +++++++ .../src/components/home/navbar/SiteNav.tsx | 113 ++-- .../components/home/navbar/navbar.module.css | 537 +++++++----------- .../components/home/projects/DemoModal.tsx | 182 ++++++ .../home/projects/ProjectsSection.tsx | 238 ++++++++ .../src/components/home/tech/TechMarquee.tsx | 136 +++++ .../components/home/webdev/WebDevSection.tsx | 161 ++++++ .../src/components/social/hub/HubCard.tsx | 2 +- .../src/components/social/hub/HubHeader.tsx | 2 +- frontend/src/components/social/hub/Tags.tsx | 28 +- frontend/src/components/social/posts/Post.tsx | 2 +- .../components/social/posts/PostComposer.tsx | 9 +- frontend/src/hooks/useInfiniteHubPosts.ts | 12 +- frontend/src/index.css | 42 ++ frontend/src/layouts/social/Chat.tsx | 2 +- frontend/src/pages/home/home.tsx | 24 +- frontend/src/pages/social/HubPage.tsx | 128 ++++- frontend/src/pages/social/HubsPage.tsx | 2 +- .../src/pages/social/chat/ChatRoomPage.tsx | 2 +- frontend/src/pages/social/hub/Create.tsx | 2 +- frontend/src/pages/social/hub/Settings.tsx | 29 +- 29 files changed, 1778 insertions(+), 559 deletions(-) create mode 100644 frontend/src/components/home/drone/DroneSection.tsx create mode 100644 frontend/src/components/home/hero/HeroSection.tsx create mode 100644 frontend/src/components/home/projects/DemoModal.tsx create mode 100644 frontend/src/components/home/projects/ProjectsSection.tsx create mode 100644 frontend/src/components/home/tech/TechMarquee.tsx create mode 100644 frontend/src/components/home/webdev/WebDevSection.tsx diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 22824d1..c24979e 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -10,7 +10,12 @@ "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 -v \"^$\")" + "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\\);\")" ] } } diff --git a/backend/social/hubs/views.py b/backend/social/hubs/views.py index 00f9f4b..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'] 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/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 4ebbc2a..d65995f 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -65,9 +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/social/hubFeed.ts b/frontend/src/api/social/hubFeed.ts index 59e704c..85624e4 100644 --- a/frontend/src/api/social/hubFeed.ts +++ b/frontend/src/api/social/hubFeed.ts @@ -2,10 +2,17 @@ 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 = ( @@ -15,9 +22,22 @@ export const apiSocialHubPostsCursor = ( privateMutator>({ url: `/api/social/posts/`, method: "GET", - params: { hub: params.hub, cursor: params.cursor ?? undefined, tag: params.tag ?? undefined }, + 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, tag?: number) => - ["social", "hubs", hubId, "posts", tag ?? null] as const; +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/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 }) => ( +
+
+ {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..34f5f5c --- /dev/null +++ b/frontend/src/components/home/hero/HeroSection.tsx @@ -0,0 +1,196 @@ +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"]; + +const PARTICLES = Array.from({ length: 10 }, (_, i) => ({ + id: i, + left: `${10 + Math.random() * 80}%`, + top: `${10 + Math.random() * 75}%`, + size: `${4 + Math.random() * 8}px`, + delay: `${Math.random() * 4}s`, + duration: `${4 + Math.random() * 4}s`, +})); + +const fade = (delay = 0) => ({ + initial: { opacity: 0, y: 30 }, + animate: { opacity: 1, y: 0 }, + transition: { duration: 0.7, ease: "easeOut", delay }, +}); + +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 speed = typing ? 55 : 30; + + 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((prev) => (prev + 1) % ROLES.length); + setTyping(true); + } + } + }, speed); + + return () => clearInterval(timer); + }, [roleIdx, typing]); + + return ( +
+ {/* Video background */} +
+ ); +} 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 ( ); } 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..415c964 --- /dev/null +++ b/frontend/src/components/home/projects/DemoModal.tsx @@ -0,0 +1,182 @@ +import { useState, useEffect } from "react"; +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} + +
+ +
+ ); +} + +export default function DemoModal({ opened, onClose }: Props) { + // Close on Escape + useEffect(() => { + if (!opened) return; + const handler = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); }; + window.addEventListener("keydown", handler); + return () => window.removeEventListener("keydown", handler); + }, [opened, onClose]); + + // Prevent body scroll when open + useEffect(() => { + document.body.style.overflow = opened ? "hidden" : ""; + return () => { document.body.style.overflow = ""; }; + }, [opened]); + + return ( + + {opened && ( + <> + {/* Backdrop */} + + + {/* Panel */} + + {/* Header */} +
+ E-Commerce Demo Access + +
+ + {/* 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..5734ad3 --- /dev/null +++ b/frontend/src/components/home/projects/ProjectsSection.tsx @@ -0,0 +1,238 @@ +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(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..9e682e7 --- /dev/null +++ b/frontend/src/components/home/tech/TechMarquee.tsx @@ -0,0 +1,136 @@ +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: "#a80030" }, + { 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 }, +]; + +function TechItem({ icon, label, color, experimental }: { icon: React.ReactNode; label: string; color: string; experimental?: boolean }) { + return ( + + {experimental && ( + + exp + + )} +
+ {icon ?? {label.split(".")[0]}} +
+ + {label} + +
+ ); +} + +export default function TechMarquee() { + const doubled = [...TECHS, ...TECHS]; + + return ( +
+
+ + + Technology Stack + +

+ Built With +

+
+
+ + {/* Fade edges */} +
+
+
+ + {/* Marquee track */} +
{ + const el = e.currentTarget.firstElementChild as HTMLElement; + if (el) el.style.animationPlayState = "paused"; + }} + onMouseLeave={(e) => { + const el = e.currentTarget.firstElementChild as HTMLElement; + if (el) el.style.animationPlayState = "running"; + }} + > +
+ {doubled.map((tech, i) => ( + + ))} +
+
+
+
+ ); +} 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} +
+
+

{title}

+

{desc}

+
+
+ ))} +
+ + {/* Payment logos strip */} + + Payment integrations: +
+ + Stripe + (international) +
+
+ ČSOB + (Czech market) +
+
+
+
+ ); +} diff --git a/frontend/src/components/social/hub/HubCard.tsx b/frontend/src/components/social/hub/HubCard.tsx index b132c5e..18c5062 100644 --- a/frontend/src/components/social/hub/HubCard.tsx +++ b/frontend/src/components/social/hub/HubCard.tsx @@ -11,7 +11,7 @@ interface Props { export default function HubCard({ hub, isMember }: Props) { return ( diff --git a/frontend/src/components/social/hub/HubHeader.tsx b/frontend/src/components/social/hub/HubHeader.tsx index 137a635..57c165d 100644 --- a/frontend/src/components/social/hub/HubHeader.tsx +++ b/frontend/src/components/social/hub/HubHeader.tsx @@ -41,7 +41,7 @@ export default function HubHeader({ hub, isMember, isOwner, isModerator, joining {/* Action buttons — top-right */}
{canManage && ( - + diff --git a/frontend/src/components/social/hub/Tags.tsx b/frontend/src/components/social/hub/Tags.tsx index aee9860..f968e61 100644 --- a/frontend/src/components/social/hub/Tags.tsx +++ b/frontend/src/components/social/hub/Tags.tsx @@ -1,34 +1,28 @@ -import type { Tags as HubTag } from "@/api/generated/private/models/tags"; +import type { Tags } from "@/api/generated/private/models/tags"; interface Props { - tags: HubTag[]; + tags: Tags[]; activeTag?: number; onSelect: (id: number | undefined) => void; } export default function HubTags({ tags, activeTag, onSelect }: Props) { - if (tags.length === 0) return null; - return ( -
+
{tags.map((tag) => { - const isActive = activeTag === tag.id; + const active = activeTag === tag.id; return ( ); diff --git a/frontend/src/components/social/posts/Post.tsx b/frontend/src/components/social/posts/Post.tsx index 450b6db..5bce603 100644 --- a/frontend/src/components/social/posts/Post.tsx +++ b/frontend/src/components/social/posts/Post.tsx @@ -126,7 +126,7 @@ export default function Post({ {post.hub_detail && !hideHubBadge && ( e.stopPropagation()} className="text-xs font-medium text-brand-accent/70 hover:text-brand-accent hover:underline" > diff --git a/frontend/src/components/social/posts/PostComposer.tsx b/frontend/src/components/social/posts/PostComposer.tsx index b206ace..30a1356 100644 --- a/frontend/src/components/social/posts/PostComposer.tsx +++ b/frontend/src/components/social/posts/PostComposer.tsx @@ -15,6 +15,7 @@ import { privateApi } from "@/api/privateClient"; interface Props { parentId?: number; hubId?: number | null; + hubName?: string; onPosted?: () => void; } @@ -22,7 +23,7 @@ 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(); @@ -119,6 +120,12 @@ export default function PostComposer({ parentId, hubId, onPosted }: Props) { className="border-b border-brand-lines/10 px-4 py-3" noValidate > + {hubName && ( +

+ Příspěvek v h/{hubName} +

+ )} +