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).
This commit is contained in:
2026-06-07 12:19:40 +02:00
parent cb23abeb5f
commit ad1f6a90b6
29 changed files with 1778 additions and 559 deletions

View File

@@ -10,7 +10,12 @@
"Bash(python -c ' *)", "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)", "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(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\\);\")"
] ]
} }
} }

View File

@@ -58,6 +58,7 @@ from .serializers import HubPermissionSerializer, HubSerializer, TagsSerializer,
class HubViewSet(viewsets.ModelViewSet): class HubViewSet(viewsets.ModelViewSet):
serializer_class = HubSerializer serializer_class = HubSerializer
permission_classes = [CanEditHub] permission_classes = [CanEditHub]
lookup_field = 'name'
filterset_fields = ['is_public', 'owner'] filterset_fields = ['is_public', 'owner']
search_fields = ['name', 'description'] search_fields = ['name', 'description']
ordering_fields = ['name'] ordering_fields = ['name']

View File

@@ -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 import status, viewsets
from rest_framework.decorators import action from rest_framework.decorators import action
from rest_framework.exceptions import PermissionDenied, ValidationError 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 rest_framework.permissions import IsAuthenticated
from social.hubs.models import Tags 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 .models import Post, PostContent, PostVote, PostSave
from .permissions import CanDeletePost, IsPostAuthorOnly from .permissions import CanDeletePost, IsPostAuthorOnly
from .serializers import PostSerializer, PostContentSerializer, PostVoteSerializer, TagAttachSerializer from .serializers import PostSerializer, PostContentSerializer, PostVoteSerializer, TagAttachSerializer
@@ -78,6 +82,51 @@ class PostViewSet(viewsets.ModelViewSet):
def perform_create(self, serializer): def perform_create(self, serializer):
serializer.save(author=self.request.user) 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 # Media upload action
# ------------------------------------------------------------------ # ------------------------------------------------------------------

View File

@@ -18,6 +18,15 @@ class CreatedCursorPagination(CursorPagination):
max_page_size = 100 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): class CreatedAscCursorPagination(CursorPagination):
"""Cursor pagination ordered by `created_at` (oldest first). """Cursor pagination ordered by `created_at` (oldest first).

View File

