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 ' *)",
"PowerShell(Get-ChildItem -Path \"c:\\\\Users\\\\bruno\\\\Documents\\\\GitHub\\\\vontor-cz\\\\frontend\\\\src\\\\components\\\\social\" -File -Recurse | Select-Object FullName, @{n='Lines';e={\\(Get-Content $_.FullName | Measure-Object -Line\\).Lines}} | Format-Table -AutoSize)",
"Bash(grep -E \"\\\\.\\(ts|tsx\\)$\")",
"Bash(grep -v \"^$\")"
"Bash(grep -v \"^$\")",
"Bash(node -e \"const r = require\\('react-icons/fa'\\); console.log\\('FaMigrateAlt' in r, 'FaBrain' in r, 'FaBolt' in r, 'FaCode' in r, 'FaCreditCard' in r, 'FaServer' in r\\);\")",
"Bash(node -e \"const r = require\\('react-icons/fa'\\); const keys = Object.keys\\(r\\).filter\\(k => k.toLowerCase\\(\\).includes\\('migrat'\\) || k.toLowerCase\\(\\).includes\\('sync'\\) || k.toLowerCase\\(\\).includes\\('exchange'\\) || k.toLowerCase\\(\\).includes\\('arrow'\\)\\).slice\\(0,15\\); console.log\\(keys.join\\('\\\\n'\\)\\);\")",
"Bash(node -e \"const r = require\\('react-icons/fa'\\); console.log\\('FaExchangeAlt' in r, 'FaSyncAlt' in r, 'FaCloudUploadAlt' in r, 'FaRandom' in r, 'FaDatabase' in r\\);\")",
"Bash(node -e \"const r = require\\('react-icons/gi'\\); console.log\\('GiStabilizer' in r, 'GiDroneBoy' in r, 'GiCctvCamera' in r, 'GiFilmProjector' in r, 'GiGyroscope' in r\\);\")",
"Bash(node -e \"const r = require\\('react-icons/si'\\); const celery = Object.keys\\(r\\).filter\\(k => k.toLowerCase\\(\\).includes\\('celery'\\) || k.toLowerCase\\(\\).includes\\('worker'\\) || k.toLowerCase\\(\\).includes\\('task'\\)\\).slice\\(0,10\\); console.log\\(celery\\);\")"
]
}
}

View File

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

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.decorators import action
from rest_framework.exceptions import PermissionDenied, ValidationError
@@ -8,7 +12,7 @@ from drf_spectacular.utils import extend_schema, extend_schema_view, OpenApiPara
from rest_framework.permissions import IsAuthenticated
from social.hubs.models import Tags
from vontor_cz.pagination import CreatedCursorPagination
from vontor_cz.pagination import CreatedCursorPagination, TopPostsCursorPagination
from .models import Post, PostContent, PostVote, PostSave
from .permissions import CanDeletePost, IsPostAuthorOnly
from .serializers import PostSerializer, PostContentSerializer, PostVoteSerializer, TagAttachSerializer
@@ -78,6 +82,51 @@ class PostViewSet(viewsets.ModelViewSet):
def perform_create(self, serializer):
serializer.save(author=self.request.user)
_TIME_WINDOWS = {
'1h': timedelta(hours=1),
'6h': timedelta(hours=6),
'day': timedelta(days=1),
'week': timedelta(weeks=1),
'month': timedelta(days=30),
'year': timedelta(days=365),
}
def _get_cutoff(self, time_param):
"""Return a datetime cutoff for the given time window, or None for 'all'."""
if time_param in self._TIME_WINDOWS:
return timezone.now() - self._TIME_WINDOWS[time_param]
return None
def list(self, request, *args, **kwargs):
sort = request.query_params.get('sort', 'newest')
time_param = request.query_params.get('time', 'all')
qs = self.filter_queryset(self.get_queryset())
# Time filter
if time_param == 'custom':
start = request.query_params.get('start')
end = request.query_params.get('end')
if start:
qs = qs.filter(created_at__date__gte=start)
if end:
qs = qs.filter(created_at__date__lte=end)
else:
cutoff = self._get_cutoff(time_param)
if cutoff:
qs = qs.filter(created_at__gte=cutoff)
if sort == 'top':
qs = qs.annotate(vote_score=Coalesce(Sum('votes__vote'), 0)).order_by('-vote_score', '-id')
paginator = TopPostsCursorPagination()
else:
qs = qs.order_by('-created_at')
paginator = CreatedCursorPagination()
page = paginator.paginate_queryset(qs, request, view=self)
ser = PostSerializer(page, many=True, context={'request': request})
return paginator.get_paginated_response(ser.data)
# ------------------------------------------------------------------
# Media upload action
# ------------------------------------------------------------------

