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:
@@ -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\\);\")"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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']
|
||||
|
||||
@@ -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
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@@ -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).
|
||||
|
||||
|
||||
@@ -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 />} />
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
213
frontend/src/components/home/drone/DroneSection.tsx
Normal file
213
frontend/src/components/home/drone/DroneSection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
196
frontend/src/components/home/hero/HeroSection.tsx
Normal file
196
frontend/src/components/home/hero/HeroSection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
182
frontend/src/components/home/projects/DemoModal.tsx
Normal file
182
frontend/src/components/home/projects/DemoModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
238
frontend/src/components/home/projects/ProjectsSection.tsx
Normal file
238
frontend/src/components/home/projects/ProjectsSection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
136
frontend/src/components/home/tech/TechMarquee.tsx
Normal file
136
frontend/src/components/home/tech/TechMarquee.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
161
frontend/src/components/home/webdev/WebDevSection.tsx
Normal file
161
frontend/src/components/home/webdev/WebDevSection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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} />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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} />}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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."),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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! });
|
||||
}
|
||||
}}
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user