@@ -65,9 +65,9 @@ export default function App() {
<Route path="feed" element={<FeedPage />} /> <Route path="feed" element={<FeedPage />} />
<Route path="post/:id" element={<PostPage />} /> <Route path="post/:id" element={<PostPage />} />
<Route path="hubs" element={<HubsPage />} /> <Route path="hubs" element={<HubsPage />} />
<Route path="hub/create" element={<HubCreatePage />} /> <Route path="h/create" element={<HubCreatePage />} />
<Route path="hub/:id" element={<HubPage />} /> <Route path="h/:name" element={<HubPage />} />
<Route path="hub/:id/settings" element={<HubSettingsPage />} /> <Route path="h/:name/settings" element={<HubSettingsPage />} />
<Route path="profile" element={<ProfilePage />} /> <Route path="profile" element={<ProfilePage />} />
<Route path="profile/:username" element={<UserProfilePage />} /> <Route path="profile/:username" element={<UserProfilePage />} />
<Route path="saved" element={<SavedPage />} /> <Route path="saved" element={<SavedPage />} />

View File

@@ -313,23 +313,23 @@ export const useApiSocialHubsCreate = <TError = unknown, TContext = unknown>(
/** /**
* @summary Retrieve a hub * @summary Retrieve a hub
*/ */
export const apiSocialHubsRetrieve = (id: string, signal?: AbortSignal) => { export const apiSocialHubsRetrieve = (name: string, signal?: AbortSignal) => {
return privateMutator<Hub>({ return privateMutator<Hub>({
url: `/api/social/hubs/${id}/`, url: `/api/social/hubs/${name}/`,
method: "GET", method: "GET",
signal, signal,
}); });
}; };
export const getApiSocialHubsRetrieveQueryKey = (id: string) => { export const getApiSocialHubsRetrieveQueryKey = (name: string) => {
return [`/api/social/hubs/${id}/`] as const; return [`/api/social/hubs/${name}/`] as const;
}; };
export const getApiSocialHubsRetrieveQueryOptions = < export const getApiSocialHubsRetrieveQueryOptions = <
TData = Awaited<ReturnType<typeof apiSocialHubsRetrieve>>, TData = Awaited<ReturnType<typeof apiSocialHubsRetrieve>>,
TError = unknown, TError = unknown,
>( >(
id: string, name: string,
options?: { options?: {
query?: Partial< query?: Partial<
UseQueryOptions< UseQueryOptions<
@@ -343,16 +343,16 @@ export const getApiSocialHubsRetrieveQueryOptions = <
const { query: queryOptions } = options ?? {}; const { query: queryOptions } = options ?? {};
const queryKey = const queryKey =
queryOptions?.queryKey ?? getApiSocialHubsRetrieveQueryKey(id); queryOptions?.queryKey ?? getApiSocialHubsRetrieveQueryKey(name);
const queryFn: QueryFunction< const queryFn: QueryFunction<
Awaited<ReturnType<typeof apiSocialHubsRetrieve>> Awaited<ReturnType<typeof apiSocialHubsRetrieve>>
> = ({ signal }) => apiSocialHubsRetrieve(id, signal); > = ({ signal }) => apiSocialHubsRetrieve(name, signal);
return { return {
queryKey, queryKey,
queryFn, queryFn,
enabled: !!id, enabled: !!name,
...queryOptions, ...queryOptions,
} as UseQueryOptions< } as UseQueryOptions<
Awaited<ReturnType<typeof apiSocialHubsRetrieve>>, Awaited<ReturnType<typeof apiSocialHubsRetrieve>>,
@@ -370,7 +370,7 @@ export function useApiSocialHubsRetrieve<
TData = Awaited<ReturnType<typeof apiSocialHubsRetrieve>>, TData = Awaited<ReturnType<typeof apiSocialHubsRetrieve>>,
TError = unknown, TError = unknown,
>( >(
id: string, name: string,
options: { options: {
query: Partial< query: Partial<
UseQueryOptions< UseQueryOptions<
@@ -396,7 +396,7 @@ export function useApiSocialHubsRetrieve<
TData = Awaited<ReturnType<typeof apiSocialHubsRetrieve>>, TData = Awaited<ReturnType<typeof apiSocialHubsRetrieve>>,
TError = unknown, TError = unknown,
>( >(
id: string, name: string,
options?: { options?: {
query?: Partial< query?: Partial<
UseQueryOptions< UseQueryOptions<
@@ -422,7 +422,7 @@ export function useApiSocialHubsRetrieve<
TData = Awaited<ReturnType<typeof apiSocialHubsRetrieve>>, TData = Awaited<ReturnType<typeof apiSocialHubsRetrieve>>,
TError = unknown, TError = unknown,
>( >(
id: string, name: string,
options?: { options?: {
query?: Partial< query?: Partial<
UseQueryOptions< UseQueryOptions<
@@ -444,7 +444,7 @@ export function useApiSocialHubsRetrieve<
TData = Awaited<ReturnType<typeof apiSocialHubsRetrieve>>, TData = Awaited<ReturnType<typeof apiSocialHubsRetrieve>>,
TError = unknown, TError = unknown,
>( >(
id: string, name: string,
options?: { options?: {
query?: Partial< query?: Partial<
UseQueryOptions< UseQueryOptions<
@@ -458,7 +458,7 @@ export function useApiSocialHubsRetrieve<
): UseQueryResult<TData, TError> & { ): UseQueryResult<TData, TError> & {
queryKey: DataTag<QueryKey, TData, TError>; queryKey: DataTag<QueryKey, TData, TError>;
} { } {
const queryOptions = getApiSocialHubsRetrieveQueryOptions(id, options); const queryOptions = getApiSocialHubsRetrieveQueryOptions(name, options);
const query = useQuery(queryOptions, queryClient) as UseQueryResult< const query = useQuery(queryOptions, queryClient) as UseQueryResult<
TData, TData,
@@ -473,12 +473,12 @@ export function useApiSocialHubsRetrieve<
* @summary Replace a hub * @summary Replace a hub
*/ */
export const apiSocialHubsUpdate = ( export const apiSocialHubsUpdate = (
id: string, name: string,
hub: NonReadonly<Hub>, hub: NonReadonly<Hub>,
signal?: AbortSignal, signal?: AbortSignal,
) => { ) => {
return privateMutator<Hub>({ return privateMutator<Hub>({
url: `/api/social/hubs/${id}/`, url: `/api/social/hubs/${name}/`,
method: "PUT", method: "PUT",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
data: hub, data: hub,
@@ -493,13 +493,13 @@ export const getApiSocialHubsUpdateMutationOptions = <
mutation?: UseMutationOptions< mutation?: UseMutationOptions<
Awaited<ReturnType<typeof apiSocialHubsUpdate>>, Awaited<ReturnType<typeof apiSocialHubsUpdate>>,
TError, TError,
{ id: string; data: NonReadonly<Hub> }, { name: string; data: NonReadonly<Hub> },
TContext TContext
>; >;
}): UseMutationOptions< }): UseMutationOptions<
Awaited<ReturnType<typeof apiSocialHubsUpdate>>, Awaited<ReturnType<typeof apiSocialHubsUpdate>>,
TError, TError,
{ id: string; data: NonReadonly<Hub> }, { name: string; data: NonReadonly<Hub> },
TContext TContext
> => { > => {
const mutationKey = ["apiSocialHubsUpdate"]; const mutationKey = ["apiSocialHubsUpdate"];
@@ -513,11 +513,11 @@ export const getApiSocialHubsUpdateMutationOptions = <
const mutationFn: MutationFunction< const mutationFn: MutationFunction<
Awaited<ReturnType<typeof apiSocialHubsUpdate>>, Awaited<ReturnType<typeof apiSocialHubsUpdate>>,
{ id: string; data: NonReadonly<Hub> } { name: string; data: NonReadonly<Hub> }
> = (props) => { > = (props) => {
const { id, data } = props ?? {}; const { name, data } = props ?? {};
return apiSocialHubsUpdate(id, data); return apiSocialHubsUpdate(name, data);
}; };
return { mutationFn, ...mutationOptions }; return { mutationFn, ...mutationOptions };
@@ -537,7 +537,7 @@ export const useApiSocialHubsUpdate = <TError = unknown, TContext = unknown>(
mutation?: UseMutationOptions< mutation?: UseMutationOptions<
Awaited<ReturnType<typeof apiSocialHubsUpdate>>, Awaited<ReturnType<typeof apiSocialHubsUpdate>>,
TError, TError,
{ id: string; data: NonReadonly<Hub> }, { name: string; data: NonReadonly<Hub> },
TContext TContext
>; >;
}, },
@@ -545,7 +545,7 @@ export const useApiSocialHubsUpdate = <TError = unknown, TContext = unknown>(
): UseMutationResult< ): UseMutationResult<
Awaited<ReturnType<typeof apiSocialHubsUpdate>>, Awaited<ReturnType<typeof apiSocialHubsUpdate>>,
TError, TError,
{ id: string; data: NonReadonly<Hub> }, { name: string; data: NonReadonly<Hub> },
TContext TContext
> => { > => {
return useMutation( return useMutation(
@@ -558,12 +558,12 @@ export const useApiSocialHubsUpdate = <TError = unknown, TContext = unknown>(
* @summary Update a hub * @summary Update a hub
*/ */
export const apiSocialHubsPartialUpdate = ( export const apiSocialHubsPartialUpdate = (
id: string, name: string,
patchedHub: NonReadonly<PatchedHub>, patchedHub: NonReadonly<PatchedHub>,
signal?: AbortSignal, signal?: AbortSignal,
) => { ) => {
return privateMutator<Hub>({ return privateMutator<Hub>({
url: `/api/social/hubs/${id}/`, url: `/api/social/hubs/${name}/`,
method: "PATCH", method: "PATCH",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
data: patchedHub, data: patchedHub,
@@ -578,13 +578,13 @@ export const getApiSocialHubsPartialUpdateMutationOptions = <
mutation?: UseMutationOptions< mutation?: UseMutationOptions<
Awaited<ReturnType<typeof apiSocialHubsPartialUpdate>>, Awaited<ReturnType<typeof apiSocialHubsPartialUpdate>>,
TError, TError,
{ id: string; data: NonReadonly<PatchedHub> }, { name: string; data: NonReadonly<PatchedHub> },
TContext TContext
>; >;
}): UseMutationOptions< }): UseMutationOptions<
Awaited<ReturnType<typeof apiSocialHubsPartialUpdate>>, Awaited<ReturnType<typeof apiSocialHubsPartialUpdate>>,
TError, TError,
{ id: string; data: NonReadonly<PatchedHub> }, { name: string; data: NonReadonly<PatchedHub> },
TContext TContext
> => { > => {
const mutationKey = ["apiSocialHubsPartialUpdate"]; const mutationKey = ["apiSocialHubsPartialUpdate"];
@@ -598,11 +598,11 @@ export const getApiSocialHubsPartialUpdateMutationOptions = <
const mutationFn: MutationFunction< const mutationFn: MutationFunction<
Awaited<ReturnType<typeof apiSocialHubsPartialUpdate>>, Awaited<ReturnType<typeof apiSocialHubsPartialUpdate>>,
{ id: string; data: NonReadonly<PatchedHub> } { name: string; data: NonReadonly<PatchedHub> }
> = (props) => { > = (props) => {
const { id, data } = props ?? {}; const { name, data } = props ?? {};
return apiSocialHubsPartialUpdate(id, data); return apiSocialHubsPartialUpdate(name, data);
}; };
return { mutationFn, ...mutationOptions }; return { mutationFn, ...mutationOptions };
@@ -625,7 +625,7 @@ export const useApiSocialHubsPartialUpdate = <
mutation?: UseMutationOptions< mutation?: UseMutationOptions<
Awaited<ReturnType<typeof apiSocialHubsPartialUpdate>>, Awaited<ReturnType<typeof apiSocialHubsPartialUpdate>>,
TError, TError,
{ id: string; data: NonReadonly<PatchedHub> }, { name: string; data: NonReadonly<PatchedHub> },
TContext TContext
>; >;
}, },
@@ -633,7 +633,7 @@ export const useApiSocialHubsPartialUpdate = <
): UseMutationResult< ): UseMutationResult<
Awaited<ReturnType<typeof apiSocialHubsPartialUpdate>>, Awaited<ReturnType<typeof apiSocialHubsPartialUpdate>>,
TError, TError,
{ id: string; data: NonReadonly<PatchedHub> }, { name: string; data: NonReadonly<PatchedHub> },
TContext TContext
> => { > => {
return useMutation( return useMutation(
@@ -645,9 +645,9 @@ export const useApiSocialHubsPartialUpdate = <
* Soft-deletes the hub. Owner or admin only. * Soft-deletes the hub. Owner or admin only.
* @summary Delete a hub * @summary Delete a hub
*/ */
export const apiSocialHubsDestroy = (id: string, signal?: AbortSignal) => { export const apiSocialHubsDestroy = (name: string, signal?: AbortSignal) => {
return privateMutator<void>({ return privateMutator<void>({
url: `/api/social/hubs/${id}/`, url: `/api/social/hubs/${name}/`,
method: "DELETE", method: "DELETE",
signal, signal,
}); });
@@ -660,13 +660,13 @@ export const getApiSocialHubsDestroyMutationOptions = <
mutation?: UseMutationOptions< mutation?: UseMutationOptions<
Awaited<ReturnType<typeof apiSocialHubsDestroy>>, Awaited<ReturnType<typeof apiSocialHubsDestroy>>,
TError, TError,
{ id: string }, { name: string },
TContext TContext
>; >;
}): UseMutationOptions< }): UseMutationOptions<
Awaited<ReturnType<typeof apiSocialHubsDestroy>>, Awaited<ReturnType<typeof apiSocialHubsDestroy>>,
TError, TError,
{ id: string }, { name: string },
TContext TContext
> => { > => {
const mutationKey = ["apiSocialHubsDestroy"]; const mutationKey = ["apiSocialHubsDestroy"];
@@ -680,11 +680,11 @@ export const getApiSocialHubsDestroyMutationOptions = <
const mutationFn: MutationFunction< const mutationFn: MutationFunction<
Awaited<ReturnType<typeof apiSocialHubsDestroy>>, Awaited<ReturnType<typeof apiSocialHubsDestroy>>,
{ id: string } { name: string }
> = (props) => { > = (props) => {
const { id } = props ?? {}; const { name } = props ?? {};
return apiSocialHubsDestroy(id); return apiSocialHubsDestroy(name);
}; };
return { mutationFn, ...mutationOptions }; return { mutationFn, ...mutationOptions };
@@ -704,7 +704,7 @@ export const useApiSocialHubsDestroy = <TError = unknown, TContext = unknown>(
mutation?: UseMutationOptions< mutation?: UseMutationOptions<
Awaited<ReturnType<typeof apiSocialHubsDestroy>>, Awaited<ReturnType<typeof apiSocialHubsDestroy>>,
TError, TError,
{ id: string }, { name: string },
TContext TContext
>; >;
}, },
@@ -712,7 +712,7 @@ export const useApiSocialHubsDestroy = <TError = unknown, TContext = unknown>(
): UseMutationResult< ): UseMutationResult<
Awaited<ReturnType<typeof apiSocialHubsDestroy>>, Awaited<ReturnType<typeof apiSocialHubsDestroy>>,
TError, TError,
{ id: string }, { name: string },
TContext TContext
> => { > => {
return useMutation( return useMutation(
@@ -724,9 +724,9 @@ export const useApiSocialHubsDestroy = <TError = unknown, TContext = unknown>(
* Adds the authenticated user as a member. Private hubs reject this request. * Adds the authenticated user as a member. Private hubs reject this request.
* @summary Join a hub * @summary Join a hub
*/ */
export const apiSocialHubsJoinCreate = (id: string, signal?: AbortSignal) => { export const apiSocialHubsJoinCreate = (name: string, signal?: AbortSignal) => {
return privateMutator<Hub>({ return privateMutator<Hub>({
url: `/api/social/hubs/${id}/join/`, url: `/api/social/hubs/${name}/join/`,
method: "POST", method: "POST",
signal, signal,
}); });
@@ -739,13 +739,13 @@ export const getApiSocialHubsJoinCreateMutationOptions = <
mutation?: UseMutationOptions< mutation?: UseMutationOptions<
Awaited<ReturnType<typeof apiSocialHubsJoinCreate>>, Awaited<ReturnType<typeof apiSocialHubsJoinCreate>>,
TError, TError,
{ id: string }, { name: string },
TContext TContext
>; >;
}): UseMutationOptions< }): UseMutationOptions<
Awaited<ReturnType<typeof apiSocialHubsJoinCreate>>, Awaited<ReturnType<typeof apiSocialHubsJoinCreate>>,
TError, TError,
{ id: string }, { name: string },
TContext TContext
> => { > => {
const mutationKey = ["apiSocialHubsJoinCreate"]; const mutationKey = ["apiSocialHubsJoinCreate"];
@@ -759,11 +759,11 @@ export const getApiSocialHubsJoinCreateMutationOptions = <
const mutationFn: MutationFunction< const mutationFn: MutationFunction<
Awaited<ReturnType<typeof apiSocialHubsJoinCreate>>, Awaited<ReturnType<typeof apiSocialHubsJoinCreate>>,
{ id: string } { name: string }
> = (props) => { > = (props) => {
const { id } = props ?? {}; const { name } = props ?? {};
return apiSocialHubsJoinCreate(id); return apiSocialHubsJoinCreate(name);
}; };
return { mutationFn, ...mutationOptions }; return { mutationFn, ...mutationOptions };
@@ -786,7 +786,7 @@ export const useApiSocialHubsJoinCreate = <
mutation?: UseMutationOptions< mutation?: UseMutationOptions<
Awaited<ReturnType<typeof apiSocialHubsJoinCreate>>, Awaited<ReturnType<typeof apiSocialHubsJoinCreate>>,
TError, TError,
{ id: string }, { name: string },
TContext TContext
>; >;
}, },
@@ -794,7 +794,7 @@ export const useApiSocialHubsJoinCreate = <
): UseMutationResult< ): UseMutationResult<
Awaited<ReturnType<typeof apiSocialHubsJoinCreate>>, Awaited<ReturnType<typeof apiSocialHubsJoinCreate>>,
TError, TError,
{ id: string }, { name: string },
TContext TContext
> => { > => {
return useMutation( return useMutation(
@@ -806,9 +806,12 @@ export const useApiSocialHubsJoinCreate = <
* Removes the authenticated user from the hub's members. * Removes the authenticated user from the hub's members.
* @summary Leave a hub * @summary Leave a hub
*/ */
export const apiSocialHubsLeaveCreate = (id: string, signal?: AbortSignal) => { export const apiSocialHubsLeaveCreate = (
name: string,
signal?: AbortSignal,
) => {
return privateMutator<void>({ return privateMutator<void>({
url: `/api/social/hubs/${id}/leave/`, url: `/api/social/hubs/${name}/leave/`,
method: "POST", method: "POST",
signal, signal,
}); });
@@ -821,13 +824,13 @@ export const getApiSocialHubsLeaveCreateMutationOptions = <
mutation?: UseMutationOptions< mutation?: UseMutationOptions<
Awaited<ReturnType<typeof apiSocialHubsLeaveCreate>>, Awaited<ReturnType<typeof apiSocialHubsLeaveCreate>>,
TError, TError,
{ id: string }, { name: string },
TContext TContext
>; >;
}): UseMutationOptions< }): UseMutationOptions<
Awaited<ReturnType<typeof apiSocialHubsLeaveCreate>>, Awaited<ReturnType<typeof apiSocialHubsLeaveCreate>>,
TError, TError,
{ id: string }, { name: string },
TContext TContext
> => { > => {
const mutationKey = ["apiSocialHubsLeaveCreate"]; const mutationKey = ["apiSocialHubsLeaveCreate"];
@@ -841,11 +844,11 @@ export const getApiSocialHubsLeaveCreateMutationOptions = <
const mutationFn: MutationFunction< const mutationFn: MutationFunction<
Awaited<ReturnType<typeof apiSocialHubsLeaveCreate>>, Awaited<ReturnType<typeof apiSocialHubsLeaveCreate>>,
{ id: string } { name: string }
> = (props) => { > = (props) => {
const { id } = props ?? {}; const { name } = props ?? {};
return apiSocialHubsLeaveCreate(id); return apiSocialHubsLeaveCreate(name);
}; };
return { mutationFn, ...mutationOptions }; return { mutationFn, ...mutationOptions };
@@ -868,7 +871,7 @@ export const useApiSocialHubsLeaveCreate = <
mutation?: UseMutationOptions< mutation?: UseMutationOptions<
Awaited<ReturnType<typeof apiSocialHubsLeaveCreate>>, Awaited<ReturnType<typeof apiSocialHubsLeaveCreate>>,
TError, TError,
{ id: string }, { name: string },
TContext TContext
>; >;
}, },
@@ -876,7 +879,7 @@ export const useApiSocialHubsLeaveCreate = <
): UseMutationResult< ): UseMutationResult<
Awaited<ReturnType<typeof apiSocialHubsLeaveCreate>>, Awaited<ReturnType<typeof apiSocialHubsLeaveCreate>>,
TError, TError,
{ id: string }, { name: string },
TContext TContext
> => { > => {
return useMutation( return useMutation(
@@ -889,11 +892,11 @@ export const useApiSocialHubsLeaveCreate = <
* @summary Cancel ownership transfer * @summary Cancel ownership transfer
*/ */
export const apiSocialHubsTransferCancelCreate = ( export const apiSocialHubsTransferCancelCreate = (
id: string, name: string,
signal?: AbortSignal, signal?: AbortSignal,
) => { ) => {
return privateMutator<void>({ return privateMutator<void>({
url: `/api/social/hubs/${id}/transfer/cancel/`, url: `/api/social/hubs/${name}/transfer/cancel/`,
method: "POST", method: "POST",
signal, signal,
}); });
@@ -906,13 +909,13 @@ export const getApiSocialHubsTransferCancelCreateMutationOptions = <
mutation?: UseMutationOptions< mutation?: UseMutationOptions<
Awaited<ReturnType<typeof apiSocialHubsTransferCancelCreate>>, Awaited<ReturnType<typeof apiSocialHubsTransferCancelCreate>>,
TError, TError,
{ id: string }, { name: string },
TContext TContext
>; >;
}): UseMutationOptions< }): UseMutationOptions<
Awaited<ReturnType<typeof apiSocialHubsTransferCancelCreate>>, Awaited<ReturnType<typeof apiSocialHubsTransferCancelCreate>>,
TError, TError,
{ id: string }, { name: string },
TContext TContext
> => { > => {
const mutationKey = ["apiSocialHubsTransferCancelCreate"]; const mutationKey = ["apiSocialHubsTransferCancelCreate"];
@@ -926,11 +929,11 @@ export const getApiSocialHubsTransferCancelCreateMutationOptions = <
const mutationFn: MutationFunction< const mutationFn: MutationFunction<
Awaited<ReturnType<typeof apiSocialHubsTransferCancelCreate>>, Awaited<ReturnType<typeof apiSocialHubsTransferCancelCreate>>,
{ id: string } { name: string }
> = (props) => { > = (props) => {
const { id } = props ?? {}; const { name } = props ?? {};
return apiSocialHubsTransferCancelCreate(id); return apiSocialHubsTransferCancelCreate(name);
}; };
return { mutationFn, ...mutationOptions }; return { mutationFn, ...mutationOptions };
@@ -953,7 +956,7 @@ export const useApiSocialHubsTransferCancelCreate = <
mutation?: UseMutationOptions< mutation?: UseMutationOptions<
Awaited<ReturnType<typeof apiSocialHubsTransferCancelCreate>>, Awaited<ReturnType<typeof apiSocialHubsTransferCancelCreate>>,
TError, TError,
{ id: string }, { name: string },
TContext TContext
>; >;
}, },
@@ -961,7 +964,7 @@ export const useApiSocialHubsTransferCancelCreate = <
): UseMutationResult< ): UseMutationResult<
Awaited<ReturnType<typeof apiSocialHubsTransferCancelCreate>>, Awaited<ReturnType<typeof apiSocialHubsTransferCancelCreate>>,
TError, TError,
{ id: string }, { name: string },
TContext TContext
> => { > => {
return useMutation( return useMutation(
@@ -974,12 +977,12 @@ export const useApiSocialHubsTransferCancelCreate = <
* @summary Initiate ownership transfer * @summary Initiate ownership transfer
*/ */
export const apiSocialHubsTransferInitiateCreate = ( export const apiSocialHubsTransferInitiateCreate = (
id: string, name: string,
transferInit: TransferInit, transferInit: TransferInit,
signal?: AbortSignal, signal?: AbortSignal,
) => { ) => {
return privateMutator<TransferInit>({ return privateMutator<TransferInit>({
url: `/api/social/hubs/${id}/transfer/initiate/`, url: `/api/social/hubs/${name}/transfer/initiate/`,
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
data: transferInit, data: transferInit,
@@ -994,13 +997,13 @@ export const getApiSocialHubsTransferInitiateCreateMutationOptions = <
mutation?: UseMutationOptions< mutation?: UseMutationOptions<
Awaited<ReturnType<typeof apiSocialHubsTransferInitiateCreate>>, Awaited<ReturnType<typeof apiSocialHubsTransferInitiateCreate>>,
TError, TError,
{ id: string; data: TransferInit }, { name: string; data: TransferInit },
TContext TContext
>; >;
}): UseMutationOptions< }): UseMutationOptions<
Awaited<ReturnType<typeof apiSocialHubsTransferInitiateCreate>>, Awaited<ReturnType<typeof apiSocialHubsTransferInitiateCreate>>,
TError, TError,
{ id: string; data: TransferInit }, { name: string; data: TransferInit },
TContext TContext
> => { > => {
const mutationKey = ["apiSocialHubsTransferInitiateCreate"]; const mutationKey = ["apiSocialHubsTransferInitiateCreate"];
@@ -1014,11 +1017,11 @@ export const getApiSocialHubsTransferInitiateCreateMutationOptions = <
const mutationFn: MutationFunction< const mutationFn: MutationFunction<
Awaited<ReturnType<typeof apiSocialHubsTransferInitiateCreate>>, Awaited<ReturnType<typeof apiSocialHubsTransferInitiateCreate>>,
{ id: string; data: TransferInit } { name: string; data: TransferInit }
> = (props) => { > = (props) => {
const { id, data } = props ?? {}; const { name, data } = props ?? {};
return apiSocialHubsTransferInitiateCreate(id, data); return apiSocialHubsTransferInitiateCreate(name, data);
}; };
return { mutationFn, ...mutationOptions }; return { mutationFn, ...mutationOptions };
@@ -1041,7 +1044,7 @@ export const useApiSocialHubsTransferInitiateCreate = <
mutation?: UseMutationOptions< mutation?: UseMutationOptions<
Awaited<ReturnType<typeof apiSocialHubsTransferInitiateCreate>>, Awaited<ReturnType<typeof apiSocialHubsTransferInitiateCreate>>,
TError, TError,
{ id: string; data: TransferInit }, { name: string; data: TransferInit },
TContext TContext
>; >;
}, },
@@ -1049,7 +1052,7 @@ export const useApiSocialHubsTransferInitiateCreate = <
): UseMutationResult< ): UseMutationResult<
Awaited<ReturnType<typeof apiSocialHubsTransferInitiateCreate>>, Awaited<ReturnType<typeof apiSocialHubsTransferInitiateCreate>>,
TError, TError,
{ id: string; data: TransferInit }, { name: string; data: TransferInit },
TContext TContext
> => { > => {
return useMutation( return useMutation(
@@ -1062,12 +1065,12 @@ export const useApiSocialHubsTransferInitiateCreate = <
* @summary Verify ownership transfer * @summary Verify ownership transfer
*/ */
export const apiSocialHubsTransferVerifyCreate = ( export const apiSocialHubsTransferVerifyCreate = (
id: string, name: string,
transferVerify: TransferVerify, transferVerify: TransferVerify,
signal?: AbortSignal, signal?: AbortSignal,
) => { ) => {
return privateMutator<Hub>({ return privateMutator<Hub>({
url: `/api/social/hubs/${id}/transfer/verify/`, url: `/api/social/hubs/${name}/transfer/verify/`,
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
data: transferVerify, data: transferVerify,
@@ -1082,13 +1085,13 @@ export const getApiSocialHubsTransferVerifyCreateMutationOptions = <
mutation?: UseMutationOptions< mutation?: UseMutationOptions<
Awaited<ReturnType<typeof apiSocialHubsTransferVerifyCreate>>, Awaited<ReturnType<typeof apiSocialHubsTransferVerifyCreate>>,
TError, TError,
{ id: string; data: TransferVerify }, { name: string; data: TransferVerify },
TContext TContext
>; >;
}): UseMutationOptions< }): UseMutationOptions<
Awaited<ReturnType<typeof apiSocialHubsTransferVerifyCreate>>, Awaited<ReturnType<typeof apiSocialHubsTransferVerifyCreate>>,
TError, TError,
{ id: string; data: TransferVerify }, { name: string; data: TransferVerify },
TContext TContext
> => { > => {
const mutationKey = ["apiSocialHubsTransferVerifyCreate"]; const mutationKey = ["apiSocialHubsTransferVerifyCreate"];
@@ -1102,11 +1105,11 @@ export const getApiSocialHubsTransferVerifyCreateMutationOptions = <
const mutationFn: MutationFunction< const mutationFn: MutationFunction<
Awaited<ReturnType<typeof apiSocialHubsTransferVerifyCreate>>, Awaited<ReturnType<typeof apiSocialHubsTransferVerifyCreate>>,
{ id: string; data: TransferVerify } { name: string; data: TransferVerify }
> = (props) => { > = (props) => {
const { id, data } = props ?? {}; const { name, data } = props ?? {};
return apiSocialHubsTransferVerifyCreate(id, data); return apiSocialHubsTransferVerifyCreate(name, data);
}; };
return { mutationFn, ...mutationOptions }; return { mutationFn, ...mutationOptions };
@@ -1129,7 +1132,7 @@ export const useApiSocialHubsTransferVerifyCreate = <
mutation?: UseMutationOptions< mutation?: UseMutationOptions<
Awaited<ReturnType<typeof apiSocialHubsTransferVerifyCreate>>, Awaited<ReturnType<typeof apiSocialHubsTransferVerifyCreate>>,
TError, TError,
{ id: string; data: TransferVerify }, { name: string; data: TransferVerify },
TContext TContext
>; >;
}, },
@@ -1137,7 +1140,7 @@ export const useApiSocialHubsTransferVerifyCreate = <
): UseMutationResult< ): UseMutationResult<
Awaited<ReturnType<typeof apiSocialHubsTransferVerifyCreate>>, Awaited<ReturnType<typeof apiSocialHubsTransferVerifyCreate>>,
TError, TError,
{ id: string; data: TransferVerify }, { name: string; data: TransferVerify },
TContext TContext
> => { > => {
return useMutation( return useMutation(

View File

@@ -2,10 +2,17 @@ import { privateMutator } from "../privateClient";
import type { Post } from "../generated/private/models/post"; import type { Post } from "../generated/private/models/post";
import type { CursorPaginated } from "./feed"; import type { CursorPaginated } from "./feed";
export type HubSortOption = "newest" | "top";
export type HubTimeOption = "1h" | "6h" | "day" | "week" | "month" | "year" | "all" | "custom";
export interface HubPostsParams { export interface HubPostsParams {
hub: number; hub: number;
cursor?: string | null; cursor?: string | null;
tag?: number | null; tag?: number | null;
sort?: HubSortOption;
time?: HubTimeOption;
start?: string;
end?: string;
} }
export const apiSocialHubPostsCursor = ( export const apiSocialHubPostsCursor = (
@@ -15,9 +22,22 @@ export const apiSocialHubPostsCursor = (
privateMutator<CursorPaginated<Post>>({ privateMutator<CursorPaginated<Post>>({
url: `/api/social/posts/`, url: `/api/social/posts/`,
method: "GET", 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, signal,
}); });
export const hubPostsQueryKey = (hubId: number, tag?: number) => export const hubPostsQueryKey = (
["social", "hubs", hubId, "posts", tag ?? null] as const; hubId: number,
sort: HubSortOption,
time: HubTimeOption,
start?: string,
end?: string,
) => ["social", "hubs", hubId, "posts", sort, time, start ?? null, end ?? null] as const;

View File

@@ -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 (
<section id="drone" className="section" style={{ background: "color-mix(in hsl, var(--c-background), black 10%)" }}>
<div className="container" style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "4rem", alignItems: "center" }}>
{/* Left: visual */}
<motion.div {...fadeLeft} style={{ position: "relative" }}>
<div className="glass" style={{
padding: "3.5rem 2rem",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
minHeight: "380px",
position: "relative",
overflow: "hidden",
}}>
{/* Rotating ring */}
<div className="animate-spin-slow" style={{
position: "absolute",
width: "280px",
height: "280px",
borderRadius: "50%",
border: "2px solid color-mix(in hsl, var(--c-other), transparent 65%)",
borderTopColor: "var(--c-other)",
}} />
<div className="animate-spin-slow" style={{
position: "absolute",
width: "220px",
height: "220px",
borderRadius: "50%",
border: "1px dashed color-mix(in hsl, var(--c-lines), transparent 60%)",
animationDirection: "reverse",
animationDuration: "14s",
}} />
{/* Drone icon */}
<MdFlightTakeoff style={{ fontSize: "6rem", color: "var(--c-text)", position: "relative", zIndex: 1, filter: "drop-shadow(0 0 1.5rem color-mix(in hsl, var(--c-other), transparent 40%))" }} />
<p style={{ marginTop: "1.5rem", color: "var(--c-lines)", fontSize: "0.9rem", fontWeight: 500, position: "relative", zIndex: 1 }}>
DJI · Sony · Gyroscope Stabilized
</p>
{/* Play button overlay hint */}
<div style={{
position: "absolute",
bottom: "1.2rem",
right: "1.2rem",
display: "flex",
alignItems: "center",
gap: "0.5rem",
color: "var(--c-other)",
fontSize: "0.8rem",
fontWeight: 600,
}}>
<FaPlay style={{ fontSize: "0.7rem" }} /> Showreel coming soon
</div>
</div>
</motion.div>
{/* Right: text */}
<motion.div {...stagger} style={{ display: "flex", flexDirection: "column", gap: "1.2rem" }}>
<motion.div {...staggerItem}>
<span style={{
display: "inline-block",
padding: "0.3em 0.9em",
borderRadius: "9999px",
background: "color-mix(in hsl, var(--c-other), transparent 80%)",
border: "1px solid color-mix(in hsl, var(--c-other), transparent 50%)",
color: "var(--c-other)",
fontSize: "0.78rem",
fontWeight: 700,
letterSpacing: "0.08em",
textTransform: "uppercase",
}}>
Filmmaking & Aerial
</span>
</motion.div>
<motion.h2 {...staggerItem} style={{ fontSize: "clamp(1.8rem, 4vw, 2.8rem)", fontWeight: 800, lineHeight: 1.15, margin: 0 }}>
Stunning Visuals {" "}
<span className="text-rainbow">Ground to Sky</span>
</motion.h2>
<motion.p {...staggerItem} style={{ color: "color-mix(in hsl, var(--c-text), transparent 25%)", lineHeight: 1.75, fontSize: "1rem", margin: 0 }}>
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.
</motion.p>
{/* Feature list */}
<motion.div {...staggerItem} style={{ display: "flex", flexDirection: "column", gap: "0.8rem" }}>
{[
{ icon: <GiFilmProjector />, label: "3-axis gyroscope stabilization", sub: "Cinema-grade smooth ground footage" },
{ icon: <MdFlightTakeoff />, label: "Licensed drone operator", sub: "EU A1 · A2 · A3 certified" },
{ icon: <MdRadio />, label: "Omezený průkaz radiotelefonisty", sub: "Authorized for restricted & controlled airspaces" },
].map(({ icon, label, sub }) => (
<div key={label} style={{ display: "flex", gap: "1rem", alignItems: "flex-start" }}>
<div style={{
width: "2.4rem",
height: "2.4rem",
borderRadius: "0.6rem",
background: "color-mix(in hsl, var(--c-other), transparent 80%)",
border: "1px solid color-mix(in hsl, var(--c-other), transparent 55%)",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: "1.1rem",
color: "var(--c-other)",
flexShrink: 0,
}}>
{icon}
</div>
<div>
<div style={{ fontWeight: 600, fontSize: "0.95rem" }}>{label}</div>
<div style={{ color: "color-mix(in hsl, var(--c-text), transparent 45%)", fontSize: "0.85rem" }}>{sub}</div>
</div>
</div>
))}
</motion.div>
{/* Cert badges */}
<motion.div {...staggerItem} style={{ display: "flex", gap: "0.6rem", flexWrap: "wrap" }}>
{["EU A1", "EU A2", "EU A3", "Restricted Airspace"].map((cert) => (
<span key={cert} style={{
padding: "0.3em 0.8em",
borderRadius: "9999px",
background: "color-mix(in hsl, var(--c-boxes), transparent 70%)",
border: "1px solid color-mix(in hsl, var(--c-lines), transparent 50%)",
fontSize: "0.8rem",
fontWeight: 600,
color: "var(--c-lines)",
}}>
{cert}
</span>
))}
</motion.div>
<motion.div {...staggerItem}>
<Link
to="/portfolio"
style={{
display: "inline-flex",
alignItems: "center",
gap: "0.5rem",
padding: "0.75em 1.8em",
borderRadius: "9999px",
background: "linear-gradient(135deg, var(--c-other), color-mix(in hsl, var(--c-other), var(--c-boxes) 50%))",
color: "#031D44",
fontWeight: 700,
fontSize: "0.9rem",
textDecoration: "none",
transition: "transform 0.2s ease, box-shadow 0.2s ease",
}}
onMouseEnter={(e) => {
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";
}}
>
<FaPlay style={{ fontSize: "0.75rem" }} /> View Portfolio
</Link>
</motion.div>
</motion.div>
</div>
{/* Mobile responsive */}
<style>{`
@media (max-width: 768px) {
#drone .container { grid-template-columns: 1fr !important; gap: 2rem !important; }
}
`}</style>
</section>
);
}

View File

@@ -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 (
<section
style={{ minHeight: "100svh", position: "relative", display: "flex", alignItems: "center", justifyContent: "center", overflow: "hidden" }}
>
{/* Video background */}
<video
autoPlay
muted
loop
playsInline
style={{ position: "absolute", inset: 0, width: "100%", height: "100%", objectFit: "cover", zIndex: 0, opacity: 0.35 }}
src="/assets/hero-drone.mp4"
/>
{/* Gradient overlay */}
<div style={{
position: "absolute", inset: 0, zIndex: 1,
background: "linear-gradient(to bottom, rgba(3,29,68,0.55) 0%, rgba(3,29,68,0.85) 60%, var(--c-background) 100%)",
}} />
{/* Floating particles */}
{PARTICLES.map((p) => (
<div
key={p.id}
className="animate-float"
style={{
position: "absolute",
left: p.left,
top: p.top,
width: p.size,
height: p.size,
borderRadius: "50%",
background: "var(--c-text)",
opacity: 0.18,
filter: "blur(2px)",
zIndex: 1,
animationDelay: p.delay,
animationDuration: p.duration,
}}
/>
))}
{/* Content */}
<div style={{ position: "relative", zIndex: 2, textAlign: "center", padding: "2rem 1.5rem", maxWidth: "820px" }}>
{/* Badge */}
<motion.div {...fade(0.1)}>
<span style={{
display: "inline-block",
padding: "0.35em 1em",
borderRadius: "9999px",
border: "1px solid color-mix(in hsl, var(--c-other), transparent 40%)",
background: "color-mix(in hsl, var(--c-other), transparent 80%)",
color: "var(--c-other)",
fontSize: "0.82rem",
fontWeight: 600,
letterSpacing: "0.06em",
textTransform: "uppercase",
marginBottom: "1.2rem",
}}>
Available for projects
</span>
</motion.div>
{/* Name */}
<motion.h1 {...fade(0.25)} style={{ fontSize: "clamp(2.8rem, 7vw, 5rem)", fontWeight: 900, marginBottom: "0.3em", lineHeight: 1.1 }}>
<span className="text-rainbow animate-gradient" style={{ background: "linear-gradient(90deg, var(--c-other), var(--c-lines), var(--c-text), var(--c-other))" }}>
Bruno Novotný
</span>
</motion.h1>
{/* Typewriter */}
<motion.div {...fade(0.4)} style={{ fontSize: "clamp(1.2rem, 3vw, 1.9rem)", fontWeight: 500, color: "var(--c-text)", marginBottom: "1.5rem", minHeight: "2.5rem" }}>
<span style={{ color: "color-mix(in hsl, var(--c-lines), transparent 30%)" }}>I build as a </span>
<span style={{ color: "var(--c-text)", fontWeight: 700 }}>{displayed}</span>
<span style={{ color: "var(--c-other)", animation: "bounce-y 0.7s ease-in-out infinite" }}>|</span>
</motion.div>
{/* Sub */}
<motion.p {...fade(0.55)} style={{ color: "color-mix(in hsl, var(--c-text), transparent 30%)", fontSize: "1.05rem", maxWidth: "560px", margin: "0 auto 2.2rem", lineHeight: 1.7 }}>
From stunning drone footage to complex web platforms I craft digital experiences that work at scale.
</motion.p>
{/* CTAs */}
<motion.div {...fade(0.7)} style={{ display: "flex", gap: "1rem", justifyContent: "center", flexWrap: "wrap" }}>
<a
href="#projects"
className="animate-pulse-glow"
style={{
padding: "0.8em 2em",
borderRadius: "9999px",
background: "linear-gradient(135deg, var(--c-other), color-mix(in hsl, var(--c-other), var(--c-boxes) 40%))",
color: "#031D44",
fontWeight: 700,
fontSize: "0.95rem",
textDecoration: "none",
border: "none",
cursor: "pointer",
transition: "transform 0.2s ease",
}}
onMouseEnter={(e) => (e.currentTarget.style.transform = "scale(1.05)")}
onMouseLeave={(e) => (e.currentTarget.style.transform = "scale(1)")}
>
Explore My Work
</a>
<Link
to="/contact"
style={{
padding: "0.8em 2em",
borderRadius: "9999px",
background: "color-mix(in hsl, var(--c-background-light), transparent 30%)",
backdropFilter: "blur(10px)",
color: "var(--c-text)",
fontWeight: 600,
fontSize: "0.95rem",
textDecoration: "none",
border: "1px solid color-mix(in hsl, var(--c-lines), transparent 55%)",
transition: "transform 0.2s ease, border-color 0.2s ease",
}}
onMouseEnter={(e) => {
e.currentTarget.style.transform = "scale(1.05)";
e.currentTarget.style.borderColor = "var(--c-other)";
}}
onMouseLeave={(e) => {
e.currentTarget.style.transform = "scale(1)";
e.currentTarget.style.borderColor = "color-mix(in hsl, var(--c-lines), transparent 55%)";
}}
>
Let's Talk
</Link>
</motion.div>
</div>
{/* Scroll indicator */}
<div
className="animate-bounce-y"
style={{ position: "absolute", bottom: "2.5rem", left: "50%", transform: "translateX(-50%)", zIndex: 2, color: "color-mix(in hsl, var(--c-text), transparent 50%)", fontSize: "1.4rem" }}
>
<FaChevronDown />
</div>
</section>
);
}

View File

@@ -1,5 +1,5 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { Link, useNavigate } from "react-router-dom"; import { Link, useNavigate, useLocation } from "react-router-dom";
import { import {
FaSignOutAlt, FaSignOutAlt,
FaSignInAlt, FaSignInAlt,
@@ -7,13 +7,10 @@ import {
FaChevronDown, FaChevronDown,
FaGlobe, FaGlobe,
FaWrench, FaWrench,
FaDownload,
FaGitAlt,
FaPlayCircle,
FaUsers, FaUsers,
FaHandsHelping, FaTimes,
} from "react-icons/fa"; } from "react-icons/fa";
import { FaClapperboard, FaCubes } from "react-icons/fa6"; import { FaClapperboard } from "react-icons/fa6";
import { useAuth } from "@/hooks/useAuth"; import { useAuth } from "@/hooks/useAuth";
import Avatar from "@/components/ui/Avatar"; import Avatar from "@/components/ui/Avatar";
import styles from "./navbar.module.css"; import styles from "./navbar.module.css";
@@ -21,33 +18,32 @@ import styles from "./navbar.module.css";
export default function Navbar() { export default function Navbar() {
const { user, isAuthenticated, logout } = useAuth(); const { user, isAuthenticated, logout } = useAuth();
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation();
const handleLogin = () => navigate("/social/login"); const handleLogin = () => navigate("/social/login");
const handleLogout = async () => { const handleLogout = async () => {
await logout(); await logout();
navigate("/"); navigate("/");
}; };
const [mobileMenu, setMobileMenu] = useState(false); const [mobileMenu, setMobileMenu] = useState(false);
const [scrolled, setScrolled] = useState(false);
const navRef = useRef<HTMLElement | null>(null); const navRef = useRef<HTMLElement | null>(null);
// close on outside click
useEffect(() => { useEffect(() => {
function handleClick(e: MouseEvent) { const onScroll = () => setScrolled(window.scrollY > 40);
if (!navRef.current) return; window.addEventListener("scroll", onScroll, { passive: true });
if (!navRef.current.contains(e.target as Node)) { return () => window.removeEventListener("scroll", onScroll);
// close only mobile menu here; dropdowns are CSS-controlled
}
}
window.addEventListener("click", handleClick);
return () => window.removeEventListener("click", handleClick);
}, []); }, []);
// close dropdowns on Escape // Close mobile menu on route change
useEffect(() => {
setMobileMenu(false);
}, [location.pathname]);
useEffect(() => { useEffect(() => {
function onKey(e: KeyboardEvent) { function onKey(e: KeyboardEvent) {
if (e.key === "Escape") { if (e.key === "Escape") setMobileMenu(false);
setMobileMenu(false);
}
} }
window.addEventListener("keydown", onKey); window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey);
@@ -55,34 +51,22 @@ export default function Navbar() {
return ( return (
<nav <nav
className={`${styles.navbar} ${mobileMenu ? styles.mobileNavOpen : ""}`} className={`${styles.navbar} ${mobileMenu ? styles.mobileNavOpen : ""} ${scrolled ? styles.scrolled : ""}`}
ref={navRef} ref={navRef}
aria-label="Hlavní navigace" aria-label="Hlavní navigace"
> >
{/* mobile burger */} {/* Brand */}
<button
className={styles.burger}
onClick={() => setMobileMenu((p) => !p)}
aria-expanded={mobileMenu}
aria-label="Otevřít menu"
>
<FaBars />
</button>
{/* left: brand */}
<div className={styles.logo}> <div className={styles.logo}>
<Link to="/" aria-label="vontor.cz home">vontor.cz</Link> <Link to="/" aria-label="vontor.cz home">vontor.cz</Link>
</div> </div>
{/* center links */} {/* Center links */}
<div className={`${styles.links} ${mobileMenu ? styles.show : ""}`} role="menubar"> <div className={`${styles.links} ${mobileMenu ? styles.show : ""}`} role="menubar">
{/* Services with submenu */} {/* Služby dropdown */}
<div className={styles.dropdownItem}> <div className={styles.dropdownItem}>
<button className={styles.linkButton} aria-haspopup="true"> <button className={styles.linkButton} aria-haspopup="true">
<FaHandsHelping className={styles.iconSmall} /> Služby{" "} Služby <FaChevronDown className={styles.chev} />
<FaChevronDown className={styles.chev} />
</button> </button>
<div className={styles.dropdown} role="menu" aria-label="Služby submenu"> <div className={styles.dropdown} role="menu" aria-label="Služby submenu">
<Link to="/services/web" role="menuitem"> <Link to="/services/web" role="menuitem">
<FaGlobe className={styles.iconSmall} /> Weby <FaGlobe className={styles.iconSmall} /> Weby
@@ -96,73 +80,52 @@ export default function Navbar() {
</div> </div>
</div> </div>
{/* Aplikace standalone submenu */} <Link className={`${styles.linkSimple} nav-item`} to="/social/feed">
<div className={styles.dropdownItem}>
<button className={styles.linkButton} aria-haspopup="true">
<FaCubes className={styles.iconSmall} /> Aplikace{" "}
<FaChevronDown className={styles.chev} />
</button>
<div className={styles.dropdown} role="menu" aria-label="Aplikace submenu">
<Link to="/apps/downloader" role="menuitem">
<FaDownload className={styles.iconSmall} /> Downloader
</Link>
<Link to="/apps/git" role="menuitem">
<FaGitAlt className={styles.iconSmall} /> Git
</Link>
<Link to="/apps/dema" role="menuitem">
<FaPlayCircle className={styles.iconSmall} /> Dema
</Link>
</div>
</div>
{/* Social entry — top-level link to the social area */}
<Link className={styles.linkSimple} to="/social/feed">
<FaUsers className={styles.iconSmall} /> Social <FaUsers className={styles.iconSmall} /> Social
</Link> </Link>
<Link className={styles.linkSimple} to="/contact"> <Link className={`${styles.linkSimple} nav-item`} to="/contact">
<FaGlobe className={styles.iconSmall} /> Kontakt Kontakt
</Link> </Link>
{/* right: user area */} {/* User area */}
{!isAuthenticated || !user ? ( {!isAuthenticated || !user ? (
<button <button
type="button" type="button"
className={styles.linkSimple} className={styles.loginBtn}
onClick={handleLogin} onClick={handleLogin}
aria-label="Přihlásit" aria-label="Přihlásit"
> >
<FaSignInAlt className={styles.iconSmall} /> <FaSignInAlt /> Přihlásit
</button> </button>
) : ( ) : (
<div className={styles.dropdownItem}> <div className={styles.dropdownItem}>
<button className={styles.linkButton} aria-haspopup="true"> <button className={styles.linkButton} aria-haspopup="true">
<Avatar <Avatar name={user.username || user.email} size={24} className={styles.avatar} />
name={user.username || user.email}
size={24}
className={styles.avatar}
/>
<span className={styles.username}>{user.username}</span> <span className={styles.username}>{user.username}</span>
<FaChevronDown className={styles.chev} /> <FaChevronDown className={styles.chev} />
</button> </button>
<div className={styles.dropdown} role="menu" aria-label="Uživatelské menu"> <div className={styles.dropdown} role="menu" aria-label="Uživatelské menu">
<Link to="/social/profile" role="menuitem">Profil</Link> <Link to="/social/profile" role="menuitem">Profil</Link>
<Link to="/social/feed" role="menuitem">Feed</Link> <Link to="/social/feed" role="menuitem">Feed</Link>
<Link to="/social/chats" role="menuitem">Zprávy</Link> <Link to="/social/chats" role="menuitem">Zprávy</Link>
<button type="button" className={styles.logoutBtn} onClick={handleLogout} role="menuitem">
<button
type="button"
className={styles.logoutBtn}
onClick={handleLogout}
role="menuitem"
>
<FaSignOutAlt className={styles.iconSmall} /> Odhlásit se <FaSignOutAlt className={styles.iconSmall} /> Odhlásit se
</button> </button>
</div> </div>
</div> </div>
)} )}
</div> </div>
{/* Mobile burger — right side */}
<button
className={styles.burger}
onClick={() => setMobileMenu((p) => !p)}
aria-expanded={mobileMenu}
aria-label={mobileMenu ? "Zavřít menu" : "Otevřít menu"}
>
{mobileMenu ? <FaTimes /> : <FaBars />}
</button>
</nav> </nav>
); );
} }

View File

@@ -1,376 +1,292 @@
/* ── Navbar ── */
.navbar { .navbar {
width: 50%;
width: max-content; width: max-content;
margin: 0; max-width: calc(100% - 2rem);
margin-left: auto; margin: 0 auto;
margin-right: auto; padding: 0.6em 2em;
padding: 0 2em; /* Glass pill */
background-color: var(--c-boxes); 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; color: white;
font-family: "Roboto Mono", monospace; font-family: "Inter", ui-sans-serif, system-ui, sans-serif;
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
position: sticky; position: sticky;
top: 0; top: 1rem;
z-index: 50; z-index: 100;
gap: 1em; gap: 0.5em;
border-bottom-left-radius: 2em; border-radius: 9999px;
border-bottom-right-radius: 2em; --nav-margin-y: 0.75em;
transition: background 0.4s ease, box-shadow 0.4s ease, border-color 0.4s ease;
}
--nav-margin-y: 1em; .scrolled {
opacity: 0.95; background: color-mix(in hsl, var(--c-background-light), transparent 10%);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
transition: all 0.3s ease-in-out; border-color: color-mix(in hsl, var(--c-lines), transparent 40%);
} }
.mobileNavOpen { .mobileNavOpen {
border-radius: 0; border-radius: 1.5rem;
top: 0;
max-width: 100%;
width: 100%;
} }
/* Brand */ /* ── Brand ── */
.logo { .logo {
padding-right: 1em; padding-right: 1.5em;
border-right: 0.2em solid var(--c-lines); border-right: 1px solid color-mix(in hsl, var(--c-lines), transparent 55%);
flex-shrink: 0;
} }
.logo a { .logo a {
font-size: 1.8em; font-size: 1.5em;
font-weight: 700; font-weight: 800;
color: white; color: white;
text-decoration: none; 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 { .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 { .burger {
display: none; display: none;
background: none; background: none;
border: none; border: 1px solid color-mix(in hsl, var(--c-lines), transparent 60%);
color: white; border-radius: 0.6rem;
font-size: 1.6em; color: var(--c-text);
font-size: 1.2em;
padding: 0.3em 0.5em;
cursor: pointer; 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 { .links {
display: flex; display: flex;
gap: 3em; gap: 0.5em;
align-items: center; align-items: center;
justify-content: space-around; justify-content: center;
width: -webkit-fill-available;
} }
/* Simple link */ /* ── Simple link ── */
.linkSimple { .linkSimple {
color: var(--c-text); color: color-mix(in hsl, var(--c-text), transparent 20%);
text-decoration: none; text-decoration: none;
font-size: 1.05em; font-size: 0.95em;
transition: transform 0.15s; font-weight: 500;
display: inline-flex;
display: flex;
flex-direction: row;
align-items: center; align-items: center;
} gap: 0.35em;
padding: 0.45em 0.9em;
border-radius: 9999px;
/* TEXT SIZE UNIFICATION */ transition: color 0.2s ease, background 0.2s ease;
.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 {
position: relative; position: relative;
} }
/* Unified dropdown container */ .linkSimple:hover {
color: white;
background: color-mix(in hsl, var(--c-boxes), transparent 70%);
}
/* ── Dropdown item wrapper ── */
.dropdownItem { .dropdownItem {
position: relative; position: relative;
} }
/* ── Dropdown trigger button ── */
.linkButton { .linkButton {
background: none; background: none;
border: none; border: none;
color: color-mix(in hsl, var(--c-text), transparent 20%);
font-size: 0.95em;
font-weight: 500;
cursor: pointer; cursor: pointer;
display: flex; display: inline-flex;
flex-direction: row;
align-items: center; align-items: center;
gap: 0.4rem; gap: 0.35em;
margin: var(--nav-margin-y) auto; padding: 0.45em 0.9em;
width: max-content; border-radius: 9999px;
transition: color 0.2s ease, background 0.2s ease;
white-space: nowrap;
} }
.linkButton:hover { .linkButton:hover {
transform: scale(1.05); color: white;
background: color-mix(in hsl, var(--c-boxes), transparent 70%);
transform: none;
} }
/* chevron icons */
.chev { .chev {
margin-left: 0.25rem; font-size: 0.7em;
font-size: 0.9rem; transition: transform 0.25s ease;
}
.dropdownItem:hover .chev,
.dropdownItem:focus-within .chev {
transform: rotate(180deg);
} }
.chevSmall { /* ── Dropdown panel ── */
margin-left: 0.25rem;
font-size: 0.75rem;
}
/* dropdown */
.dropdown { .dropdown {
position: absolute; position: absolute;
top: auto; top: calc(100% + 0.5rem);
left: 0; left: 50%;
width: -moz-max-content; transform: translateX(-50%) translateY(-6px);
width: max-content; width: max-content;
background-color: var(--c-background-light); min-width: 10rem;
/* border: 1px solid var(--c-text); */ background: color-mix(in hsl, var(--c-background-light), transparent 10%);
padding: 0.6rem; backdrop-filter: blur(20px);
/* border-radius: 0.45rem; */ -webkit-backdrop-filter: blur(20px);
border-bottom-left-radius: 1em; border: 1px solid color-mix(in hsl, var(--c-lines), transparent 60%);
border-bottom-right-radius: 1em; padding: 0.5rem;
display: none; border-radius: 1rem;
display: flex;
flex-direction: column; flex-direction: column;
gap: 0.35rem; gap: 0.2rem;
box-shadow: 0px 20px 24px 6px rgba(0, 0, 0, 0.35); box-shadow: 0 16px 40px rgba(0, 0, 0, 0.4);
z-index: 49; 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:hover .dropdown,
.dropdownItem:focus-within .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 a,
.dropdown button { .dropdown button {
color: white; color: color-mix(in hsl, var(--c-text), transparent 15%);
text-decoration: none; text-decoration: none;
background: none; background: none;
border: none; border: none;
padding: 0.35rem 0.25rem; padding: 0.5rem 0.75rem;
border-radius: 0.6rem;
text-align: left; text-align: left;
cursor: pointer; cursor: pointer;
transition: transform 0.12s; font-size: 0.9em;
font-weight: 500;
display: inline-flex; display: inline-flex;
flex-direction: row;
align-items: center; align-items: center;
gap: 0.45rem;
transition: background 0.15s ease, color 0.15s ease;
width: 100%;
} }
.dropdown a:hover, .dropdown a:hover,
.dropdown button:hover { .dropdown button:hover {
transform: scale(1.04); background: color-mix(in hsl, var(--c-boxes), transparent 60%);
}
/* 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;
color: white; 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; cursor: pointer;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 0.45rem; gap: 0.4em;
transition: opacity 0.2s ease, transform 0.15s ease;
}
.loginBtn svg {
font-size: 1.5rem;
} }
.loginBtn:hover { .loginBtn:hover {
background: var(--c-text); opacity: 0.9;
transform: scale(1.03); transform: scale(1.04);
} box-shadow: 0 0 1rem color-mix(in hsl, var(--c-other), transparent 50%);
/* 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;
} }
/* ── User avatar + username ── */
.avatar { .avatar {
width: 1.8rem;
height: 1.8rem;
border-radius: 50%; border-radius: 50%;
object-fit: cover; object-fit: cover;
flex-shrink: 0;
} }
.username { .username {
font-weight: 600; font-weight: 600;
font-size: 0.9em;
max-width: 8rem;
overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
max-width: max-content; white-space: nowrap;
text-overflow: ellipsis;
} }
/* logout button */ /* ── Logout button ── */
.logoutBtn { .logoutBtn {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
background: none; background: none;
border: none; border: none;
color: white; color: color-mix(in hsl, #ff6b6b, var(--c-text) 30%);
cursor: pointer; 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 */ .logoutBtn:hover {
@media (max-width: 1010px) { background: color-mix(in hsl, #ff6b6b, transparent 80%);
color: #ff9898;
transform: none;
}
/* ── Mobile ── */
@media (max-width: 900px) {
.navbar { .navbar {
width: 100%; 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{ .logo {
margin: auto; border-right: none;
text-align: center; padding-right: 0;
border: none; flex: 1;
} }
.burger { .burger {
display: inline-block; display: inline-flex;
} align-items: center;
justify-content: center;
.burger svg {
width: auto;
} }
.links { .links {
@@ -378,82 +294,71 @@
left: 0; left: 0;
right: 0; right: 0;
top: 100%; top: 100%;
flex-direction: column; flex-direction: column;
gap: 0.6rem; align-items: stretch;
padding: 1rem 1.2rem; gap: 0.3rem;
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;
padding: 0; 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; opacity: 0;
transition: max-height 0.4s ease, opacity 0.3s ease, padding 0.3s ease;
} }
.links.show { .links.show {
max-height: 100vh; max-height: 80vh;
padding: 1rem 1.2rem;
background-color: var(--c-boxes);
opacity: 1; opacity: 1;
padding: 0.75rem;
overflow-y: auto;
} }
.dropdownItem {
width: 100%;
}
.linkButton { .linkButton {
background-color: var(--c-background-light);
width: 100%; width: 100%;
align-items: center; justify-content: space-between;
margin:auto; padding: 0.75em 1em;
border-radius: 0.75rem;
display: flex; background: color-mix(in hsl, var(--c-background-light), transparent 40%);
align-items: center; color: var(--c-text);
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;
} }
.linkSimple { .linkSimple {
margin: var(--nav-margin-y) auto; width: 100%;
padding: 0.75em 1em;
border-radius: 0.75rem;
justify-content: flex-start;
} }
.dropdown { .dropdown {
position: relative; position: relative;
top: 0; top: 0;
left: 0; left: 0;
transform: none;
opacity: 1;
pointer-events: auto;
border: none; border: none;
box-shadow: 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%; 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;
} }
} }

View File

@@ -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 (
<div style={{
display: "flex", alignItems: "center", justifyContent: "space-between",
padding: "0.65rem 1rem", borderRadius: "0.6rem",
background: "color-mix(in hsl, var(--c-background), transparent 15%)",
border: "1px solid color-mix(in hsl, var(--c-lines), transparent 70%)",
}}>
<div>
<span style={{ fontSize: "0.7rem", color: "color-mix(in hsl, var(--c-text), transparent 50%)", textTransform: "uppercase", letterSpacing: "0.08em", display: "block", marginBottom: "0.15rem" }}>
{label}
</span>
<code style={{ fontSize: "1rem", fontWeight: 700, color: "var(--c-text)", fontFamily: "monospace" }}>
{value}
</code>
</div>
<button
onClick={copy}
title={copied ? "Copied!" : "Copy"}
style={{
background: copied ? "color-mix(in hsl, var(--c-other), transparent 70%)" : "color-mix(in hsl, var(--c-background-light), transparent 30%)",
border: "1px solid color-mix(in hsl, var(--c-lines), transparent 60%)",
borderRadius: "0.5rem",
padding: "0.35em 0.6em",
color: copied ? "var(--c-other)" : "color-mix(in hsl, var(--c-text), transparent 30%)",
cursor: "pointer",
fontSize: "0.85rem",
transition: "all 0.2s ease",
}}
>
{copied ? <FaCheck /> : <FaCopy />}
</button>
</div>
);
}
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 (
<AnimatePresence>
{opened && (
<>
{/* Backdrop */}
<motion.div
key="backdrop"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
onClick={onClose}
style={{
position: "fixed", inset: 0, zIndex: 1000,
background: "rgba(3,29,68,0.75)",
backdropFilter: "blur(8px)",
WebkitBackdropFilter: "blur(8px)",
}}
/>
{/* Panel */}
<motion.div
key="modal"
initial={{ opacity: 0, scale: 0.9, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.92, y: 10 }}
transition={{ duration: 0.25, ease: "easeOut" }}
style={{
position: "fixed", top: "50%", left: "50%",
transform: "translate(-50%, -50%)",
zIndex: 1001,
width: "min(92vw, 420px)",
background: "var(--c-background-light)",
border: "1px solid color-mix(in hsl, var(--c-lines), transparent 60%)",
borderRadius: "1.25rem",
boxShadow: "0 24px 64px rgba(0,0,0,0.5)",
overflow: "hidden",
}}
>
{/* Header */}
<div style={{
display: "flex", alignItems: "center", justifyContent: "space-between",
padding: "1.1rem 1.4rem",
borderBottom: "1px solid color-mix(in hsl, var(--c-lines), transparent 70%)",
}}>
<span style={{ fontWeight: 800, fontSize: "1.05rem" }}>E-Commerce Demo Access</span>
<button
onClick={onClose}
style={{
background: "none", border: "none",
color: "color-mix(in hsl, var(--c-text), transparent 40%)",
cursor: "pointer", fontSize: "1rem", padding: "0.25em",
borderRadius: "0.4rem", display: "flex",
transition: "color 0.15s ease",
}}
onMouseEnter={(e) => (e.currentTarget.style.color = "var(--c-text)")}
onMouseLeave={(e) => (e.currentTarget.style.color = "color-mix(in hsl, var(--c-text), transparent 40%)")}
>
<FaTimes />
</button>
</div>
{/* Body */}
<div style={{ padding: "1.4rem", display: "flex", flexDirection: "column", gap: "1rem" }}>
<p style={{ margin: 0, color: "color-mix(in hsl, var(--c-text), transparent 30%)", fontSize: "0.88rem", lineHeight: 1.65 }}>
Explore the admin panel manage products, orders, customers, and payments.
</p>
<div style={{ display: "flex", flexDirection: "column", gap: "0.5rem" }}>
<CredRow label="Username" value="vontorCZ" />
<CredRow label="Password" value="VontorCZ1234" />
</div>
<div style={{
padding: "0.7rem 0.9rem", borderRadius: "0.6rem",
background: "color-mix(in hsl, var(--c-other), transparent 87%)",
border: "1px solid color-mix(in hsl, var(--c-other), transparent 58%)",
fontSize: "0.8rem", color: "var(--c-other)",
display: "flex", gap: "0.5rem", alignItems: "flex-start",
}}>
<span style={{ flexShrink: 0 }}></span>
<span>Demo environment data may reset. Stripe uses test mode, no real charges.</span>
</div>
<a
href="/admin"
target="_blank"
rel="noopener noreferrer"
style={{
display: "inline-flex", alignItems: "center", justifyContent: "center",
gap: "0.5rem", padding: "0.75em 1.5em", borderRadius: "9999px",
background: "linear-gradient(135deg, var(--c-other), color-mix(in hsl, var(--c-other), var(--c-boxes) 50%))",
color: "#031D44", fontWeight: 700, fontSize: "0.88rem", textDecoration: "none",
transition: "transform 0.2s ease, box-shadow 0.2s ease",
}}
onMouseEnter={(e) => {
e.currentTarget.style.transform = "scale(1.03)";
e.currentTarget.style.boxShadow = "0 0 1.2rem color-mix(in hsl, var(--c-other), transparent 45%)";
}}
onMouseLeave={(e) => {
e.currentTarget.style.transform = "scale(1)";
e.currentTarget.style.boxShadow = "none";
}}
>
Open Admin Panel <FaExternalLinkAlt style={{ fontSize: "0.75rem" }} />
</a>
</div>
</motion.div>
</>
)}
</AnimatePresence>
);
}

View File

@@ -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 (
<span style={{
display: "inline-flex",
alignItems: "center",
gap: "0.3rem",
padding: "0.25em 0.65em",
borderRadius: "9999px",
background: "color-mix(in hsl, var(--c-background), transparent 20%)",
border: "1px solid color-mix(in hsl, var(--c-lines), transparent 65%)",
fontSize: "0.75rem",
fontWeight: 600,
color: color ?? "var(--c-lines)",
}}>
{icon && <span style={{ fontSize: "0.9em", color: color ?? "var(--c-lines)" }}>{icon}</span>}
{label}
</span>
);
}
export default function ProjectsSection() {
const [demoOpen, setDemoOpen] = useState(false);
return (
<section id="projects" className="section" style={{ background: "color-mix(in hsl, var(--c-background), black 8%)" }}>
<div className="container">
{/* Header */}
<motion.div
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6 }}
viewport={{ once: true }}
style={{ textAlign: "center", marginBottom: "3.5rem" }}
>
<span style={{
display: "inline-block",
padding: "0.3em 0.9em",
borderRadius: "9999px",
background: "color-mix(in hsl, var(--c-lines), transparent 82%)",
border: "1px solid color-mix(in hsl, var(--c-lines), transparent 55%)",
color: "var(--c-lines)",
fontSize: "0.78rem",
fontWeight: 700,
letterSpacing: "0.08em",
textTransform: "uppercase",
marginBottom: "1rem",
}}>
Live Projects
</span>
<h2 style={{ fontSize: "clamp(1.9rem, 4.5vw, 3rem)", fontWeight: 800, margin: "0 0 1rem" }}>
Things I've <span className="text-rainbow">Shipped</span>
</h2>
<p style={{ color: "color-mix(in hsl, var(--c-text), transparent 35%)", maxWidth: "500px", margin: "0 auto", lineHeight: 1.7 }}>
Real applications running in production not demos, not tutorials.
</p>
</motion.div>
{/* Cards */}
<motion.div
variants={containerVariants}
initial="hidden"
whileInView="visible"
viewport={{ once: true }}
style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(300px, 1fr))", gap: "1.5rem" }}
>
{/* Social Network */}
<motion.div
variants={cardVariants}
whileHover={{ y: -8 }}
transition={{ type: "spring", stiffness: 250, damping: 20 }}
className="glass"
style={{ padding: "2rem", display: "flex", flexDirection: "column", gap: "1.2rem", position: "relative", overflow: "hidden" }}
>
{/* Glow accent top */}
<div style={{ position: "absolute", top: 0, left: 0, right: 0, height: "3px", background: "linear-gradient(90deg, var(--c-boxes), var(--c-lines))" }} />
<div style={{ display: "flex", alignItems: "center", gap: "1rem" }}>
<div style={{ width: "3rem", height: "3rem", borderRadius: "0.75rem", background: "color-mix(in hsl, var(--c-boxes), transparent 65%)", display: "flex", alignItems: "center", justifyContent: "center", fontSize: "1.4rem", color: "var(--c-lines)" }}>
<FaUsers />
</div>
<div>
<h3 style={{ margin: 0, fontSize: "1.15rem", fontWeight: 800 }}>Vontor Social</h3>
<span style={{ fontSize: "0.8rem", color: "color-mix(in hsl, var(--c-text), transparent 45%)" }}>Social network</span>
</div>
</div>
<p style={{ margin: 0, color: "color-mix(in hsl, var(--c-text), transparent 30%)", fontSize: "0.9rem", lineHeight: 1.65 }}>
Full social network posts, hubs (communities), reactions, real-time direct messaging, user profiles, and live notifications.
</p>
<div style={{ display: "flex", gap: "0.4rem", flexWrap: "wrap" }}>
<TechBadge icon={<SiDjango />} label="Django" color="#09d3ac" />
<TechBadge icon={<SiReact />} label="React" color="#61dafb" />
<TechBadge icon={<SiRedis />} label="Redis" color="#ff4438" />
<TechBadge label="WebSockets" />
</div>
<Link
to="/social/feed"
style={{
display: "inline-flex", alignItems: "center", gap: "0.5rem",
padding: "0.65em 1.4em", borderRadius: "9999px",
background: "color-mix(in hsl, var(--c-boxes), transparent 55%)",
border: "1px solid color-mix(in hsl, var(--c-lines), transparent 50%)",
color: "var(--c-text)", fontWeight: 600, fontSize: "0.88rem",
textDecoration: "none", marginTop: "auto",
transition: "background 0.2s ease, transform 0.2s ease",
}}
onMouseEnter={(e) => { 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 <FaArrowRight style={{ fontSize: "0.75rem" }} />
</Link>
</motion.div>
{/* Media Downloader */}
<motion.div
variants={cardVariants}
whileHover={{ y: -8 }}
transition={{ type: "spring", stiffness: 250, damping: 20 }}
className="glass"
style={{ padding: "2rem", display: "flex", flexDirection: "column", gap: "1.2rem", position: "relative", overflow: "hidden" }}
>
<div style={{ position: "absolute", top: 0, left: 0, right: 0, height: "3px", background: "linear-gradient(90deg, var(--c-other), var(--c-lines))" }} />
<div style={{ display: "flex", alignItems: "center", gap: "1rem" }}>
<div style={{ width: "3rem", height: "3rem", borderRadius: "0.75rem", background: "color-mix(in hsl, var(--c-other), transparent 70%)", display: "flex", alignItems: "center", justifyContent: "center", fontSize: "1.4rem", color: "var(--c-other)" }}>
<FaDownload />
</div>
<div>
<h3 style={{ margin: 0, fontSize: "1.15rem", fontWeight: 800 }}>Media Downloader</h3>
<span style={{ fontSize: "0.8rem", color: "color-mix(in hsl, var(--c-text), transparent 45%)" }}>yt-dlp powered</span>
</div>
</div>
<p style={{ margin: 0, color: "color-mix(in hsl, var(--c-text), transparent 30%)", fontSize: "0.9rem", lineHeight: 1.65 }}>
Download video and audio from 1000+ sites. Select quality, format, and subtitles. Async processing via Celery no timeouts, even for long videos.
</p>
<div style={{ display: "flex", gap: "0.4rem", flexWrap: "wrap" }}>
<TechBadge icon={<SiPython />} label="yt-dlp" color="#3776ab" />
<TechBadge label="Celery" />
<TechBadge icon={<SiRedis />} label="Redis" color="#ff4438" />
<TechBadge icon={<SiReact />} label="React" color="#61dafb" />
</div>
<Link
to="/apps/downloader"
style={{
display: "inline-flex", alignItems: "center", gap: "0.5rem",
padding: "0.65em 1.4em", borderRadius: "9999px",
background: "color-mix(in hsl, var(--c-other), transparent 75%)",
border: "1px solid color-mix(in hsl, var(--c-other), transparent 50%)",
color: "var(--c-text)", fontWeight: 600, fontSize: "0.88rem",
textDecoration: "none", marginTop: "auto",
transition: "background 0.2s ease, transform 0.2s ease",
}}
onMouseEnter={(e) => { 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 <FaArrowRight style={{ fontSize: "0.75rem" }} />
</Link>
</motion.div>
{/* E-Commerce */}
<motion.div
variants={cardVariants}
whileHover={{ y: -8 }}
transition={{ type: "spring", stiffness: 250, damping: 20 }}
className="glass"
style={{ padding: "2rem", display: "flex", flexDirection: "column", gap: "1.2rem", position: "relative", overflow: "hidden" }}
>
<div style={{ position: "absolute", top: 0, left: 0, right: 0, height: "3px", background: "linear-gradient(90deg, #635bff, var(--c-lines))" }} />
<div style={{ display: "flex", alignItems: "center", gap: "1rem" }}>
<div style={{ width: "3rem", height: "3rem", borderRadius: "0.75rem", background: "color-mix(in hsl, #635bff, transparent 70%)", display: "flex", alignItems: "center", justifyContent: "center", fontSize: "1.4rem", color: "#918bf0" }}>
<FaShoppingCart />
</div>
<div>
<h3 style={{ margin: 0, fontSize: "1.15rem", fontWeight: 800 }}>E-Commerce Store</h3>
<span style={{ fontSize: "0.8rem", color: "color-mix(in hsl, var(--c-text), transparent 45%)" }}>Demo available</span>
</div>
</div>
<p style={{ margin: 0, color: "color-mix(in hsl, var(--c-text), transparent 30%)", fontSize: "0.9rem", lineHeight: 1.65 }}>
Full product catalog, order management, customer accounts, and payment processing. Separate admin dashboard for store management.
</p>
<div style={{ display: "flex", gap: "0.4rem", flexWrap: "wrap" }}>
<TechBadge icon={<SiDjango />} label="Django" color="#09d3ac" />
<TechBadge icon={<SiStripe />} label="Stripe" color="#635bff" />
<TechBadge label="ČSOB" color="var(--c-other)" />
<TechBadge label="DRF" />
</div>
<button
onClick={() => 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 <FaArrowRight style={{ fontSize: "0.75rem" }} />
</button>
</motion.div>
</motion.div>
</div>
<DemoModal opened={demoOpen} onClose={() => setDemoOpen(false)} />
</section>
);
}

View File

@@ -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: <SiDocker />, label: "Docker", color: "#2496ed" },
{ icon: <SiNginx />, label: "Nginx", color: "#009900" },
{ icon: <SiPython />, label: "Python", color: "#3776ab" },
{ icon: <SiDjango />, label: "Django", color: "#09d3ac" },
{ icon: <SiReact />, label: "React", color: "#61dafb" },
{ icon: <SiDebian />, label: "Debian", color: "#a80030" },
{ icon: <SiPostgresql />, label: "PostgreSQL", color: "#336791" },
{ icon: <SiRedis />, label: "Redis", color: "#ff4438" },
{ icon: <SiCelery />, label: "Celery", color: "#37b24d" },
{ icon: null, label: "Gorse.io", color: "var(--c-other)" },
{ icon: <FaBrain />, label: "Ollama", color: "var(--c-lines)", experimental: true },
];
function TechItem({ icon, label, color, experimental }: { icon: React.ReactNode; label: string; color: string; experimental?: boolean }) {
return (
<motion.div
whileHover={{ scale: 1.2, y: -4 }}
transition={{ type: "spring", stiffness: 300, damping: 18 }}
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: "0.5rem",
padding: "0.75rem 1.25rem",
borderRadius: "1rem",
cursor: "default",
position: "relative",
}}
>
{experimental && (
<span style={{
position: "absolute",
top: 0,
right: 0,
fontSize: "0.6rem",
fontWeight: 700,
padding: "0.15em 0.4em",
borderRadius: "9999px",
background: "color-mix(in hsl, var(--c-lines), transparent 70%)",
color: "var(--c-lines)",
letterSpacing: "0.04em",
lineHeight: 1.4,
}}>
exp
</span>
)}
<div style={{ fontSize: "2.5rem", color, filter: `drop-shadow(0 0 0.4rem color-mix(in hsl, ${color}, transparent 65%))` }}>
{icon ?? <span style={{ fontSize: "1rem", fontWeight: 800, color }}>{label.split(".")[0]}</span>}
</div>
<span style={{ fontSize: "0.78rem", fontWeight: 600, color: "color-mix(in hsl, var(--c-text), transparent 35%)", whiteSpace: "nowrap" }}>
{label}
</span>
</motion.div>
);
}
export default function TechMarquee() {
const doubled = [...TECHS, ...TECHS];
return (
<section className="section" style={{ background: "var(--c-background)", overflow: "hidden" }}>
<div className="container" style={{ marginBottom: "2rem" }}>
<motion.div
initial={{ opacity: 0, y: 24 }}
whileInView={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6 }}
viewport={{ once: true }}
style={{ textAlign: "center" }}
>
<span style={{
display: "inline-block",
padding: "0.3em 0.9em",
borderRadius: "9999px",
background: "color-mix(in hsl, var(--c-boxes), transparent 78%)",
border: "1px solid color-mix(in hsl, var(--c-lines), transparent 58%)",
color: "var(--c-lines)",
fontSize: "0.78rem",
fontWeight: 700,
letterSpacing: "0.08em",
textTransform: "uppercase",
marginBottom: "1rem",
}}>
Technology Stack
</span>
<h2 style={{ fontSize: "clamp(1.9rem, 4.5vw, 3rem)", fontWeight: 800, margin: 0 }}>
Built <span className="text-rainbow">With</span>
</h2>
</motion.div>
</div>
{/* Fade edges */}
<div style={{ position: "relative" }}>
<div style={{
position: "absolute", left: 0, top: 0, bottom: 0, width: "8rem", zIndex: 2,
background: "linear-gradient(to right, var(--c-background), transparent)",
pointerEvents: "none",
}} />
<div style={{
position: "absolute", right: 0, top: 0, bottom: 0, width: "8rem", zIndex: 2,
background: "linear-gradient(to left, var(--c-background), transparent)",
pointerEvents: "none",
}} />
{/* Marquee track */}
<div
style={{ overflow: "hidden" }}
onMouseEnter={(e) => {
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";
}}
>
<div
className="animate-marquee"
style={{ display: "flex", width: "max-content" }}
>
{doubled.map((tech, i) => (
<TechItem key={`${tech.label}-${i}`} {...tech} />
))}
</div>
</div>
</div>
</section>
);
}

View File

@@ -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: <FaCode />,
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: <FaCreditCard />,
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: <FaBolt />,
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: <FaServer />,
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: <FaExchangeAlt />,
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: <FaBrain />,
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 (
<section id="webdev" className="section" style={{ background: "var(--c-background)" }}>
<div className="container">
{/* Header */}
<motion.div
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, ease: "easeOut" }}
viewport={{ once: true }}
style={{ textAlign: "center", marginBottom: "3.5rem" }}
>
<span style={{
display: "inline-block",
padding: "0.3em 0.9em",
borderRadius: "9999px",
background: "color-mix(in hsl, var(--c-boxes), transparent 75%)",
border: "1px solid color-mix(in hsl, var(--c-lines), transparent 55%)",
color: "var(--c-lines)",
fontSize: "0.78rem",
fontWeight: 700,
letterSpacing: "0.08em",
textTransform: "uppercase",
marginBottom: "1rem",
}}>
Web Development
</span>
<h2 style={{ fontSize: "clamp(1.9rem, 4.5vw, 3rem)", fontWeight: 800, margin: "0 0 1rem" }}>
What I <span className="text-rainbow">Build</span>
</h2>
<p style={{ color: "color-mix(in hsl, var(--c-text), transparent 35%)", maxWidth: "520px", margin: "0 auto", lineHeight: 1.7 }}>
Full-stack solutions from concept to deployment reliable, secure, and ready to scale.
</p>
</motion.div>
{/* Grid */}
<motion.div
variants={containerVariants}
initial="hidden"
whileInView="visible"
viewport={{ once: true }}
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fit, minmax(290px, 1fr))",
gap: "1.2rem",
}}
>
{FEATURES.map(({ icon, color, title, desc }) => (
<motion.div
key={title}
variants={cardVariants}
whileHover={{ scale: 1.03, y: -4 }}
transition={{ type: "spring", stiffness: 280, damping: 22 }}
className="glass"
style={{ padding: "1.8rem", display: "flex", flexDirection: "column", gap: "1rem", cursor: "default" }}
>
<div style={{
width: "2.8rem",
height: "2.8rem",
borderRadius: "0.75rem",
background: `color-mix(in hsl, ${color}, transparent 80%)`,
border: `1px solid color-mix(in hsl, ${color}, transparent 55%)`,
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: "1.25rem",
color: color,
}}>
{icon}
</div>
<div>
<h3 style={{ margin: "0 0 0.5rem", fontSize: "1.05rem", fontWeight: 700 }}>{title}</h3>
<p style={{ margin: 0, color: "color-mix(in hsl, var(--c-text), transparent 35%)", fontSize: "0.9rem", lineHeight: 1.65 }}>{desc}</p>
</div>
</motion.div>
))}
</motion.div>
{/* Payment logos strip */}
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, delay: 0.3 }}
viewport={{ once: true }}
style={{
marginTop: "3rem",
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: "2rem",
flexWrap: "wrap",
}}
>
<span style={{ color: "color-mix(in hsl, var(--c-text), transparent 50%)", fontSize: "0.85rem" }}>Payment integrations:</span>
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem", color: "#635bff" }}>
<SiStripe style={{ fontSize: "1.5rem" }} />
<span style={{ fontWeight: 700, fontSize: "1rem" }}>Stripe</span>
<span style={{ color: "color-mix(in hsl, var(--c-text), transparent 50%)", fontSize: "0.78rem" }}>(international)</span>
</div>
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem", color: "var(--c-other)" }}>
<span style={{ fontWeight: 700, fontSize: "1rem", fontFamily: "monospace" }}>ČSOB</span>
<span style={{ color: "color-mix(in hsl, var(--c-text), transparent 50%)", fontSize: "0.78rem" }}>(Czech market)</span>
</div>
</motion.div>
</div>
</section>
);
}

View File

@@ -11,7 +11,7 @@ interface Props {
export default function HubCard({ hub, isMember }: Props) { export default function HubCard({ hub, isMember }: Props) {
return ( return (
<Link <Link
to={`/social/hub/${hub.id}`} to={`/social/h/${hub.name}`}
className="flex items-start gap-3 rounded-xl border border-brand-lines/15 bg-brand-bgLight/30 p-3 transition-colors hover:bg-brand-lines/10 hover:border-brand-lines/30" className="flex items-start gap-3 rounded-xl border border-brand-lines/15 bg-brand-bgLight/30 p-3 transition-colors hover:bg-brand-lines/10 hover:border-brand-lines/30"
> >
<Avatar name={hub.name} src={hub.icon ?? undefined} size={44} /> <Avatar name={hub.name} src={hub.icon ?? undefined} size={44} />

View File

@@ -41,7 +41,7 @@ export default function HubHeader({ hub, isMember, isOwner, isModerator, joining
{/* Action buttons — top-right */} {/* Action buttons — top-right */}
<div className="flex justify-end gap-2 pt-2 pb-1"> <div className="flex justify-end gap-2 pt-2 pb-1">
{canManage && ( {canManage && (
<Link to={`/social/hub/${hub.id}/settings`}> <Link to={`/social/h/${hub.name}/settings`}>
<Button variant="ghost" size="sm" leftIcon={<FiSettings size={14} />}> <Button variant="ghost" size="sm" leftIcon={<FiSettings size={14} />}>
Nastavení Nastavení
</Button> </Button>

View File

@@ -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 { interface Props {
tags: HubTag[]; tags: Tags[];
activeTag?: number; activeTag?: number;
onSelect: (id: number | undefined) => void; onSelect: (id: number | undefined) => void;
} }
export default function HubTags({ tags, activeTag, onSelect }: Props) { export default function HubTags({ tags, activeTag, onSelect }: Props) {
if (tags.length === 0) return null;
return ( return (
<div className="flex flex-wrap gap-1.5 px-4 py-2 border-b border-brand-lines/10"> <div className="flex flex-wrap gap-2 border-b border-brand-lines/10 px-4 py-2">
{tags.map((tag) => { {tags.map((tag) => {
const isActive = activeTag === tag.id; const active = activeTag === tag.id;
return ( return (
<button <button
key={tag.id} key={tag.id}
type="button" type="button"
onClick={() => onSelect(isActive ? undefined : tag.id)} onClick={() => onSelect(active ? undefined : tag.id)}
className="flex items-center gap-1.5 rounded-full border px-3 py-1 text-xs font-medium transition-all" className="rounded-full border px-3 py-0.5 text-xs font-medium transition-colors"
style={ style={{
isActive borderColor: tag.color ?? undefined,
? { backgroundColor: tag.color + "33", borderColor: tag.color, color: tag.color } color: active ? "#fff" : (tag.color ?? undefined),
: { backgroundColor: "transparent", borderColor: tag.color + "55", color: tag.color + "cc" } backgroundColor: active ? (tag.color ?? "#6366f1") : (tag.color ?? "#6366f1") + "22",
} }}
> >
<span
className="h-2 w-2 shrink-0 rounded-full"
style={{ backgroundColor: tag.color }}
/>
{tag.name} {tag.name}
</button> </button>
); );

View File

@@ -126,7 +126,7 @@ export default function Post({
</Link> </Link>
{post.hub_detail && !hideHubBadge && ( {post.hub_detail && !hideHubBadge && (
<Link <Link
to={`/social/hub/${post.hub_detail.id}`} to={`/social/h/${post.hub_detail.name}`}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
className="text-xs font-medium text-brand-accent/70 hover:text-brand-accent hover:underline" className="text-xs font-medium text-brand-accent/70 hover:text-brand-accent hover:underline"
> >

View File

@@ -15,6 +15,7 @@ import { privateApi } from "@/api/privateClient";
interface Props { interface Props {
parentId?: number; parentId?: number;
hubId?: number | null; hubId?: number | null;
hubName?: string;
onPosted?: () => void; onPosted?: () => void;
} }
@@ -22,7 +23,7 @@ interface ComposerForm {
content: string; content: string;
} }
export default function PostComposer({ parentId, hubId, onPosted }: Props) { export default function PostComposer({ parentId, hubId, hubName, onPosted }: Props) {
const { t } = useTranslation("social"); const { t } = useTranslation("social");
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [rootError, setRootError] = useState<string | undefined>(); const [rootError, setRootError] = useState<string | undefined>();
@@ -119,6 +120,12 @@ export default function PostComposer({ parentId, hubId, onPosted }: Props) {
className="border-b border-brand-lines/10 px-4 py-3" className="border-b border-brand-lines/10 px-4 py-3"
noValidate noValidate
> >
{hubName && (
<h2 className="mb-2 text-base font-semibold text-brand-text">
Příspěvek v h/{hubName}
</h2>
)}
<FormErrorBanner message={rootError} className="mb-2" /> <FormErrorBanner message={rootError} className="mb-2" />
<Textarea <Textarea

View File

@@ -1,5 +1,6 @@
import { useInfiniteQuery } from "@tanstack/react-query"; import { useInfiniteQuery } from "@tanstack/react-query";
import { apiSocialHubPostsCursor, hubPostsQueryKey } from "@/api/social/hubFeed"; import { apiSocialHubPostsCursor, hubPostsQueryKey } from "@/api/social/hubFeed";
import type { HubSortOption, HubTimeOption } from "@/api/social/hubFeed";
function extractCursor(nextUrl: string | null): string | null { function extractCursor(nextUrl: string | null): string | null {
if (!nextUrl) return null; if (!nextUrl) return null;
@@ -13,17 +14,20 @@ function extractCursor(nextUrl: string | null): string | null {
interface Opts { interface Opts {
hubId: number; hubId: number;
tag?: number; sort?: HubSortOption;
time?: HubTimeOption;
start?: string;
end?: string;
enabled?: boolean; enabled?: boolean;
} }
export function useInfiniteHubPosts({ hubId, tag, enabled = true }: Opts) { export function useInfiniteHubPosts({ hubId, sort = "newest", time = "all", start, end, enabled = true }: Opts) {
const query = useInfiniteQuery({ const query = useInfiniteQuery({
queryKey: hubPostsQueryKey(hubId, tag), queryKey: hubPostsQueryKey(hubId, sort, time, start, end),
enabled: enabled && Number.isFinite(hubId), enabled: enabled && Number.isFinite(hubId),
initialPageParam: null as string | null, initialPageParam: null as string | null,
queryFn: ({ pageParam, signal }) => queryFn: ({ pageParam, signal }) =>
apiSocialHubPostsCursor({ hub: hubId, cursor: pageParam, tag }, signal), apiSocialHubPostsCursor({ hub: hubId, cursor: pageParam, sort, time, start, end }, signal),
getNextPageParam: (last) => extractCursor(last.next), getNextPageParam: (last) => extractCursor(last.next),
}); });

View File

@@ -247,3 +247,45 @@ li.custom-marker::before {
width: 1em; width: 1em;
margin-left: -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;
}

View File

@@ -23,7 +23,7 @@ export default function ChatLayout() {
<button <button
type="button" type="button"
onClick={() => setOpen((v) => !v)} onClick={() => 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"} aria-label={open ? "Hide sidebar" : "Show sidebar"}
> >
{open ? <FiChevronsLeft size={15} /> : <FiMenu size={15} />} {open ? <FiChevronsLeft size={15} /> : <FiMenu size={15} />}

View File

@@ -1,13 +1,13 @@
import { useEffect } from "react"; import { useEffect } from "react";
import PortfolioGrid from "./components/Services/Services"; import HeroSection from "@/components/home/hero/HeroSection";
import TradingGraph from "./components/Services/TradingGraph"; import DroneSection from "@/components/home/drone/DroneSection";
import DonationShop from "./components/donate/DonationShop"; import WebDevSection from "@/components/home/webdev/WebDevSection";
import ContactMeForm from "../../components/home/ContactMe/ContactMeForm"; import ProjectsSection from "@/components/home/projects/ProjectsSection";
import Services from "../../components/home/services/Services"; import TechMarquee from "@/components/home/tech/TechMarquee";
import ContactMeForm from "@/components/home/ContactMe/ContactMeForm";
export default function Home() { export default function Home() {
// Optional: keep spark effect for fun // Spark cursor on click
useEffect(() => { useEffect(() => {
const handleClick = (event: MouseEvent) => { const handleClick = (event: MouseEvent) => {
const spark = document.createElement("div"); const spark = document.createElement("div");
@@ -33,13 +33,15 @@ export default function Home() {
return ( return (
<main> <main>
<Services /> <HeroSection />
<div className="divider" /> <div className="divider" />
<PortfolioGrid /> <DroneSection />
<div className="divider" /> <div className="divider" />
<TradingGraph /> <WebDevSection />
<div className="divider" /> <div className="divider" />
<DonationShop /> <ProjectsSection />
<div className="divider" />
<TechMarquee />
<div className="divider" /> <div className="divider" />
<ContactMeForm /> <ContactMeForm />
</main> </main>

View File

@@ -1,6 +1,7 @@
import { useState } from "react"; import { useState } from "react";
import { useNavigate, useParams } from "react-router-dom"; import { useNavigate, useParams } from "react-router-dom";
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import { FiClock, FiTrendingUp } from "react-icons/fi";
import { import {
useApiSocialHubsRetrieve, useApiSocialHubsRetrieve,
getApiSocialHubsRetrieveQueryKey, getApiSocialHubsRetrieveQueryKey,
@@ -10,23 +11,36 @@ import {
import { useAuth } from "@/hooks/useAuth"; import { useAuth } from "@/hooks/useAuth";
import { useInfiniteHubPosts } from "@/hooks/useInfiniteHubPosts"; import { useInfiniteHubPosts } from "@/hooks/useInfiniteHubPosts";
import { useIntersectionLoader } from "@/hooks/useIntersectionLoader"; import { useIntersectionLoader } from "@/hooks/useIntersectionLoader";
import type { HubSortOption, HubTimeOption } from "@/api/social/hubFeed";
import HubHeader from "@/components/social/hub/HubHeader"; import HubHeader from "@/components/social/hub/HubHeader";
import HubTags from "@/components/social/hub/Tags";
import Post from "@/components/social/posts/Post"; import Post from "@/components/social/posts/Post";
import PostComposer from "@/components/social/posts/PostComposer"; import PostComposer from "@/components/social/posts/PostComposer";
import Spinner from "@/components/ui/Spinner"; import Spinner from "@/components/ui/Spinner";
import EmptyState from "@/components/ui/EmptyState"; 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() { export default function HubPage() {
const { id } = useParams<{ id: string }>(); const { name } = useParams<{ name: string }>();
const hubId = Number(id);
const navigate = useNavigate(); const navigate = useNavigate();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { user } = useAuth(); const { user } = useAuth();
const [activeTag, setActiveTag] = useState<number | undefined>(undefined); const [sort, setSort] = useState<HubSortOption>("newest");
const [time, setTime] = useState<HubTimeOption>("all");
const [customStart, setCustomStart] = useState("");
const [customEnd, setCustomEnd] = useState("");
const { data: hub, isLoading: hubLoading } = useApiSocialHubsRetrieve(String(hubId)); const { data: hub, isLoading: hubLoading } = useApiSocialHubsRetrieve(name ?? "");
const isMember = !!(user && hub?.members?.includes(user.id)); const isMember = !!(user && hub?.members?.includes(user.id));
const isOwner = !!(user && hub?.owner === user.id); const isOwner = !!(user && hub?.owner === user.id);
@@ -35,14 +49,14 @@ export default function HubPage() {
const joinMutation = useApiSocialHubsJoinCreate({ const joinMutation = useApiSocialHubsJoinCreate({
mutation: { mutation: {
onSuccess: () => { onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: getApiSocialHubsRetrieveQueryKey(String(hubId)) }); void queryClient.invalidateQueries({ queryKey: getApiSocialHubsRetrieveQueryKey(name!) });
}, },
}, },
}); });
const leaveMutation = useApiSocialHubsLeaveCreate({ const leaveMutation = useApiSocialHubsLeaveCreate({
mutation: { mutation: {
onSuccess: () => { onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: getApiSocialHubsRetrieveQueryKey(String(hubId)) }); void queryClient.invalidateQueries({ queryKey: getApiSocialHubsRetrieveQueryKey(name!) });
}, },
}, },
}); });
@@ -54,7 +68,14 @@ export default function HubPage() {
isFetchingNextPage, isFetchingNextPage,
fetchNextPage, fetchNextPage,
refetch, refetch,
} = useInfiniteHubPosts({ hubId, tag: activeTag, enabled: Number.isFinite(hubId) }); } = useInfiniteHubPosts({
hubId: hub?.id ?? 0,
sort,
time,
start: time === "custom" ? customStart : undefined,
end: time === "custom" ? customEnd : undefined,
enabled: !!hub?.id,
});
const sentinelRef = useIntersectionLoader<HTMLDivElement>( const sentinelRef = useIntersectionLoader<HTMLDivElement>(
() => { if (hasNextPage && !isFetchingNextPage) void fetchNextPage(); }, () => { if (hasNextPage && !isFetchingNextPage) void fetchNextPage(); },
@@ -76,21 +97,90 @@ export default function HubPage() {
isOwner={isOwner} isOwner={isOwner}
isModerator={isModerator} isModerator={isModerator}
joining={joinMutation.isPending || leaveMutation.isPending} joining={joinMutation.isPending || leaveMutation.isPending}
onJoin={() => joinMutation.mutate({ id: String(hubId) })} onJoin={() => joinMutation.mutate({ name: name! })}
onLeave={() => leaveMutation.mutate({ id: String(hubId) })} onLeave={() => leaveMutation.mutate({ name: name! })}
/> />
{/* Tag filter pills */}
{(hub.tags?.length ?? 0) > 0 && (
<HubTags
tags={hub.tags ?? []}
activeTag={activeTag}
onSelect={setActiveTag}
/>
)}
{/* Composer — members and owner */} {/* Composer — members and owner */}
{(isMember || isOwner) && <PostComposer hubId={hubId} onPosted={() => void refetch()} />} {(isMember || isOwner) && (
<>
<hr className="border-brand-lines/15" />
<PostComposer hubId={hub.id} hubName={hub.name} onPosted={() => void refetch()} />
</>
)}
{/* Sort + time filter bar */}
<div className="border-b border-brand-lines/10 px-4 py-2 flex flex-col gap-2">
{/* Sort toggle */}
<div className="flex gap-1">
<button
type="button"
onClick={() => 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(" ")}
>
<FiClock size={12} />
Nejnovější
</button>
<button
type="button"
onClick={() => 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(" ")}
>
<FiTrendingUp size={12} />
Nejlepší
</button>
</div>
{/* Time filter — only for "top" */}
{sort === "top" && (
<div className="flex flex-wrap gap-1">
{TIME_OPTIONS.map((opt) => (
<button
key={opt.value}
type="button"
onClick={() => 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}
</button>
))}
</div>
)}
{/* Custom date range */}
{sort === "top" && time === "custom" && (
<div className="flex items-center gap-2">
<input
type="date"
value={customStart}
onChange={(e) => 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"
/>
<span className="text-xs text-brand-text/40"></span>
<input
type="date"
value={customEnd}
onChange={(e) => 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"
/>
</div>
)}
</div>
{/* Posts */} {/* Posts */}
{postsLoading && ( {postsLoading && (

View File

@@ -36,7 +36,7 @@ export default function HubsPage() {
size="sm" size="sm"
variant="primary" variant="primary"
leftIcon={<FiPlus size={14} />} leftIcon={<FiPlus size={14} />}
onClick={() => navigate("/social/hub/create")} onClick={() => navigate("/social/h/create")}
> >
Vytvořit Vytvořit
</Button> </Button>

View File

@@ -259,7 +259,7 @@ export default function ChatRoomPage() {
return ( return (
<div className="flex h-full flex-col"> <div className="flex h-full flex-col">
<header className="flex items-center gap-3 border-b border-brand-lines/15 px-4 py-3"> <header className="flex items-center gap-3 border-b border-brand-lines/15 py-3 pl-12 pr-4">
<Avatar <Avatar
name={chat?.name ?? `chat ${chatId}`} name={chat?.name ?? `chat ${chatId}`}
src={chat?.icon ?? undefined} src={chat?.icon ?? undefined}

View File

@@ -28,7 +28,7 @@ export default function HubCreatePage() {
const mutation = useApiSocialHubsCreate({ const mutation = useApiSocialHubsCreate({
mutation: { mutation: {
onSuccess: (hub) => navigate(`/social/hub/${hub.id}`), onSuccess: (hub) => navigate(`/social/h/${hub.name}`),
onError: () => setServerError("Nepodařilo se vytvořit hub. Zkontroluj zadané hodnoty."), onError: () => setServerError("Nepodařilo se vytvořit hub. Zkontroluj zadané hodnoty."),
}, },
}); });

View File

@@ -35,9 +35,9 @@ interface GeneralForm {
is_public: boolean; is_public: boolean;
} }
function GeneralTab({ hubId }: { hubId: number }) { function GeneralTab({ hubId, hubName }: { hubId: number; hubName: string }) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { data: hub } = useApiSocialHubsRetrieve(String(hubId)); const { data: hub } = useApiSocialHubsRetrieve(hubName);
const [icon, setIcon] = useState<File | null>(null); const [icon, setIcon] = useState<File | null>(null);
const [banner, setBanner] = useState<File | null>(null); const [banner, setBanner] = useState<File | null>(null);
const [iconPreview, setIconPreview] = useState<string | null>(null); const [iconPreview, setIconPreview] = useState<string | null>(null);
@@ -54,7 +54,7 @@ function GeneralTab({ hubId }: { hubId: number }) {
const mutation = useApiSocialHubsPartialUpdate({ const mutation = useApiSocialHubsPartialUpdate({
mutation: { mutation: {
onSuccess: () => { onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: getApiSocialHubsRetrieveQueryKey(String(hubId)) }); void queryClient.invalidateQueries({ queryKey: getApiSocialHubsRetrieveQueryKey(hubName) });
}, },
}, },
}); });
@@ -75,7 +75,7 @@ function GeneralTab({ hubId }: { hubId: number }) {
form.append("is_public", String(values.is_public)); form.append("is_public", String(values.is_public));
if (icon) form.append("icon", icon); if (icon) form.append("icon", icon);
if (banner) form.append("banner", banner); if (banner) form.append("banner", banner);
mutation.mutate({ id: String(hubId), data: form as unknown as Parameters<typeof mutation.mutate>[0]["data"] }); mutation.mutate({ name: hubName, data: form as unknown as Parameters<typeof mutation.mutate>[0]["data"] });
} }
return ( return (
@@ -287,14 +287,13 @@ function TagsTab({ hubId }: { hubId: number }) {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export default function HubSettingsPage() { export default function HubSettingsPage() {
const { id } = useParams<{ id: string }>(); const { name } = useParams<{ name: string }>();
const hubId = Number(id);
const navigate = useNavigate(); const navigate = useNavigate();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { user } = useAuth(); const { user } = useAuth();
const [tab, setTab] = useState<Tab>("general"); const [tab, setTab] = useState<Tab>("general");
const { data: hub, isLoading } = useApiSocialHubsRetrieve(String(hubId)); const { data: hub, isLoading } = useApiSocialHubsRetrieve(name!);
const isOwner = !!(user && hub?.owner === user.id); const isOwner = !!(user && hub?.owner === user.id);
const isModerator = !!(user && hub?.moderators?.some((m) => m.user === user.id)); const isModerator = !!(user && hub?.moderators?.some((m) => m.user === user.id));
@@ -302,14 +301,14 @@ export default function HubSettingsPage() {
// Redirect if no permission // Redirect if no permission
useEffect(() => { useEffect(() => {
if (!isLoading && hub && !isOwner && !isModerator) { if (!isLoading && hub && !isOwner && !isModerator) {
navigate(`/social/hub/${hubId}`, { replace: true }); navigate(`/social/h/${hub.name}`, { replace: true });
} }
}, [isLoading, hub, isOwner, isModerator, hubId, navigate]); }, [isLoading, hub, isOwner, isModerator, navigate]);
const destroyMutation = useApiSocialHubsDestroy({ const destroyMutation = useApiSocialHubsDestroy({
mutation: { mutation: {
onSuccess: () => { onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: getApiSocialHubsRetrieveQueryKey(String(hubId)) }); void queryClient.invalidateQueries({ queryKey: getApiSocialHubsRetrieveQueryKey(name!) });
navigate("/social/hubs", { replace: true }); navigate("/social/hubs", { replace: true });
}, },
}, },
@@ -327,7 +326,7 @@ export default function HubSettingsPage() {
return ( return (
<div className="mx-auto max-w-xl"> <div className="mx-auto max-w-xl">
<header className="sticky top-0 z-10 flex items-center gap-3 border-b border-brand-lines/10 bg-brand-bg/80 px-4 py-3 backdrop-blur"> <header className="sticky top-0 z-10 flex items-center gap-3 border-b border-brand-lines/10 bg-brand-bg/80 px-4 py-3 backdrop-blur">
<button type="button" onClick={() => navigate(`/social/hub/${hubId}`)} <button type="button" onClick={() => navigate(`/social/h/${hub.name}`)}
className="rounded-full p-1 text-brand-text hover:bg-brand-lines/10"> className="rounded-full p-1 text-brand-text hover:bg-brand-lines/10">
<FiArrowLeft size={20} /> <FiArrowLeft size={20} />
</button> </button>
@@ -351,9 +350,9 @@ export default function HubSettingsPage() {
</div> </div>
<div className="px-4"> <div className="px-4">
{tab === "general" && <GeneralTab hubId={hubId} />} {tab === "general" && <GeneralTab hubId={hub.id} hubName={hub.name} />}
{tab === "moderators" && <ModeratorsTab hubId={hubId} />} {tab === "moderators" && <ModeratorsTab hubId={hub.id} />}
{tab === "tags" && <TagsTab hubId={hubId} />} {tab === "tags" && <TagsTab hubId={hub.id} />}
{/* Danger zone — only on general tab */} {/* Danger zone — only on general tab */}
{isOwner && tab === "general" && ( {isOwner && tab === "general" && (
@@ -367,7 +366,7 @@ export default function HubSettingsPage() {
loading={destroyMutation.isPending} loading={destroyMutation.isPending}
onClick={() => { onClick={() => {
if (confirm(`Opravdu smazat hub "${hub.name}"? Tato akce je nevratná.`)) { if (confirm(`Opravdu smazat hub "${hub.name}"? Tato akce je nevratná.`)) {
destroyMutation.mutate({ id: String(hubId) }); destroyMutation.mutate({ name: name! });
} }
}} }}
> >