View File

@@ -18,6 +18,15 @@ class CreatedCursorPagination(CursorPagination):
max_page_size = 100
class TopPostsCursorPagination(CursorPagination):
"""Cursor pagination ordered by vote score descending, then by id descending as tiebreaker."""
page_size = 20
ordering = ('-vote_score', '-id')
cursor_query_param = 'cursor'
page_size_query_param = 'page_size'
max_page_size = 100
class CreatedAscCursorPagination(CursorPagination):
"""Cursor pagination ordered by `created_at` (oldest first).

View File

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

View File

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

View File

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

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

View File

@@ -1,376 +1,292 @@
/* ── Navbar ── */
.navbar {
width: 50%;
width: max-content;
margin: 0;
margin-left: auto;
margin-right: auto;
padding: 0 2em;
background-color: var(--c-boxes);
max-width: calc(100% - 2rem);
margin: 0 auto;
padding: 0.6em 2em;
/* Glass pill */
background: color-mix(in hsl, var(--c-background-light), transparent 35%);
backdrop-filter: blur(20px) saturate(1.4);
-webkit-backdrop-filter: blur(20px) saturate(1.4);
border: 1px solid color-mix(in hsl, var(--c-lines), transparent 65%);
color: white;
font-family: "Roboto Mono", monospace;
font-family: "Inter", ui-sans-serif, system-ui, sans-serif;
display: flex;
justify-content: space-between;
align-items: center;
position: sticky;
top: 1rem;
z-index: 100;
gap: 0.5em;
border-radius: 9999px;
--nav-margin-y: 0.75em;
transition: background 0.4s ease, box-shadow 0.4s ease, border-color 0.4s ease;
}
.scrolled {
background: color-mix(in hsl, var(--c-background-light), transparent 10%);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
border-color: color-mix(in hsl, var(--c-lines), transparent 40%);
}
.mobileNavOpen {
border-radius: 1.5rem;
top: 0;
z-index: 50;
gap: 1em;
border-bottom-left-radius: 2em;
border-bottom-right-radius: 2em;
--nav-margin-y: 1em;
opacity: 0.95;
transition: all 0.3s ease-in-out;
max-width: 100%;
width: 100%;
}
.mobileNavOpen{
border-radius: 0;
}
/* Brand */
/* ── Brand ── */
.logo {
padding-right: 1em;
border-right: 0.2em solid var(--c-lines);
padding-right: 1.5em;
border-right: 1px solid color-mix(in hsl, var(--c-lines), transparent 55%);
flex-shrink: 0;
}
.logo a {
font-size: 1.8em;
font-weight: 700;
font-size: 1.5em;
font-weight: 800;
color: white;
text-decoration: none;
transition: text-shadow 0.25s ease-in-out;
letter-spacing: -0.02em;
transition: color 0.25s ease, text-shadow 0.25s ease;
}
.logo a:hover {
text-shadow: 0.25em 0.25em 0.2em var(--c-text);
color: var(--c-text);
text-shadow: 0 0 1rem color-mix(in hsl, var(--c-text), transparent 40%);
}
/* Burger */
/* ── Burger ── */
.burger {
display: none;
background: none;
border: none;
color: white;
font-size: 1.6em;
border: 1px solid color-mix(in hsl, var(--c-lines), transparent 60%);
border-radius: 0.6rem;
color: var(--c-text);
font-size: 1.2em;
padding: 0.3em 0.5em;
cursor: pointer;
flex-shrink: 0;
transition: background 0.2s;
}
.burger:hover {
background: color-mix(in hsl, var(--c-boxes), transparent 60%);
}
/* Links container */
/* ── Links container ── */
.links {
display: flex;
gap: 3em;
gap: 0.5em;
align-items: center;
justify-content: space-around;
width: -webkit-fill-available;
justify-content: center;
}
/* Simple link */
/* ── Simple link ── */
.linkSimple {
color: var(--c-text);
color: color-mix(in hsl, var(--c-text), transparent 20%);
text-decoration: none;
font-size: 1.05em;
transition: transform 0.15s;
display: flex;
flex-direction: row;
font-size: 0.95em;
font-weight: 500;
display: inline-flex;
align-items: center;
}
/* TEXT SIZE UNIFICATION */
.linkSimple,
.user,
.linkButton {
font-size: 1.25em;
color: white;
}
.dropdown a {
font-size: 1.1em;
color: var(--c-text);
}
.linkSimple:hover {
transform: scale(1.08);
}
/* Link item with dropdown */
.linkItem {
gap: 0.35em;
padding: 0.45em 0.9em;
border-radius: 9999px;
transition: color 0.2s ease, background 0.2s ease;
position: relative;
}
/* Unified dropdown container */
.linkSimple:hover {
color: white;
background: color-mix(in hsl, var(--c-boxes), transparent 70%);
}
/* ── Dropdown item wrapper ── */
.dropdownItem {
position: relative;
}
/* ── Dropdown trigger button ── */
.linkButton {
background: none;
border: none;
color: color-mix(in hsl, var(--c-text), transparent 20%);
font-size: 0.95em;
font-weight: 500;
cursor: pointer;
display: flex;
flex-direction: row;
display: inline-flex;
align-items: center;
gap: 0.4rem;
margin: var(--nav-margin-y) auto;
width: max-content;
gap: 0.35em;
padding: 0.45em 0.9em;
border-radius: 9999px;
transition: color 0.2s ease, background 0.2s ease;
white-space: nowrap;
}
.linkButton:hover {
transform: scale(1.05);
color: white;
background: color-mix(in hsl, var(--c-boxes), transparent 70%);
transform: none;
}
/* chevron icons */
.chev {
margin-left: 0.25rem;
font-size: 0.9rem;
font-size: 0.7em;
transition: transform 0.25s ease;
}
.dropdownItem:hover .chev,
.dropdownItem:focus-within .chev {
transform: rotate(180deg);
}
.chevSmall {
margin-left: 0.25rem;
font-size: 0.75rem;
}
/* dropdown */
/* ── Dropdown panel ── */
.dropdown {
position: absolute;
top: auto;
left: 0;
width: -moz-max-content;
top: calc(100% + 0.5rem);
left: 50%;
transform: translateX(-50%) translateY(-6px);
width: max-content;
background-color: var(--c-background-light);
/* border: 1px solid var(--c-text); */
padding: 0.6rem;
/* border-radius: 0.45rem; */
border-bottom-left-radius: 1em;
border-bottom-right-radius: 1em;
display: none;
min-width: 10rem;
background: color-mix(in hsl, var(--c-background-light), transparent 10%);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid color-mix(in hsl, var(--c-lines), transparent 60%);
padding: 0.5rem;
border-radius: 1rem;
display: flex;
flex-direction: column;
gap: 0.35rem;
box-shadow: 0px 20px 24px 6px rgba(0, 0, 0, 0.35);
z-index: 49;
gap: 0.2rem;
box-shadow: 0 16px 40px rgba(0, 0, 0, 0.4);
z-index: 200;
/* Animated show */
opacity: 0;
pointer-events: none;
transition: opacity 0.2s ease, transform 0.2s ease;
}
/* show dropdown on hover or keyboard focus within */
.linkItem:hover .dropdown,
.linkItem:focus-within .dropdown,
.dropdownItem:hover .dropdown,
.dropdownItem:focus-within .dropdown {
display: flex;
opacity: 1;
pointer-events: auto;
transform: translateX(-50%) translateY(0);
}
/* nested wrapper for submenu items */
.nestedWrapper {
display: flex;
flex-direction: column;
}
/* nested toggle (button that opens nested submenu) */
.nestedToggle {
background: none;
border: none;
color: white !important;
text-align: left;
padding: 0;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 0.45rem;
width: 100%;
}
.nestedToggle:hover {
transform: scale(1.03);
}
/* Unified dropdown toggle */
.dropdownToggle {
background: none;
border: none;
color: white !important;
text-align: left;
padding: 0;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 0.45rem;
width: 100%;
}
.dropdownToggle:hover {
transform: scale(1.03);
}
/* nested submenu */
.nested {
margin-top: 0.25rem;
margin-left: 1.1rem;
display: none;
/* hidden until hover/focus within */
flex-direction: column;
gap: 0.25rem;
}
/* show nested submenu on hover/focus within */
.nestedWrapper:hover .nested,
.nestedWrapper:focus-within .nested {
display: flex;
}
/* Nested dropdown (dropdown inside dropdown) */
.dropdown .dropdown {
position: static;
border: none;
box-shadow: none;
padding-left: 0.2rem;
min-width: auto;
margin-left: 1.1rem;
}
/* links inside dropdown / nested */
.dropdown a,
.dropdown button {
color: white;
color: color-mix(in hsl, var(--c-text), transparent 15%);
text-decoration: none;
background: none;
border: none;
padding: 0.35rem 0.25rem;
padding: 0.5rem 0.75rem;
border-radius: 0.6rem;
text-align: left;
cursor: pointer;
transition: transform 0.12s;
font-size: 0.9em;
font-weight: 500;
display: inline-flex;
flex-direction: row;
align-items: center;
gap: 0.45rem;
transition: background 0.15s ease, color 0.15s ease;
width: 100%;
}
.dropdown a:hover,
.dropdown button:hover {
transform: scale(1.04);
}
/* small icons next to dropdown links */
.iconSmall {
margin-right: 0.45rem;
font-size: 0.95rem;
vertical-align: middle;
}
/* User area */
.user {
display: flex;
align-items: center;
gap: 0.6rem;
height: -webkit-fill-available;
}
.loginBtn {
width: max-content;
background: none;
border: none;
border-radius: 0;
padding: 1em;
background: color-mix(in hsl, var(--c-boxes), transparent 60%);
color: white;
font-size: 0.98rem;
transform: none;
}
/* ── Icons ── */
.iconSmall {
font-size: 0.9em;
flex-shrink: 0;
}
/* ── Login button ── */
.loginBtn {
background: linear-gradient(135deg, var(--c-other), color-mix(in hsl, var(--c-other), var(--c-boxes) 40%));
border: none;
border-radius: 9999px;
padding: 0.45em 1.1em;
color: #031D44;
font-size: 0.9em;
font-weight: 700;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 0.45rem;
}
.loginBtn svg {
font-size: 1.5rem;
gap: 0.4em;
transition: opacity 0.2s ease, transform 0.15s ease;
}
.loginBtn:hover {
background: var(--c-text);
transform: scale(1.03);
}
/* user dropdown */
.userWrapper {
height: -webkit-fill-available;
position: relative;
display: flex;
align-items: center;
}
.userWrapper .dropdown{
position: absolute;
top: 0;
left: 0;
margin-top: 3.5em;
width: max-content;
border-top-right-radius: 1em;
}
.userWrapper .dropdown a, button{
font-size: 0.9em;
}
.userButton {
display: flex;
align-items: center;
width: max-content;
gap: 0.6rem;
background: none;
border: none;
color: white;
cursor: pointer;
font-size: 1rem;
flex-wrap: wrap;
justify-content: space-between;
}
.userIcon {
font-size: inherit;
opacity: 0.9;
transform: scale(1.04);
box-shadow: 0 0 1rem color-mix(in hsl, var(--c-other), transparent 50%);
}
/* ── User avatar + username ── */
.avatar {
width: 1.8rem;
height: 1.8rem;
border-radius: 50%;
object-fit: cover;
flex-shrink: 0;
}
.username {
font-weight: 600;
font-size: 0.9em;
max-width: 8rem;
overflow: hidden;
text-overflow: ellipsis;
max-width: max-content;
text-overflow: ellipsis;
white-space: nowrap;
}
/* logout button */
/* ── Logout button ── */
.logoutBtn {
display: inline-flex;
align-items: center;
gap: 0.5rem;
background: none;
border: none;
color: white;
color: color-mix(in hsl, #ff6b6b, var(--c-text) 30%);
cursor: pointer;
border-radius: 0.6rem;
padding: 0.5rem 0.75rem;
font-size: 0.9em;
font-weight: 500;
width: 100%;
transition: background 0.15s ease, color 0.15s ease;
}
/* Responsive: mobile */
@media (max-width: 1010px) {
.logoutBtn:hover {
background: color-mix(in hsl, #ff6b6b, transparent 80%);
color: #ff9898;
transform: none;
}
/* ── Mobile ── */
@media (max-width: 900px) {
.navbar {
width: 100%;
max-width: 100%;
top: 0;
border-radius: 0;
padding: 0.7em 1.2em;
border-left: none;
border-right: none;
border-top: none;
}
.navbar .logo{
margin: auto;
text-align: center;
border: none;
.logo {
border-right: none;
padding-right: 0;
flex: 1;
}
.burger {
display: inline-block;
}
.burger svg {
width: auto;
display: inline-flex;
align-items: center;
justify-content: center;
}
.links {
@@ -378,82 +294,71 @@
left: 0;
right: 0;
top: 100%;
flex-direction: column;
gap: 0.6rem;
padding: 1rem 1.2rem;
display: none;
z-index: 40;
border-top: 1px solid rgba(255, 255, 255, 0.03);
border-bottom-left-radius: 2em;
border-bottom-right-radius: 2em;
transition: all 0.5s ease-in-out;
max-height: 0;
display: flex;
overflow: hidden;
align-items: stretch;
gap: 0.3rem;
padding: 0;
background: color-mix(in hsl, var(--c-background-light), transparent 5%);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border-bottom: 1px solid color-mix(in hsl, var(--c-lines), transparent 60%);
border-bottom-left-radius: 1.5rem;
border-bottom-right-radius: 1.5rem;
overflow: hidden;
max-height: 0;
opacity: 0;
transition: max-height 0.4s ease, opacity 0.3s ease, padding 0.3s ease;
}
.links.show {
max-height: 100vh;
padding: 1rem 1.2rem;
background-color: var(--c-boxes);
max-height: 80vh;
opacity: 1;
padding: 0.75rem;
overflow-y: auto;
}
.linkButton{
background-color: var(--c-background-light);
.dropdownItem {
width: 100%;
align-items: center;
margin:auto;
display: flex;
align-items: center;
justify-content: center;
padding: 1em;
transition: all 0.2s ease-in-out;
}
.linkButton:hover{
transform: none !important;
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
.linkButton {
width: 100%;
justify-content: space-between;
padding: 0.75em 1em;
border-radius: 0.75rem;
background: color-mix(in hsl, var(--c-background-light), transparent 40%);
color: var(--c-text);
}
.linkSimple{
margin: var(--nav-margin-y) auto;
.linkSimple {
width: 100%;
padding: 0.75em 1em;
border-radius: 0.75rem;
justify-content: flex-start;
}
.dropdown {
position: relative;
top: 0;
left: 0;
transform: none;
opacity: 1;
pointer-events: auto;
border: none;
box-shadow: none;
padding-left: 0.2rem;
background: color-mix(in hsl, var(--c-background), transparent 20%);
border-radius: 0.75rem;
margin-top: 0.25rem;
}
.dropdownItem:hover .dropdown,
.dropdownItem:focus-within .dropdown {
transform: none;
}
.loginBtn {
width: 100%;
align-items: center;
justify-content: center;
padding: 0.75em 1em;
}
.dropdownItem{
width: 100%;
}
.nested {
margin-left: 0.6rem;
}
.dropdown .dropdown {
margin-left: 0.6rem;
}
.userButton .username{
display: none;
}
}
}

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) {
return (
<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"
>
<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 */}
<div className="flex justify-end gap-2 pt-2 pb-1">
{canManage && (
<Link to={`/social/hub/${hub.id}/settings`}>
<Link to={`/social/h/${hub.name}/settings`}>
<Button variant="ghost" size="sm" leftIcon={<FiSettings size={14} />}>
Nastavení
</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 {
tags: HubTag[];
tags: Tags[];
activeTag?: number;
onSelect: (id: number | undefined) => void;
}
export default function HubTags({ tags, activeTag, onSelect }: Props) {
if (tags.length === 0) return null;
return (
<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) => {
const isActive = activeTag === tag.id;
const active = activeTag === tag.id;
return (
<button
key={tag.id}
type="button"
onClick={() => onSelect(isActive ? undefined : tag.id)}
className="flex items-center gap-1.5 rounded-full border px-3 py-1 text-xs font-medium transition-all"
style={
isActive
? { backgroundColor: tag.color + "33", borderColor: tag.color, color: tag.color }
: { backgroundColor: "transparent", borderColor: tag.color + "55", color: tag.color + "cc" }
}
onClick={() => onSelect(active ? undefined : tag.id)}
className="rounded-full border px-3 py-0.5 text-xs font-medium transition-colors"
style={{
borderColor: tag.color ?? undefined,
color: active ? "#fff" : (tag.color ?? undefined),
backgroundColor: active ? (tag.color ?? "#6366f1") : (tag.color ?? "#6366f1") + "22",
}}
>
<span
className="h-2 w-2 shrink-0 rounded-full"
style={{ backgroundColor: tag.color }}
/>
{tag.name}
</button>
);

View File

@@ -126,7 +126,7 @@ export default function Post({
</Link>
{post.hub_detail && !hideHubBadge && (
<Link
to={`/social/hub/${post.hub_detail.id}`}
to={`/social/h/${post.hub_detail.name}`}
onClick={(e) => e.stopPropagation()}
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 {
parentId?: number;
hubId?: number | null;
hubName?: string;
onPosted?: () => void;
}
@@ -22,7 +23,7 @@ interface ComposerForm {
content: string;
}
export default function PostComposer({ parentId, hubId, onPosted }: Props) {
export default function PostComposer({ parentId, hubId, hubName, onPosted }: Props) {
const { t } = useTranslation("social");
const queryClient = useQueryClient();
const [rootError, setRootError] = useState<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"
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" />
<Textarea

View File

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

View File

@@ -247,3 +247,45 @@ li.custom-marker::before {
width: 1em;
margin-left: -1em;
}
/* ── Landing page animations ── */
@keyframes marquee {
from { transform: translateX(0); }
to { transform: translateX(-50%); }
}
@keyframes float {
0%, 100% { transform: translateY(0px); }
50% { transform: translateY(-18px); }
}
@keyframes spin-slow {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
@keyframes pulse-glow {
0%, 100% { box-shadow: 0 0 0.5rem color-mix(in hsl, var(--c-other), transparent 60%); }
50% { box-shadow: 0 0 1.5rem color-mix(in hsl, var(--c-other), transparent 30%); }
}
@keyframes bounce-y {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(8px); }
}
@keyframes gradient-shift {
0% { background-position: 0% 50%; }
50% { background-position: 100% 50%; }
100% { background-position: 0% 50%; }
}
.animate-marquee { animation: marquee 30s linear infinite; }
.animate-float { animation: float 5s ease-in-out infinite; }
.animate-spin-slow { animation: spin-slow 20s linear infinite; }
.animate-pulse-glow { animation: pulse-glow 2.5s ease-in-out infinite; }
.animate-bounce-y { animation: bounce-y 1.4s ease-in-out infinite; }
.animate-gradient {
background-size: 200% 200%;
animation: gradient-shift 4s ease infinite;
}

View File

@@ -23,7 +23,7 @@ export default function ChatLayout() {
<button
type="button"
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"}
>
{open ? <FiChevronsLeft size={15} /> : <FiMenu size={15} />}

View File

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

View File

@@ -1,6 +1,7 @@
import { useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { useQueryClient } from "@tanstack/react-query";
import { FiClock, FiTrendingUp } from "react-icons/fi";
import {
useApiSocialHubsRetrieve,
getApiSocialHubsRetrieveQueryKey,
@@ -10,23 +11,36 @@ import {
import { useAuth } from "@/hooks/useAuth";
import { useInfiniteHubPosts } from "@/hooks/useInfiniteHubPosts";
import { useIntersectionLoader } from "@/hooks/useIntersectionLoader";
import type { HubSortOption, HubTimeOption } from "@/api/social/hubFeed";
import HubHeader from "@/components/social/hub/HubHeader";
import HubTags from "@/components/social/hub/Tags";
import Post from "@/components/social/posts/Post";
import PostComposer from "@/components/social/posts/PostComposer";
import Spinner from "@/components/ui/Spinner";
import EmptyState from "@/components/ui/EmptyState";
const TIME_OPTIONS: { value: HubTimeOption; label: string }[] = [
{ value: "1h", label: "1 h" },
{ value: "6h", label: "6 h" },
{ value: "day", label: "Dnes" },
{ value: "week", label: "Týden" },
{ value: "month", label: "Měsíc" },
{ value: "year", label: "Rok" },
{ value: "all", label: "Vše" },
{ value: "custom",label: "Vlastní" },
];
export default function HubPage() {
const { id } = useParams<{ id: string }>();
const hubId = Number(id);
const { name } = useParams<{ name: string }>();
const navigate = useNavigate();
const queryClient = useQueryClient();
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 isOwner = !!(user && hub?.owner === user.id);
@@ -35,14 +49,14 @@ export default function HubPage() {
const joinMutation = useApiSocialHubsJoinCreate({
mutation: {
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: getApiSocialHubsRetrieveQueryKey(String(hubId)) });
void queryClient.invalidateQueries({ queryKey: getApiSocialHubsRetrieveQueryKey(name!) });
},
},
});
const leaveMutation = useApiSocialHubsLeaveCreate({
mutation: {
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: getApiSocialHubsRetrieveQueryKey(String(hubId)) });
void queryClient.invalidateQueries({ queryKey: getApiSocialHubsRetrieveQueryKey(name!) });
},
},
});
@@ -54,7 +68,14 @@ export default function HubPage() {
isFetchingNextPage,
fetchNextPage,
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>(
() => { if (hasNextPage && !isFetchingNextPage) void fetchNextPage(); },
@@ -76,21 +97,90 @@ export default function HubPage() {
isOwner={isOwner}
isModerator={isModerator}
joining={joinMutation.isPending || leaveMutation.isPending}
onJoin={() => joinMutation.mutate({ id: String(hubId) })}
onLeave={() => leaveMutation.mutate({ id: String(hubId) })}
onJoin={() => joinMutation.mutate({ name: name! })}
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 */}
{(isMember || isOwner) && (
<>
<hr className="border-brand-lines/15" />
<PostComposer hubId={hub.id} hubName={hub.name} onPosted={() => void refetch()} />
</>
)}
{/* Composer — members and owner */}
{(isMember || isOwner) && <PostComposer hubId={hubId} 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 */}
{postsLoading && (

View File

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

View File

@@ -259,7 +259,7 @@ export default function ChatRoomPage() {
return (
<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
name={chat?.name ?? `chat ${chatId}`}
src={chat?.icon ?? undefined}

View File

@@ -28,7 +28,7 @@ export default function HubCreatePage() {
const mutation = useApiSocialHubsCreate({
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."),
},
});

View File

@@ -35,9 +35,9 @@ interface GeneralForm {
is_public: boolean;
}
function GeneralTab({ hubId }: { hubId: number }) {
function GeneralTab({ hubId, hubName }: { hubId: number; hubName: string }) {
const queryClient = useQueryClient();
const { data: hub } = useApiSocialHubsRetrieve(String(hubId));
const { data: hub } = useApiSocialHubsRetrieve(hubName);
const [icon, setIcon] = useState<File | null>(null);
const [banner, setBanner] = useState<File | null>(null);
const [iconPreview, setIconPreview] = useState<string | null>(null);
@@ -54,7 +54,7 @@ function GeneralTab({ hubId }: { hubId: number }) {
const mutation = useApiSocialHubsPartialUpdate({
mutation: {
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));
if (icon) form.append("icon", icon);
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 (
@@ -287,14 +287,13 @@ function TagsTab({ hubId }: { hubId: number }) {
// ---------------------------------------------------------------------------
export default function HubSettingsPage() {
const { id } = useParams<{ id: string }>();
const hubId = Number(id);
const { name } = useParams<{ name: string }>();
const navigate = useNavigate();
const queryClient = useQueryClient();
const { user } = useAuth();
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 isModerator = !!(user && hub?.moderators?.some((m) => m.user === user.id));
@@ -302,14 +301,14 @@ export default function HubSettingsPage() {
// Redirect if no permission
useEffect(() => {
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({
mutation: {
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: getApiSocialHubsRetrieveQueryKey(String(hubId)) });
void queryClient.invalidateQueries({ queryKey: getApiSocialHubsRetrieveQueryKey(name!) });
navigate("/social/hubs", { replace: true });
},
},
@@ -327,7 +326,7 @@ export default function HubSettingsPage() {
return (
<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">
<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">
<FiArrowLeft size={20} />
</button>
@@ -351,9 +350,9 @@ export default function HubSettingsPage() {
</div>
<div className="px-4">
{tab === "general" && <GeneralTab hubId={hubId} />}
{tab === "moderators" && <ModeratorsTab hubId={hubId} />}
{tab === "tags" && <TagsTab hubId={hubId} />}
{tab === "general" && <GeneralTab hubId={hub.id} hubName={hub.name} />}
{tab === "moderators" && <ModeratorsTab hubId={hub.id} />}
{tab === "tags" && <TagsTab hubId={hub.id} />}
{/* Danger zone — only on general tab */}
{isOwner && tab === "general" && (
@@ -367,7 +366,7 @@ export default function HubSettingsPage() {
loading={destroyMutation.isPending}
onClick={() => {
if (confirm(`Opravdu smazat hub "${hub.name}"? Tato akce je nevratná.`)) {
destroyMutation.mutate({ id: String(hubId) });
destroyMutation.mutate({ name: name! });
}
}}
>