typed api

This commit is contained in:
nvrl committed 2026-04-26 03:14:57 +02:00
1 parent 5219a66722
commit fa37f8eb79
61 files changed
+2929 -2546

No files matched your search

+55
View File
@@ -13,6 +13,8 @@
"@emotion/styled": "^11.14.1", "@emotion/styled": "^11.14.1",
"@mui/icons-material": "^7.3.8", "@mui/icons-material": "^7.3.8",
"@mui/material": "^7.3.8", "@mui/material": "^7.3.8",
"@tanstack/react-query": "^5.100.3",
"@tanstack/react-query-devtools": "^5.100.3",
"axios": "^1.13.5", "axios": "^1.13.5",
"colorthief": "^2.6.0", "colorthief": "^2.6.0",
"i18next": "^24.2.2", "i18next": "^24.2.2",
@@ -2400,6 +2402,59 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@tanstack/query-core": {
"version": "5.100.3",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.100.3.tgz",
"integrity": "sha512-oMO1imV4qStH+GqddafkI7Q7r2ktPL7/0Mu74W1XEhfHHd3oTIrwP3OOIsbtpnnbe8y/IU+8Lm7Bi2LlMhVdNA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tanstack/query-devtools": {
"version": "5.100.3",
"resolved": "https://registry.npmjs.org/@tanstack/query-devtools/-/query-devtools-5.100.3.tgz",
"integrity": "sha512-RfLRX0R9yXYNbtt1OIIN4mObzTT8/jteljgioTMwSKRw6C84xrLjnDRIyIzMhUZxbCJHrgkXiD9IA8owiYFA9A==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tanstack/react-query": {
"version": "5.100.3",
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.100.3.tgz",
"integrity": "sha512-8Fgb4vKmBHllRHUjz3ZOwgV0v9b7cxCdN5T0iFQvvWJJVs6xvaxHERO1BclTL03bbK8vZAuXVKN3IeVS1sUdeQ==",
"license": "MIT",
"dependencies": {
"@tanstack/query-core": "5.100.3"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"react": "^18 || ^19"
}
},
"node_modules/@tanstack/react-query-devtools": {
"version": "5.100.3",
"resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-5.100.3.tgz",
"integrity": "sha512-0h2rTSS8mwLDL5m7d2seKs4Gg3XShGiLXBfSDDYNrbJcxX6AHuiqbg9xBi2bWCNyhq0dGRIMay3sE6xn1SSCOQ==",
"license": "MIT",
"dependencies": {
"@tanstack/query-devtools": "5.100.3"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"@tanstack/react-query": "^5.100.3",
"react": "^18 || ^19"
}
},
"node_modules/@testing-library/dom": { "node_modules/@testing-library/dom": {
"version": "10.4.1", "version": "10.4.1",
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
+2
View File
@@ -20,6 +20,8 @@
"@emotion/styled": "^11.14.1", "@emotion/styled": "^11.14.1",
"@mui/icons-material": "^7.3.8", "@mui/icons-material": "^7.3.8",
"@mui/material": "^7.3.8", "@mui/material": "^7.3.8",
"@tanstack/react-query": "^5.100.3",
"@tanstack/react-query-devtools": "^5.100.3",
"axios": "^1.13.5", "axios": "^1.13.5",
"colorthief": "^2.6.0", "colorthief": "^2.6.0",
"i18next": "^24.2.2", "i18next": "^24.2.2",
+16
View File
@@ -0,0 +1,16 @@
/**
* Central query-key factory. Keys live here so mutations can invalidate the
* right slices without grepping. Convention: scope first, then identifying
* params (URL-stable, not the full args object).
*/
export const queryKeys = {
dashboard: () => ['dashboard'] as const,
publicProfile: (username: string) => ['publicProfile', username] as const,
publicProfileAll: () => ['publicProfile'] as const,
flavors: (params: Record<string, unknown>) => ['flavors', params] as const,
flavorsAll: () => ['flavors'] as const,
flavorDetail: (id: number | string) => ['flavorDetail', String(id)] as const,
activeBanner: (username: string) => ['activeBanner', username] as const,
notifications: () => ['notifications'] as const,
communityFeed: (params: Record<string, unknown>) => ['communityFeed', params] as const,
};
@@ -0,0 +1,91 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import api from '../../lib/api';
const invalidateAdmin = (qc: ReturnType<typeof useQueryClient>) => {
qc.invalidateQueries({ queryKey: ['adminStats'] });
qc.invalidateQueries({ queryKey: ['adminUsers'] });
qc.invalidateQueries({ queryKey: ['adminJobs'] });
qc.invalidateQueries({ queryKey: ['adminConfig'] });
};
export const useUpdateAdminConfig = () => {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ key, value }: { key: string; value: unknown }) =>
api.patch('admin-custom/config/', { [key]: value }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['adminConfig'] });
},
});
};
export const useTriggerJob = () => {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: number) => api.post(`admin-custom/${id}/trigger_job/`),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['adminJobs'] });
},
});
};
export const useUpdateJobSchedule = () => {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: number; data: Record<string, unknown> }) =>
api.patch(`admin-custom/${id}/update_job_schedule/`, data),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['adminJobs'] });
},
});
};
export const useActivateBanner = () => {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: number) => api.post(`banners/${id}/activate/`),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['banners'] });
qc.invalidateQueries({ queryKey: ['activeBanner'] });
},
});
};
export const useToggleBannerEnabled = () => {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: number) => api.post(`banners/${id}/toggle_enabled/`),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['banners'] });
},
});
};
export const useSendTestEmail = () =>
useMutation({
mutationFn: () => api.post('admin-custom/send_test_email/'),
});
export const useToggleUserActive = () => {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ id, isActive }: { id: string | number; isActive: boolean }) =>
api.patch(`admin-custom/${id}/user_detail/`, { is_active: !isActive }),
onSuccess: (_, vars) => {
qc.invalidateQueries({ queryKey: ['adminUserDetail', String(vars.id)] });
qc.invalidateQueries({ queryKey: ['adminUsers'] });
},
});
};
export const useDeleteUser = () => {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: string | number) => api.delete(`admin-custom/${id}/user_detail/`),
onSuccess: (_, id) => {
qc.invalidateQueries({ queryKey: ['adminUserDetail', String(id)] });
qc.invalidateQueries({ queryKey: ['adminUsers'] });
invalidateAdmin(qc);
},
});
};
@@ -0,0 +1,25 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import api from '../../lib/api';
import { queryKeys } from '../keys';
export interface UpdateFlavorInput {
id: string | number;
data: Record<string, unknown> | FormData;
isFormData?: boolean;
}
export const useUpdateFlavor = () => {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ id, data, isFormData }: UpdateFlavorInput) =>
api.patch(
`flavors/${id}/`,
data,
isFormData ? { headers: { 'Content-Type': 'multipart/form-data' } } : undefined,
),
onSuccess: (_, vars) => {
qc.invalidateQueries({ queryKey: queryKeys.flavorDetail(vars.id) });
qc.invalidateQueries({ queryKey: queryKeys.flavorsAll() });
},
});
};
@@ -0,0 +1,28 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import api from '../../lib/api';
import { queryKeys } from '../keys';
import type { Notification } from '../queries/useNotifications';
export const useMarkAllNotificationsRead = () => {
const qc = useQueryClient();
return useMutation({
mutationFn: () => api.post('notifications/mark_all_read/'),
onSuccess: () => {
qc.setQueryData<Notification[]>(queryKeys.notifications(), (old) =>
old?.map((n) => ({ ...n, is_read: true })),
);
},
});
};
export const useMarkNotificationRead = () => {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: number) => api.post(`notifications/${id}/mark_read/`),
onSuccess: (_, id) => {
qc.setQueryData<Notification[]>(queryKeys.notifications(), (old) =>
old?.map((n) => (n.id === id ? { ...n, is_read: true } : n)),
);
},
});
};
@@ -0,0 +1,70 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import api from '../../lib/api';
import { queryKeys } from '../keys';
const invalidateRatingViews = (qc: ReturnType<typeof useQueryClient>) => {
qc.invalidateQueries({ queryKey: queryKeys.dashboard() });
qc.invalidateQueries({ queryKey: queryKeys.publicProfileAll() });
qc.invalidateQueries({ queryKey: queryKeys.flavorsAll() });
qc.invalidateQueries({ queryKey: ['flavorDetail'] });
qc.invalidateQueries({ queryKey: ['communityFeed'] });
};
export interface UpdateRatingInput {
id: number;
score: number;
comment: string | null;
}
export const useUpdateRating = () => {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ id, score, comment }: UpdateRatingInput) =>
api.patch(`ratings/${id}/`, { score, comment }),
onSuccess: () => invalidateRatingViews(qc),
});
};
export const useDeleteRating = () => {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: number) => api.delete(`ratings/${id}/`),
onSuccess: () => invalidateRatingViews(qc),
});
};
export interface CreateRatingInput {
flavor: number;
score: number;
comment?: string | null;
}
export const useCreateRating = () => {
const qc = useQueryClient();
return useMutation({
mutationFn: (input: CreateRatingInput) => api.post('ratings/', input),
onSuccess: () => invalidateRatingViews(qc),
});
};
export interface ReplyInput {
ratingId: number;
text: string;
}
export const useCreateReply = () => {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ ratingId, text }: ReplyInput) =>
api.post(`ratings/${ratingId}/reply/`, { text }),
onSuccess: () => invalidateRatingViews(qc),
});
};
export const useDeleteReply = () => {
const qc = useQueryClient();
return useMutation({
mutationFn: (replyId: number) => api.delete(`replies/${replyId}/`),
onSuccess: () => invalidateRatingViews(qc),
});
};
@@ -0,0 +1,20 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import api from '../../lib/api';
import { queryKeys } from '../keys';
const invalidateRatingViews = (qc: ReturnType<typeof useQueryClient>) => {
qc.invalidateQueries({ queryKey: queryKeys.dashboard() });
qc.invalidateQueries({ queryKey: queryKeys.publicProfileAll() });
qc.invalidateQueries({ queryKey: queryKeys.flavorsAll() });
qc.invalidateQueries({ queryKey: ['flavorDetail'] });
qc.invalidateQueries({ queryKey: ['communityFeed'] });
};
export const useUpdateReply = () => {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ replyId, text }: { replyId: number; text: string }) =>
api.patch(`replies/${replyId}/`, { text }),
onSuccess: () => invalidateRatingViews(qc),
});
};
@@ -0,0 +1,62 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import api from '../../lib/api';
const invalidateMe = (qc: ReturnType<typeof useQueryClient>) => {
qc.invalidateQueries({ queryKey: ['me'] });
qc.invalidateQueries({ queryKey: ['currentUserLite'] });
};
export const useUpdateAvatar = () => {
const qc = useQueryClient();
return useMutation({
mutationFn: (file: File) => {
const formData = new FormData();
formData.append('avatar', file);
return api.post<{ avatar: string }>('users/update_avatar/', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
});
},
onSuccess: () => invalidateMe(qc),
});
};
export const useUpdatePreferences = () => {
const qc = useQueryClient();
return useMutation({
mutationFn: (prefs: Record<string, unknown>) => api.patch('users/update_preferences/', prefs),
onSuccess: () => invalidateMe(qc),
});
};
export const useUpdateProfile = () => {
const qc = useQueryClient();
return useMutation({
mutationFn: (input: { username: string; email: string }) =>
api.patch<{ message?: string }>('users/update_profile/', input),
onSuccess: () => invalidateMe(qc),
});
};
export const useConfirmEmail = () => {
const qc = useQueryClient();
return useMutation({
mutationFn: (code: string) => api.post<{ email: string }>('users/confirm_email/', { code }),
onSuccess: () => invalidateMe(qc),
});
};
export const useChangePassword = () =>
useMutation({
mutationFn: (input: { old_password: string; new_password: string }) =>
api.post('users/change_password/', input),
});
export const useRequestAccountDeletion = () =>
useMutation({
mutationFn: () => api.post('users/request_account_deletion/'),
});
export const useConfirmAccountDeletion = () =>
useMutation({
mutationFn: (code: string) => api.post('users/confirm_account_deletion/', { code }),
});
@@ -0,0 +1,36 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import api from '../../lib/api';
import { queryKeys } from '../keys';
export const useFollowToggle = () => {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ userId, currentlyFollowing }: { userId: number; currentlyFollowing: boolean }) =>
api.post(`users/${userId}/${currentlyFollowing ? 'unfollow' : 'follow'}/`),
onSuccess: () => {
qc.invalidateQueries({ queryKey: queryKeys.publicProfileAll() });
},
});
};
export const useAddProfileComment = () => {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ userId, text }: { userId: number; text: string }) =>
api.post(`users/${userId}/add_comment/`, { text }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: queryKeys.publicProfileAll() });
},
});
};
export const useDeleteProfileComment = () => {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ userId, commentId }: { userId: number; commentId: number }) =>
api.delete(`users/${userId}/delete_comment/${commentId}/`),
onSuccess: () => {
qc.invalidateQueries({ queryKey: queryKeys.publicProfileAll() });
},
});
};
@@ -0,0 +1,40 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import api from '../../lib/api';
const invalidateTickets = (qc: ReturnType<typeof useQueryClient>) => {
qc.invalidateQueries({ queryKey: ['tickets'] });
};
export const useCreateTicket = () => {
const qc = useQueryClient();
return useMutation({
mutationFn: (input: { subject: string; description: string }) => api.post('tickets/', input),
onSuccess: () => invalidateTickets(qc),
});
};
export const useAddTicketMessage = () => {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ ticketId, text }: { ticketId: number; text: string }) =>
api.post(`tickets/${ticketId}/add_message/`, { text }),
onSuccess: () => invalidateTickets(qc),
});
};
export const useUpdateTicketStatus = () => {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ ticketId, status }: { ticketId: number; status: string }) =>
api.post(`tickets/${ticketId}/update_status/`, { status }),
onSuccess: () => invalidateTickets(qc),
});
};
export const useDeleteTicket = () => {
const qc = useQueryClient();
return useMutation({
mutationFn: (ticketId: number) => api.delete(`tickets/${ticketId}/`),
onSuccess: () => invalidateTickets(qc),
});
};
@@ -0,0 +1,15 @@
import { useQuery } from '@tanstack/react-query';
import api from '../../lib/api';
import { queryKeys } from '../keys';
import type { BannerConfig } from '../types';
export const useActiveBanner = (username: string) =>
useQuery({
queryKey: queryKeys.activeBanner(username),
queryFn: async () => {
const res = await api.get<BannerConfig>('banners/active/', { params: { username } });
return res.data;
},
enabled: !!username,
staleTime: 5 * 60_000,
});
+106
View File
@@ -0,0 +1,106 @@
import { useQuery } from '@tanstack/react-query';
import api from '../../lib/api';
export interface AdminRating {
id: number;
user: string;
score: number;
comment: string | null;
flavor_name: string;
}
export interface AdminReply {
id: number;
user: string;
text: string;
}
export const useAdminRating = (id: string | undefined) =>
useQuery({
queryKey: ['adminRating', id],
queryFn: async () => {
const res = await api.get<AdminRating>(`ratings/${id}/`);
return res.data;
},
enabled: !!id,
});
export const useAdminReply = (id: string | undefined) =>
useQuery({
queryKey: ['adminReply', id],
queryFn: async () => {
const res = await api.get<AdminReply>(`replies/${id}/`);
return res.data;
},
enabled: !!id,
});
export interface AdminStats {
[key: string]: unknown;
}
export interface AdminUserSummary {
id: number;
username: string;
email?: string;
is_active?: boolean;
is_superuser?: boolean;
date_joined?: string;
rating_count?: number;
}
export interface AdminJob {
id: number;
name: string;
status?: string;
schedule?: unknown;
next_run?: string | null;
}
export interface AdminConfig {
[key: string]: unknown;
}
export const useAdminStats = () =>
useQuery({
queryKey: ['adminStats'],
queryFn: async () => (await api.get<AdminStats>('admin-custom/stats/')).data,
});
export const useAdminUsers = () =>
useQuery({
queryKey: ['adminUsers'],
queryFn: async () => (await api.get<AdminUserSummary[]>('admin-custom/users/')).data,
});
export const useAdminJobs = () =>
useQuery({
queryKey: ['adminJobs'],
queryFn: async () => (await api.get<AdminJob[]>('admin-custom/jobs/')).data,
refetchInterval: 30_000,
});
export const useAdminConfig = () =>
useQuery({
queryKey: ['adminConfig'],
queryFn: async () => (await api.get<AdminConfig>('admin-custom/config/')).data,
});
export interface AdminUserDetailData {
id: number;
username: string;
email?: string;
is_active: boolean;
is_superuser?: boolean;
date_joined?: string;
ratings?: unknown[];
[key: string]: unknown;
}
export const useAdminUserDetail = (id: string | undefined) =>
useQuery({
queryKey: ['adminUserDetail', id],
queryFn: async () =>
(await api.get<AdminUserDetailData>(`admin-custom/${id}/user_detail/`)).data,
enabled: !!id,
});
+21
View File
@@ -0,0 +1,21 @@
import { useQuery } from '@tanstack/react-query';
import api from '../../lib/api';
export interface BannerListItem {
id: number;
name: string;
slug: string;
description?: string;
is_active?: boolean;
is_enabled?: boolean;
}
export const useBannersList = () =>
useQuery({
queryKey: ['banners'],
queryFn: async () => {
const res = await api.get<BannerListItem[] | { results: BannerListItem[] }>('banners/');
return Array.isArray(res.data) ? res.data : (res.data.results ?? []);
},
staleTime: 5 * 60_000,
});
@@ -0,0 +1,17 @@
import { useQuery } from '@tanstack/react-query';
import api from '../../lib/api';
import { queryKeys } from '../keys';
import type { FlavorListItem } from './useFlavorsList';
export const useCategoryFlavors = (slug: string | undefined) =>
useQuery({
queryKey: queryKeys.flavors({ category: slug ?? '' }),
queryFn: async () => {
const res = await api.get<FlavorListItem[] | { results: FlavorListItem[] }>(
`flavors/?category__slug=${slug}`,
);
const data = res.data;
return Array.isArray(data) ? data : (data.results ?? []);
},
enabled: !!slug,
});
@@ -0,0 +1,81 @@
import { useQuery } from '@tanstack/react-query';
import api from '../../lib/api';
import { queryKeys } from '../keys';
export interface FeedReply {
id: number;
user: string;
text: string;
created_at: string;
}
export interface FeedRating {
id: number;
user: string;
user_id: number;
user_avatar: string | null;
flavor: number;
flavor_name: string;
flavor_image: string | null;
category_name: string;
category_slug: string;
score: number;
comment: string;
created_at: string;
replies: FeedReply[];
}
export interface FollowedUser {
id: number;
username: string;
avatar: string | null;
}
export interface FollowedTopFlavor {
id: number;
name: string;
image_url: string | null;
average_rating: number;
}
export interface CommunityFeedResult {
ratings: FeedRating[];
totalPages: number;
}
export const useCommunityFeed = (page: number) =>
useQuery({
queryKey: queryKeys.communityFeed({ page }),
queryFn: async (): Promise<CommunityFeedResult> => {
const res = await api.get<{ results?: FeedRating[]; count?: number } | FeedRating[]>(
`ratings/feed/?page=${page}`,
);
if (Array.isArray(res.data)) {
return { ratings: res.data, totalPages: 1 };
}
const ratings = res.data.results ?? [];
const totalPages = Math.ceil((res.data.count ?? 0) / 10) || 1;
return { ratings, totalPages };
},
placeholderData: (prev) => prev,
});
export const useFollowingList = () =>
useQuery({
queryKey: ['followingList'],
queryFn: async () => {
const res = await api.get<FollowedUser[] | { results: FollowedUser[] }>(
'users/following_list/',
);
return Array.isArray(res.data) ? res.data : (res.data.results ?? []);
},
});
export const useFollowedTopFlavors = () =>
useQuery({
queryKey: ['followedTopFlavors'],
queryFn: async () => {
const res = await api.get<FollowedTopFlavor[]>('flavors/followed_top/');
return Array.isArray(res.data) ? res.data.slice(0, 5) : [];
},
});
@@ -0,0 +1,26 @@
import { useQuery } from '@tanstack/react-query';
import api from '../../lib/api';
export interface CurrentUserLite {
username: string;
is_superuser: boolean;
}
/**
* Reads /users/me/ for cases where we need a quick "is this me / am I admin"
* check outside of AuthContext. Disabled when not authenticated.
*/
export const useCurrentUserLite = () =>
useQuery({
queryKey: ['currentUserLite'],
queryFn: async () => {
const res = await api.get<CurrentUserLite>('users/me/', {
// Stale access tokens are common; never bounce a public page reader
// to /login because of a background "am I logged in?" check.
...({ skipAuthRedirect: true } as object),
});
return res.data;
},
enabled: !!localStorage.getItem('access'),
staleTime: 60_000,
});
+13
View File
@@ -0,0 +1,13 @@
import { useQuery } from '@tanstack/react-query';
import api from '../../lib/api';
import { queryKeys } from '../keys';
import type { DashboardData } from '../types';
export const useDashboard = () =>
useQuery({
queryKey: queryKeys.dashboard(),
queryFn: async () => {
const res = await api.get<DashboardData>('users/dashboard/');
return res.data;
},
});
@@ -0,0 +1,45 @@
import { useQuery } from '@tanstack/react-query';
import api from '../../lib/api';
import { queryKeys } from '../keys';
export interface FlavorDetailReply {
id: number;
user: string;
text: string;
created_at: string;
}
export interface FlavorDetailRating {
id: number;
user: string;
user_avatar: string | null;
score: number;
comment: string;
created_at: string;
replies: FlavorDetailReply[];
}
export interface FlavorDetailData {
id: number;
name: string;
category_name: string;
category_slug: string;
description: string;
average_rating: number;
user_rating: number | null;
ratings: FlavorDetailRating[];
image_url: string | null;
is_available: boolean;
is_legacy: boolean;
shop_url: string | null;
}
export const useFlavorDetail = (id: string | undefined) =>
useQuery({
queryKey: queryKeys.flavorDetail(id ?? ''),
queryFn: async () => {
const res = await api.get<FlavorDetailData>(`flavors/${id}/`);
return res.data;
},
enabled: !!id,
});
@@ -0,0 +1,50 @@
import { useQuery } from '@tanstack/react-query';
import api from '../../lib/api';
import { queryKeys } from '../keys';
export interface FlavorListRating {
id: number;
user: string;
score: number;
comment: string;
created_at: string;
}
export interface FlavorListItem {
id: number;
name: string;
category_name: string;
description: string;
average_rating: number;
user_rating: number | null;
ratings: FlavorListRating[];
image_url: string | null;
is_available: boolean;
is_legacy?: boolean;
shop_url: string | null;
}
export interface CategoryItem {
id: number;
name: string;
slug: string;
}
export const useFlavorsList = () =>
useQuery({
queryKey: queryKeys.flavors({ scope: 'all' }),
queryFn: async () => {
const res = await api.get<FlavorListItem[]>('flavors/');
return res.data;
},
});
export const useCategories = () =>
useQuery({
queryKey: ['categories'],
queryFn: async () => {
const res = await api.get<CategoryItem[]>('categories/');
return res.data;
},
staleTime: 5 * 60_000,
});
@@ -0,0 +1,98 @@
import { useQuery } from '@tanstack/react-query';
import api from '../../lib/api';
import { queryKeys } from '../keys';
export interface MainPageRating {
id: number;
user: string;
user_avatar: string | null;
flavor: number;
flavor_name: string;
flavor_image: string | null;
flavor_category?: string;
score: number;
comment: string;
created_at: string;
replies?: unknown[];
}
export interface MainPageFlavor {
id: number;
name: string;
category_name: string;
description: string;
average_rating: number;
image_url: string | null;
is_available: boolean;
is_legacy: boolean;
ratings: MainPageRating[];
}
export interface MainPageReview {
id: number;
user: string;
user_avatar: string | null;
flavor_name: string;
flavor_image: string | null;
score: number;
comment: string;
created_at: string;
flavor: number;
}
const unwrap = <T>(data: T[] | { results?: T[] }): T[] =>
Array.isArray(data) ? data : (data.results ?? []);
export const useTopFlavors = (categorySlug: string | null = null) =>
useQuery({
queryKey: queryKeys.flavors({ scope: 'top', category: categorySlug ?? '' }),
queryFn: async () => {
const url = categorySlug ? `flavors/top/?category=${categorySlug}` : 'flavors/top/';
const res = await api.get<MainPageFlavor[] | { results: MainPageFlavor[] }>(url);
return unwrap(res.data);
},
});
export const useNewestFlavors = () =>
useQuery({
queryKey: queryKeys.flavors({ scope: 'newest' }),
queryFn: async () => {
const res = await api.get<MainPageFlavor[] | { results: MainPageFlavor[] }>(
'flavors/newest/',
);
return unwrap(res.data);
},
});
export const useRecentReviews = () =>
useQuery({
queryKey: ['recentReviews'],
queryFn: async () => {
const res = await api.get<MainPageReview[] | { results: MainPageReview[] }>(
'ratings/recent/',
);
return unwrap(res.data);
},
});
export const useFollowingFeedTop = (enabled: boolean) =>
useQuery({
queryKey: ['followingFeedTop'],
queryFn: async () => {
const res = await api.get<MainPageRating[] | { results: MainPageRating[] }>('ratings/feed/');
return unwrap(res.data).slice(0, 6);
},
enabled,
});
export const useFlavorSearch = (query: string) =>
useQuery({
queryKey: queryKeys.flavors({ scope: 'search', q: query }),
queryFn: async () => {
const res = await api.get<MainPageFlavor[] | { results: MainPageFlavor[] }>(
`flavors/?search=${encodeURIComponent(query)}`,
);
return unwrap(res.data);
},
enabled: !!query,
});
+25
View File
@@ -0,0 +1,25 @@
import { useQuery } from '@tanstack/react-query';
import api from '../../lib/api';
export interface CurrentUserFull {
id: number;
username: string;
email: string;
avatar: string | null;
language?: string;
selected_banner?: number | null;
is_superuser?: boolean;
unread_notifications_count?: number;
}
export const useMe = () =>
useQuery({
queryKey: ['me'],
queryFn: async () => {
const res = await api.get<CurrentUserFull>('users/me/', {
...({ skipAuthRedirect: true } as object),
});
return res.data;
},
enabled: !!localStorage.getItem('access'),
});
@@ -0,0 +1,38 @@
import { useQuery } from '@tanstack/react-query';
import api from '../../lib/api';
import { queryKeys } from '../keys';
export interface Notification {
id: number;
actor_username: string;
actor_avatar: string | null;
notification_type:
| 'reply'
| 'mention'
| 'follow'
| 'ticket_new'
| 'ticket_reply'
| 'profile_comment';
rating: number | null;
reply: number | null;
is_read: boolean;
created_at: string;
flavor_name: string | null;
flavor_id: number | null;
profile_owner_username: string | null;
}
export const useNotificationsQuery = (enabled: boolean) =>
useQuery({
queryKey: queryKeys.notifications(),
queryFn: async () => {
const res = await api.get('notifications/', {
...({ skipAuthRedirect: true } as object),
});
const data = Array.isArray(res.data) ? res.data : res.data.results || [];
return data as Notification[];
},
enabled,
refetchInterval: 60_000,
staleTime: 30_000,
});
@@ -0,0 +1,53 @@
import { useQuery } from '@tanstack/react-query';
import api from '../../lib/api';
import { queryKeys } from '../keys';
export interface PublicProfileRating {
id: number;
flavor: number;
flavor_name: string;
flavor_image: string | null;
category_name: string;
score: number;
comment: string;
created_at: string;
}
export interface ProfileComment {
id: number;
author_username: string;
author_avatar: string | null;
text: string;
created_at: string;
}
export interface MiniUser {
id: number;
username: string;
avatar: string | null;
is_following?: boolean;
}
export interface ProfileData {
id: number;
username: string;
theme: string;
avatar: string | null;
following_count: number;
followers_count: number;
is_following: boolean;
ratings: PublicProfileRating[];
comments: ProfileComment[];
followers: MiniUser[];
following: MiniUser[];
}
export const usePublicProfile = (username: string | undefined) =>
useQuery({
queryKey: queryKeys.publicProfile(username ?? ''),
queryFn: async () => {
const res = await api.get<ProfileData>(`users/profile/${username}/`);
return res.data;
},
enabled: !!username,
});
+34
View File
@@ -0,0 +1,34 @@
import { useQuery } from '@tanstack/react-query';
import api from '../../lib/api';
export interface TicketMessage {
id: number;
username: string;
text: string;
created_at: string;
is_admin: boolean;
}
export type TicketStatus = 'open' | 'in_progress' | 'resolved' | 'closed';
export interface Ticket {
id: number;
user: number;
username: string;
user_email: string;
user_avatar: string | null;
subject: string;
description: string;
status: TicketStatus;
created_at: string;
messages: TicketMessage[];
}
export const useTickets = () =>
useQuery({
queryKey: ['tickets'],
queryFn: async () => {
const res = await api.get<Ticket[] | { results: Ticket[] }>('tickets/');
return Array.isArray(res.data) ? res.data : (res.data.results ?? []);
},
});
+60
View File
@@ -0,0 +1,60 @@
export interface Reply {
id: number;
user: string;
text: string;
created_at: string;
}
export interface RatedItem {
id: number;
flavor: number;
flavor_name: string;
flavor_image: string | null;
category_name: string;
category_slug: string;
score: number;
comment: string | null;
created_at: string;
replies: Reply[];
is_legacy?: boolean;
is_available?: boolean;
}
export interface MissingFlavor {
id: number;
name: string;
image_url: string | null;
category_name: string;
category_slug: string;
average_rating: number;
followed_average_rating?: number;
is_legacy?: boolean;
is_available?: boolean;
}
export interface DashboardData {
user: { username: string; avatar: string | null };
rated_count: number;
missing_count: number;
missing_flavors: MissingFlavor[];
my_ratings: RatedItem[];
}
export interface BannerConfig {
id: number;
name: string;
slug: string;
description: string;
is_active: boolean;
settings: Record<string, unknown> | null;
}
export interface NotificationItem {
id: number;
verb: string;
actor: string;
target: string | null;
url: string | null;
is_read: boolean;
created_at: string;
}
+6
View File
@@ -1,12 +1,16 @@
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import { QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import { ThemeProvider } from '../contexts/ThemeContext'; import { ThemeProvider } from '../contexts/ThemeContext';
import { AuthProvider } from '../contexts/AuthContext'; import { AuthProvider } from '../contexts/AuthContext';
import { NotificationProvider } from '../contexts/NotificationContext'; import { NotificationProvider } from '../contexts/NotificationContext';
import { ToastProvider } from '../contexts/ToastContext'; import { ToastProvider } from '../contexts/ToastContext';
import { ConfirmProvider } from '../contexts/ConfirmContext'; import { ConfirmProvider } from '../contexts/ConfirmContext';
import { queryClient } from './queryClient';
import '../i18n'; import '../i18n';
export const AppProviders = ({ children }: { children: ReactNode }) => ( export const AppProviders = ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={queryClient}>
<ThemeProvider> <ThemeProvider>
<AuthProvider> <AuthProvider>
<NotificationProvider> <NotificationProvider>
@@ -16,4 +20,6 @@ export const AppProviders = ({ children }: { children: ReactNode }) => (
</NotificationProvider> </NotificationProvider>
</AuthProvider> </AuthProvider>
</ThemeProvider> </ThemeProvider>
{import.meta.env.DEV && <ReactQueryDevtools initialIsOpen={false} />}
</QueryClientProvider>
); );
+18
View File
@@ -0,0 +1,18 @@
import { QueryClient } from '@tanstack/react-query';
import type { AxiosError } from 'axios';
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 30_000,
gcTime: 5 * 60_000,
refetchOnWindowFocus: true,
retry: (failureCount, error) => {
const status = (error as AxiosError | undefined)?.response?.status;
if (status === 401 || status === 403 || status === 404) return false;
return failureCount < 1;
},
},
mutations: { retry: 0 },
},
});
+27 -93
View File
@@ -1,21 +1,16 @@
import React, { useEffect, useState } from 'react'; import React from 'react';
import api from '../lib/api'; import { useActiveBanner } from '../api/queries/useActiveBanner';
import GenerativeBanner from './GenerativeBanner'; import type { BannerConfig } from '../api/types';
import NebulaBanner from './NebulaBanner'; import {
import FirefliesBanner from './FirefliesBanner'; BannerPerformanceWrapper,
import HextechBanner from './HextechBanner'; FirefliesBanner,
import HextechCorruptionBanner from './HextechCorruptionBanner'; GenerativeBanner,
import GenerativeFlowBanner from './GenerativeFlowBanner'; GenerativeFlowBanner,
import { BannerPerformanceWrapper } from './BannerPerformanceWrapper'; HextechBanner,
HextechCorruptionBanner,
interface Banner { MatrixBanner,
id: number; NebulaBanner,
name: string; } from './banners';
slug: string;
description: string;
is_active: boolean;
settings: any;
}
interface DynamicBannerProps { interface DynamicBannerProps {
username: string; username: string;
@@ -24,90 +19,29 @@ interface DynamicBannerProps {
followersCount: number; followersCount: number;
} }
const MatrixBanner: React.FC<DynamicBannerProps & { settings: Banner['settings'] | null }> = ({ const pickBanner = (
username, banner: BannerConfig | null | undefined,
settings, props: DynamicBannerProps,
}) => { ): React.ReactElement => {
const [durations] = useState(() => Array.from({ length: 20 }, () => 2 + Math.random() * 3)); const settings = banner?.settings ?? undefined;
return (
<div
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
background: 'rgba(0,0,0,0.8)',
color: '#0f0',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontFamily: 'monospace',
zIndex: 0,
overflow: 'hidden',
opacity: settings?.opacity || 0.5,
}}
>
<div>
{durations.map((dur, i) => (
<div key={i} style={{ animation: `matrixFall ${dur}s linear infinite` }}>
{username.split('').map((char, j) => (
<span key={j} style={{ display: 'block' }}>
{char}
</span>
))}
</div>
))}
</div>
<style>
{`
@keyframes matrixFall {
0% { transform: translateY(-100%); }
100% { transform: translateY(100%); }
}
`}
</style>
</div>
);
};
const pickBanner = (banner: Banner | null, props: DynamicBannerProps): React.ReactElement => {
if (!banner || banner.slug === 'generative-web') { if (!banner || banner.slug === 'generative-web') {
return <GenerativeBanner {...props} settings={banner?.settings} />; return <GenerativeBanner {...props} settings={settings} />;
} }
if (banner.slug === 'matrix') return <MatrixBanner {...props} settings={banner.settings} />; if (banner.slug === 'matrix') return <MatrixBanner {...props} settings={settings} />;
if (banner.slug === 'nebula') return <NebulaBanner {...props} settings={banner.settings} />; if (banner.slug === 'nebula') return <NebulaBanner {...props} settings={settings} />;
if (banner.slug === 'fireflies') return <FirefliesBanner {...props} settings={banner.settings} />; if (banner.slug === 'fireflies') return <FirefliesBanner {...props} settings={settings} />;
if (banner.slug === 'hextech') return <HextechBanner {...props} settings={banner.settings} />; if (banner.slug === 'hextech') return <HextechBanner {...props} settings={settings} />;
if (banner.slug === 'hextech-corruption') if (banner.slug === 'hextech-corruption')
return <HextechCorruptionBanner {...props} settings={banner.settings} />; return <HextechCorruptionBanner {...props} settings={settings} />;
if (banner.slug === 'generative-flow') if (banner.slug === 'generative-flow')
return <GenerativeFlowBanner {...props} settings={banner.settings} />; return <GenerativeFlowBanner {...props} settings={settings} />;
return <GenerativeBanner {...props} />; return <GenerativeBanner {...props} />;
}; };
const DynamicBanner: React.FC<DynamicBannerProps> = (props) => { const DynamicBanner: React.FC<DynamicBannerProps> = (props) => {
const [banner, setBanner] = useState<Banner | null>(null); const { data: banner, isLoading } = useActiveBanner(props.username);
const [loading, setLoading] = useState(true);
useEffect(() => { if (isLoading) return null;
const fetchActiveBanner = async () => {
try {
const res = await api.get('banners/active/', {
params: { username: props.username },
});
setBanner(res.data);
} catch (err) {
console.error('Failed to fetch active banner:', err);
} finally {
setLoading(false);
}
};
fetchActiveBanner();
}, [props.username]);
if (loading) return null;
return <BannerPerformanceWrapper>{pickBanner(banner, props)}</BannerPerformanceWrapper>; return <BannerPerformanceWrapper>{pickBanner(banner, props)}</BannerPerformanceWrapper>;
}; };
@@ -1,336 +0,0 @@
import React, { useRef, useEffect } from 'react';
import { alpha, useTheme } from '@mui/material';
import { useBannerFrameGate } from './BannerPerformanceWrapper';
interface GenerativeBannerProps {
username: string;
palette: string[];
ratingsCount: number;
followersCount: number;
settings?: {
nodeCountBase?: number;
connectionDist?: number;
speed?: number;
opacity?: number;
};
}
const GenerativeBanner: React.FC<GenerativeBannerProps> = ({
username,
palette,
ratingsCount,
followersCount,
settings,
}) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
const theme = useTheme();
const gate = useBannerFrameGate();
const nodeCountBase = settings?.nodeCountBase ?? 60;
const connectionDist = settings?.connectionDist ?? 140;
const speed = settings?.speed ?? 0.005;
const globalOpacity = settings?.opacity ?? 0.35;
const getSeed = (str: string) => {
let hash = 0;
for (let i = 0; i < str.length; i++) {
hash = str.charCodeAt(i) + ((hash << 5) - hash);
}
return hash;
};
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d', { alpha: true });
if (!ctx) return;
let animationFrameId: number;
const seed = getSeed(username);
// Configuration - High density for "complex web" feel
const nodeCount = Math.min(nodeCountBase + Math.floor(ratingsCount * 1.5), 120);
const colors =
palette.length >= 2
? palette
: [theme.palette.primary.main, theme.palette.secondary.main, theme.palette.info.main];
interface Node {
x: number;
y: number;
vx: number;
vy: number;
baseX: number;
baseY: number;
color: string;
size: number;
}
let nodes: Node[] = [];
const pseudoRandom = (offset: number) => {
const x = Math.sin(seed + offset) * 10000;
return x - Math.floor(x);
};
const initNodes = (width: number, height: number) => {
const newNodes = [];
for (let i = 0; i < nodeCount; i++) {
const bx = pseudoRandom(i) * width;
const by = pseudoRandom(i + 100) * height;
newNodes.push({
x: bx,
y: by,
baseX: bx,
baseY: by,
vx: 0,
vy: 0,
color: colors[i % colors.length],
size: 1 + pseudoRandom(i + 200) * 3,
});
}
return newNodes;
};
const resize = () => {
const parent = canvas.parentElement;
if (parent) {
const rect = parent.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
canvas.width = rect.width * dpr;
canvas.height = rect.height * dpr;
ctx.scale(dpr, dpr);
canvas.style.width = `${rect.width}px`;
canvas.style.height = `${rect.height}px`;
nodes = initNodes(rect.width, rect.height);
}
};
const resizeObserver = new ResizeObserver(() => resize());
if (canvas.parentElement) resizeObserver.observe(canvas.parentElement);
resize();
const mouse = { x: -1000, y: -1000, down: false };
const updateMouseFromCoords = (clientX: number, clientY: number) => {
const rect = canvas.getBoundingClientRect();
const x = clientX - rect.left;
const y = clientY - rect.top;
if (x < 0 || y < 0 || x > rect.width || y > rect.height) {
mouse.x = -1000;
mouse.y = -1000;
return false;
}
mouse.x = x;
mouse.y = y;
return true;
};
const handleMouseMove = (e: MouseEvent) => {
if (!updateMouseFromCoords(e.clientX, e.clientY)) {
mouse.down = false;
}
};
const handleTouchMove = (e: TouchEvent) => {
if (e.touches.length > 0) {
updateMouseFromCoords(e.touches[0].clientX, e.touches[0].clientY);
}
};
const handleMouseDown = (e: MouseEvent) => {
const rect = canvas.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
if (x >= 0 && y >= 0 && x <= rect.width && y <= rect.height) {
mouse.down = true;
}
};
const handleMouseUp = () => {
mouse.down = false;
};
const handleTouchStart = (e: TouchEvent) => {
if (e.touches.length > 0) {
if (updateMouseFromCoords(e.touches[0].clientX, e.touches[0].clientY)) {
mouse.down = true;
}
}
};
window.addEventListener('mousemove', handleMouseMove);
window.addEventListener('touchmove', handleTouchMove, { passive: false });
window.addEventListener('mousedown', handleMouseDown);
window.addEventListener('mouseup', handleMouseUp);
window.addEventListener('touchstart', handleTouchStart);
window.addEventListener('touchend', handleMouseUp);
const draw = (now = 0) => {
const decision = gate(now);
if (decision === 'halt') return;
if (decision === 'skip') {
animationFrameId = requestAnimationFrame(draw);
return;
}
const width = canvas.width / (window.devicePixelRatio || 1);
const height = canvas.height / (window.devicePixelRatio || 1);
if (!width || !height) {
animationFrameId = requestAnimationFrame(draw);
return;
}
ctx.clearRect(0, 0, width, height);
// Physics Update
nodes.forEach((node) => {
// Return force (Elasticity) - slightly slower return
const dxBase = node.baseX - node.x;
const dyBase = node.baseY - node.y;
node.vx += dxBase * speed;
node.vy += dyBase * speed;
// Mouse interaction
const dxMouse = mouse.x - node.x;
const dyMouse = mouse.y - node.y;
const distMouse = Math.sqrt(dxMouse * dxMouse + dyMouse * dyMouse);
if (mouse.down) {
// Grab and drag effect - less strong
const dragDist = 300;
if (distMouse < dragDist) {
const power = (dragDist - distMouse) / dragDist;
node.vx += dxMouse * power * 0.08;
node.vy += dyMouse * power * 0.08;
}
} else {
// Hover disturbance - less strong pushing effect
const hoverDist = 150;
if (distMouse < hoverDist) {
const power = (hoverDist - distMouse) / hoverDist;
node.vx -= dxMouse * power * 0.02;
node.vy -= dyMouse * power * 0.02;
}
}
// Damping (Friction) - stronger damping for less "fast" feel
node.vx *= 0.88;
node.vy *= 0.88;
// Update Position
node.x += node.vx;
node.y += node.vy;
});
// Draw Mesh (Connections)
ctx.lineWidth = 1;
for (let i = 0; i < nodes.length; i++) {
let currentConnections = 0;
// We only connect a few to keep it a "web" and not a solid block
for (let j = i + 1; j < nodes.length && currentConnections < 6; j++) {
const dx = nodes[i].x - nodes[j].x;
const dy = nodes[i].y - nodes[j].y;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist < connectionDist) {
currentConnections++;
const opacity = (1 - dist / connectionDist) * globalOpacity;
ctx.beginPath();
ctx.moveTo(nodes[i].x, nodes[i].y);
ctx.lineTo(nodes[j].x, nodes[j].y);
// Dynamic gradient for the line
const lineGrad = ctx.createLinearGradient(
nodes[i].x,
nodes[i].y,
nodes[j].x,
nodes[j].y,
);
lineGrad.addColorStop(0, alpha(nodes[i].color, opacity));
lineGrad.addColorStop(1, alpha(nodes[j].color, opacity));
ctx.strokeStyle = lineGrad;
ctx.stroke();
// Fill some triangles for complexity
if (dist < connectionDist * 0.6 && pseudoRandom(i + j) > 0.92) {
for (let k = j + 1; k < nodes.length; k++) {
const d3x = nodes[i].x - nodes[k].x;
const d3y = nodes[i].y - nodes[k].y;
if (Math.sqrt(d3x * d3x + d3y * d3y) < connectionDist * 0.6) {
ctx.beginPath();
ctx.moveTo(nodes[i].x, nodes[i].y);
ctx.lineTo(nodes[j].x, nodes[j].y);
ctx.lineTo(nodes[k].x, nodes[k].y);
ctx.closePath();
ctx.fillStyle = alpha(nodes[i].color, 0.04);
ctx.fill();
break;
}
}
}
}
}
}
// Draw Nodes
nodes.forEach((node) => {
ctx.beginPath();
ctx.arc(node.x, node.y, node.size, 0, Math.PI * 2);
ctx.fillStyle = node.color;
ctx.fill();
// Core glow
ctx.beginPath();
ctx.arc(node.x, node.y, node.size * 2.5, 0, Math.PI * 2);
ctx.fillStyle = alpha(node.color, 0.15);
ctx.fill();
});
animationFrameId = requestAnimationFrame(draw);
};
draw();
return () => {
resizeObserver.disconnect();
window.removeEventListener('mousemove', handleMouseMove);
window.removeEventListener('touchmove', handleTouchMove);
window.removeEventListener('mousedown', handleMouseDown);
window.removeEventListener('mouseup', handleMouseUp);
window.removeEventListener('touchstart', handleTouchStart);
window.removeEventListener('touchend', handleMouseUp);
cancelAnimationFrame(animationFrameId);
};
}, [
username,
palette,
ratingsCount,
followersCount,
theme,
nodeCountBase,
connectionDist,
speed,
globalOpacity,
gate,
]);
return (
<canvas
ref={canvasRef}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
pointerEvents: 'none',
zIndex: 0,
mixBlendMode: theme.palette.mode === 'light' ? 'multiply' : 'screen',
}}
/>
);
};
export default GenerativeBanner;
-310
View File
@@ -1,310 +0,0 @@
import React, { useRef, useEffect, useMemo } from 'react';
import { alpha, useTheme } from '@mui/material';
import { useBannerFrameGate } from './BannerPerformanceWrapper';
interface HextechBannerProps {
username: string;
palette: string[];
ratingsCount: number;
followersCount: number;
settings?: {
gridSize?: number;
pulseSpeed?: number;
glowIntensity?: number;
streamCount?: number;
distortion?: number;
};
}
const HextechBanner: React.FC<HextechBannerProps> = ({ username, palette, settings }) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
const theme = useTheme();
const gate = useBannerFrameGate();
const gridSize = settings?.gridSize ?? 40;
const pulseSpeed = settings?.pulseSpeed ?? 0.02;
const glowIntensity = settings?.glowIntensity ?? 0.8;
const streamCount = settings?.streamCount ?? 12;
const distortionFactor = settings?.distortion ?? 0.5;
const getSeed = (str: string) => {
let hash = 0;
for (let i = 0; i < str.length; i++) {
hash = str.charCodeAt(i) + ((hash << 5) - hash);
}
return hash;
};
const seed = useMemo(() => getSeed(username), [username]);
const brightColors = useMemo(() => {
const colors =
palette.length >= 2
? palette
: [theme.palette.primary.main, theme.palette.secondary.main, theme.palette.info.main];
const getBrightness = (hex: string) => {
const r = parseInt(hex.slice(1, 3), 16) || 0;
const g = parseInt(hex.slice(3, 5), 16) || 0;
const b = parseInt(hex.slice(5, 7), 16) || 0;
return (r * 299 + g * 587 + b * 114) / 1000;
};
const sorted = [...colors].sort((a, b) => getBrightness(b) - getBrightness(a));
return sorted.slice(0, 3);
}, [palette, theme]);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d', { alpha: true });
if (!ctx) return;
let animationFrameId: number;
let time = 0;
const pseudoRandom = (offset: number) => {
const x = Math.sin(seed + offset) * 10000;
return x - Math.floor(x);
};
// Mana Stream Logic
interface Stream {
segments: { x: number; y: number }[];
color: string;
life: number;
speed: number;
}
let streams: Stream[] = [];
const createStream = (width: number, height: number) => {
const color = brightColors[Math.floor(pseudoRandom(time) * brightColors.length)];
const x = pseudoRandom(time + 1) * width;
const y = pseudoRandom(time + 2) * height;
return {
segments: [{ x, y }],
color,
life: 1.0,
speed: 2 + pseudoRandom(time + 3) * 4,
};
};
const drawHexagon = (x: number, y: number, r: number, tilt: number) => {
ctx.beginPath();
for (let i = 0; i < 6; i++) {
const angle = (Math.PI / 3) * i + tilt;
const px = x + r * Math.cos(angle);
const py = y + r * Math.sin(angle);
if (i === 0) ctx.moveTo(px, py);
else ctx.lineTo(px, py);
}
ctx.closePath();
};
const resize = () => {
const parent = canvas.parentElement;
if (parent) {
const rect = parent.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
canvas.width = rect.width * dpr;
canvas.height = rect.height * dpr;
ctx.scale(dpr, dpr);
canvas.style.width = `${rect.width}px`;
canvas.style.height = `${rect.height}px`;
}
};
const resizeObserver = new ResizeObserver(() => resize());
if (canvas.parentElement) resizeObserver.observe(canvas.parentElement);
resize();
const mouse = { x: -1000, y: -1000, down: false };
const handleMouseMove = (e: MouseEvent) => {
const rect = canvas.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
if (x < 0 || y < 0 || x > rect.width || y > rect.height) {
mouse.x = -1000;
mouse.y = -1000;
mouse.down = false;
} else {
mouse.x = x;
mouse.y = y;
}
};
const handleMouseDown = (e: MouseEvent) => {
const rect = canvas.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
if (x >= 0 && y >= 0 && x <= rect.width && y <= rect.height) {
mouse.down = true;
}
};
const handleMouseUp = () => {
mouse.down = false;
};
window.addEventListener('mousemove', handleMouseMove);
window.addEventListener('mousedown', handleMouseDown);
window.addEventListener('mouseup', handleMouseUp);
const draw = (now = 0) => {
const decision = gate(now);
if (decision === 'halt') return;
if (decision === 'skip') {
animationFrameId = requestAnimationFrame(draw);
return;
}
const width = canvas.width / (window.devicePixelRatio || 1);
const height = canvas.height / (window.devicePixelRatio || 1);
if (!width || !height) {
animationFrameId = requestAnimationFrame(draw);
return;
}
ctx.clearRect(0, 0, width, height);
// Background
const isLight = theme.palette.mode === 'light';
const bgGrad = ctx.createRadialGradient(
width / 2,
height / 2,
0,
width / 2,
height / 2,
width,
);
if (isLight) {
// For light themes, use a deeper, more saturated version of the theme color
bgGrad.addColorStop(0, alpha(brightColors[2] || brightColors[0], 0.65));
bgGrad.addColorStop(1, alpha(brightColors[2] || brightColors[0], 0.95));
} else {
bgGrad.addColorStop(0, '#0a051a'); // Arcane Purple tint
bgGrad.addColorStop(1, '#020105');
}
ctx.fillStyle = bgGrad;
ctx.fillRect(0, 0, width, height);
time += pulseSpeed;
// 1. Draw Hex Grid
ctx.lineWidth = 1;
const h = gridSize * Math.sqrt(3);
const w = gridSize * 2;
for (let y = -gridSize; y < height + gridSize; y += h / 2) {
for (let x = -gridSize; x < width + gridSize; x += w * 0.75) {
const xOffset = Math.floor(y / (h / 2)) % 2 === 0 ? 0 : w * 0.375;
const finalX = x + xOffset;
const finalY = y;
// Apply Trippiness (Distortion)
const dist = Math.sqrt(Math.pow(finalX - mouse.x, 2) + Math.pow(finalY - mouse.y, 2));
const mouseImpact = Math.max(0, (200 - dist) / 200) * (mouse.down ? 2 : 1);
const dx =
Math.sin(time + finalX * 0.01) * gridSize * distortionFactor * (1 + mouseImpact);
const dy =
Math.cos(time + finalY * 0.01) * gridSize * distortionFactor * (1 + mouseImpact);
const drawX = finalX + dx;
const drawY = finalY + dy;
// Pulse glow
const pulseVal = 0.1 + Math.sin(time * 2 + (finalX + finalY) * 0.005) * 0.1;
const opacity = (0.1 + pulseVal + mouseImpact * 0.3) * glowIntensity;
ctx.strokeStyle = alpha(brightColors[0], opacity);
drawHexagon(drawX, drawY, gridSize * 0.9, time * 0.1);
ctx.stroke();
// Occasional Runes
if (pseudoRandom(Math.floor(finalX) + Math.floor(finalY)) > 0.92) {
ctx.fillStyle = alpha(brightColors[1], opacity * 0.5);
ctx.font = `${gridSize * 0.5}px serif`;
ctx.fillText('⚡', drawX - 5, drawY + 5);
}
}
}
// 2. Process Mana Streams
if (streams.length < streamCount && Math.random() > 0.9) {
streams.push(createStream(width, height));
}
ctx.globalCompositeOperation = 'screen';
streams.forEach((s, idx) => {
s.life -= 0.005;
const last = s.segments[s.segments.length - 1];
const ang = pseudoRandom(time + idx) * Math.PI * 2;
const nextX = last.x + Math.cos(ang) * s.speed;
const nextY = last.y + Math.sin(ang) * s.speed;
s.segments.push({ x: nextX, y: nextY });
if (s.segments.length > 20) s.segments.shift();
ctx.beginPath();
ctx.strokeStyle = alpha(s.color, s.life * glowIntensity);
ctx.lineWidth = 2;
s.segments.forEach((seg, i) => {
if (i === 0) ctx.moveTo(seg.x, seg.y);
else ctx.lineTo(seg.x, seg.y);
});
ctx.stroke();
const head = s.segments[s.segments.length - 1];
const g = ctx.createRadialGradient(head.x, head.y, 0, head.x, head.y, 15);
g.addColorStop(0, alpha(s.color, s.life));
g.addColorStop(1, alpha(s.color, 0));
ctx.fillStyle = g;
ctx.beginPath();
ctx.arc(head.x, head.y, 15, 0, Math.PI * 2);
ctx.fill();
});
ctx.globalCompositeOperation = 'source-over';
streams = streams.filter((s) => s.life > 0);
animationFrameId = requestAnimationFrame(draw);
};
draw();
return () => {
resizeObserver.disconnect();
window.removeEventListener('mousemove', handleMouseMove);
window.removeEventListener('mousedown', handleMouseDown);
window.removeEventListener('mouseup', handleMouseUp);
cancelAnimationFrame(animationFrameId);
};
}, [
seed,
brightColors,
gridSize,
pulseSpeed,
glowIntensity,
streamCount,
distortionFactor,
theme,
gate,
]);
return (
<canvas
ref={canvasRef}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
pointerEvents: 'none',
zIndex: 0,
}}
/>
);
};
export default HextechBanner;
@@ -1,300 +0,0 @@
import React, { useRef, useEffect, useMemo } from 'react';
import { alpha, useTheme } from '@mui/material';
import { useBannerFrameGate } from './BannerPerformanceWrapper';
interface HextechCorruptionBannerProps {
username: string;
palette: string[];
ratingsCount: number;
followersCount: number;
settings?: {
cellCount?: number;
shimmerSpeed?: number;
glowIntensity?: number;
instability?: number;
shellDarkness?: number;
};
}
const HextechCorruptionBanner: React.FC<HextechCorruptionBannerProps> = ({
username,
palette,
settings,
}) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
const theme = useTheme();
const gate = useBannerFrameGate();
const cellCount = settings?.cellCount ?? 40;
const shimmerSpeed = settings?.shimmerSpeed ?? 0.015;
const glowIntensity = settings?.glowIntensity ?? 0.9;
const instability = settings?.instability ?? 0.6;
const shellDarkness = settings?.shellDarkness ?? 0.85;
const getSeed = (str: string) => {
let hash = 0;
for (let i = 0; i < str.length; i++) {
hash = str.charCodeAt(i) + ((hash << 5) - hash);
}
return hash;
};
const seed = useMemo(() => getSeed(username), [username]);
const brightColors = useMemo(() => {
const colors =
palette.length >= 2
? palette
: [theme.palette.primary.main, theme.palette.secondary.main, theme.palette.info.main];
const getBrightness = (hex: string) => {
const r = parseInt(hex.slice(1, 3), 16) || 0;
const g = parseInt(hex.slice(3, 5), 16) || 0;
const b = parseInt(hex.slice(5, 7), 16) || 0;
return (r * 299 + g * 587 + b * 114) / 1000;
};
const sorted = [...colors].sort((a, b) => getBrightness(b) - getBrightness(a));
return sorted.slice(0, 3);
}, [palette, theme]);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d', { alpha: true });
if (!ctx) return;
let animationFrameId: number;
let time = 0;
interface ControlPoint {
x: number;
y: number;
ox: number;
oy: number;
vx: number;
vy: number;
phase: number;
color: string;
}
let points: ControlPoint[] = [];
const pseudoRandom = (offset: number) => {
const x = Math.sin(seed + offset) * 10000;
return x - Math.floor(x);
};
const initPoints = (width: number, height: number) => {
const newPoints = [];
for (let i = 0; i < cellCount; i++) {
const rx = pseudoRandom(i) * width;
const ry = pseudoRandom(i + 500) * height;
newPoints.push({
x: rx,
y: ry,
ox: rx,
oy: ry,
vx: 0,
vy: 0,
phase: pseudoRandom(i + 1000) * Math.PI * 2,
color: brightColors[i % brightColors.length],
});
}
return newPoints;
};
const resize = () => {
const parent = canvas.parentElement;
if (parent) {
const rect = parent.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
canvas.width = rect.width * dpr;
canvas.height = rect.height * dpr;
ctx.scale(dpr, dpr);
canvas.style.width = `${rect.width}px`;
canvas.style.height = `${rect.height}px`;
points = initPoints(rect.width, rect.height);
}
};
const resizeObserver = new ResizeObserver(() => resize());
if (canvas.parentElement) resizeObserver.observe(canvas.parentElement);
resize();
const mouse = { x: -1000, y: -1000, down: false };
const handleMouseMove = (e: MouseEvent) => {
const rect = canvas.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
if (x < 0 || y < 0 || x > rect.width || y > rect.height) {
mouse.x = -1000;
mouse.y = -1000;
mouse.down = false;
} else {
mouse.x = x;
mouse.y = y;
}
};
const handleMouseDown = (e: MouseEvent) => {
const rect = canvas.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
if (x >= 0 && y >= 0 && x <= rect.width && y <= rect.height) {
mouse.down = true;
}
};
const handleMouseUp = () => {
mouse.down = false;
};
window.addEventListener('mousemove', handleMouseMove);
window.addEventListener('mousedown', handleMouseDown);
window.addEventListener('mouseup', handleMouseUp);
const draw = (now = 0) => {
const decision = gate(now);
if (decision === 'halt') return;
if (decision === 'skip') {
animationFrameId = requestAnimationFrame(draw);
return;
}
const width = canvas.width / (window.devicePixelRatio || 1);
const height = canvas.height / (window.devicePixelRatio || 1);
if (!width || !height) {
animationFrameId = requestAnimationFrame(draw);
return;
}
ctx.clearRect(0, 0, width, height);
time += shimmerSpeed;
// 1. Draw Under-Glow (Inner Cores)
ctx.globalCompositeOperation = 'source-over';
points.forEach((p) => {
// Harmonic motion + mouse interaction
const dxMouse = mouse.x - p.x;
const dyMouse = mouse.y - p.y;
const distMouse = Math.sqrt(dxMouse * dxMouse + dyMouse * dyMouse);
const mouseForce = Math.max(0, (300 - distMouse) / 300);
const driftX = Math.sin(time + p.phase) * 20 * instability;
const driftY = Math.cos(time * 0.8 + p.phase) * 20 * instability;
p.x = p.ox + driftX + (mouse.down ? -dxMouse * mouseForce * 0.5 : 0);
p.y = p.oy + driftY + (mouse.down ? -dyMouse * mouseForce * 0.5 : 0);
const pulse = 0.7 + Math.sin(time * 3 + p.phase) * 0.3;
const glowSize = 60 + mouseForce * 40;
const grad = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, glowSize);
grad.addColorStop(0, alpha(p.color, 0.8 * glowIntensity * pulse));
grad.addColorStop(0.5, alpha(p.color, 0.3 * glowIntensity));
grad.addColorStop(1, alpha(p.color, 0));
ctx.fillStyle = grad;
ctx.beginPath();
ctx.arc(p.x, p.y, glowSize, 0, Math.PI * 2);
ctx.fill();
});
// 2. Draw Dark "Void" Shell (Voronoi-based cutout)
// We use a temporary canvas to draw the shell then mask it, or just use a heavy overlay
ctx.globalCompositeOperation = 'source-over';
// Create the "holes" effect by using a pattern of circles that are actually transparent
// Actually, to get the specific look from the image, we can use a simpler approach:
// Layer a dark color over everything, but with 'destination-out' circles at the point locations.
const offCanvas = document.createElement('canvas');
offCanvas.width = canvas.width;
offCanvas.height = canvas.height;
const offCtx = offCanvas.getContext('2d');
if (offCtx) {
const dpr = window.devicePixelRatio || 1;
offCtx.scale(dpr, dpr);
// Draw full dark shell
const shellColor =
theme.palette.mode === 'light'
? alpha('#1a0a2a', shellDarkness)
: alpha('#05020a', shellDarkness);
offCtx.fillStyle = shellColor;
offCtx.fillRect(0, 0, width, height);
// Cut holes
offCtx.globalCompositeOperation = 'destination-out';
points.forEach((p) => {
const holeSize = 15 + Math.sin(time + p.phase) * 5 + instability * 10;
// Main hole
offCtx.beginPath();
offCtx.arc(p.x, p.y, holeSize, 0, Math.PI * 2);
offCtx.fill();
// Add secondary smaller holes for that "punctured" look
for (let j = 0; j < 3; j++) {
const angle = p.phase + j * Math.PI * 0.6;
const hx = p.x + Math.cos(angle) * (holeSize * 1.5);
const hy = p.y + Math.sin(angle) * (holeSize * 1.5);
offCtx.beginPath();
offCtx.arc(hx, hy, holeSize * 0.4, 0, Math.PI * 2);
offCtx.fill();
}
});
ctx.drawImage(offCanvas, 0, 0, width, height);
}
// 3. Add Iridescent Edge Highlights
ctx.globalCompositeOperation = 'screen';
ctx.lineWidth = 2;
points.forEach((p) => {
const pulse = 0.5 + Math.sin(time * 2 + p.phase) * 0.5;
ctx.strokeStyle = alpha(p.color, pulse * 0.4);
ctx.beginPath();
ctx.arc(p.x, p.y, 20 + pulse * 5, 0, Math.PI * 2);
ctx.stroke();
});
ctx.globalCompositeOperation = 'source-over';
animationFrameId = requestAnimationFrame(draw);
};
draw();
return () => {
resizeObserver.disconnect();
window.removeEventListener('mousemove', handleMouseMove);
window.removeEventListener('mousedown', handleMouseDown);
window.removeEventListener('mouseup', handleMouseUp);
cancelAnimationFrame(animationFrameId);
};
}, [
seed,
brightColors,
cellCount,
shimmerSpeed,
glowIntensity,
instability,
shellDarkness,
theme,
gate,
]);
return (
<canvas
ref={canvasRef}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
pointerEvents: 'none',
zIndex: 0,
}}
/>
);
};
export default HextechCorruptionBanner;
-347
View File
@@ -1,347 +0,0 @@
import React, { useRef, useEffect, useMemo } from 'react';
import { alpha, useTheme } from '@mui/material';
import { useBannerFrameGate } from './BannerPerformanceWrapper';
interface NebulaBannerProps {
username: string;
palette: string[];
ratingsCount: number;
followersCount: number;
settings?: {
particleCount?: number;
speed?: number;
opacity?: number;
smearRadius?: number;
turbulence?: number;
returnSpeed?: number;
starDensity?: number;
};
}
const NebulaBanner: React.FC<NebulaBannerProps> = ({
username,
palette,
ratingsCount,
followersCount,
settings,
}) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
const theme = useTheme();
const gate = useBannerFrameGate();
const particleCount = settings?.particleCount ?? 400;
const speed = settings?.speed ?? 0.0015;
const globalOpacity = settings?.opacity ?? 0.6;
const smearRadius = settings?.smearRadius ?? 120;
const turbulenceStrength = settings?.turbulence ?? 0.4;
const returnSpeed = settings?.returnSpeed ?? 0.004; // Much lower for smoke-like feel
const starDensity = settings?.starDensity ?? 150;
const getSeed = (str: string) => {
let hash = 0;
for (let i = 0; i < str.length; i++) {
hash = str.charCodeAt(i) + ((hash << 5) - hash);
}
return hash;
};
const seed = useMemo(() => getSeed(username), [username]);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d', { alpha: true });
if (!ctx) return;
let animationFrameId: number;
const colors =
palette.length >= 2
? palette
: [theme.palette.primary.main, theme.palette.secondary.main, theme.palette.info.main];
interface Particle {
x: number;
y: number;
baseX: number;
baseY: number;
vx: number;
vy: number;
size: number;
color: string;
alpha: number;
targetAlpha: number;
phase: number;
}
interface Star {
x: number;
y: number;
size: number;
opacity: number;
}
let particles: Particle[] = [];
let stars: Star[] = [];
const pseudoRandom = (offset: number) => {
const x = Math.sin(seed + offset) * 10000;
return x - Math.floor(x);
};
const initScene = (width: number, height: number) => {
// Init Stars
const newStars = [];
for (let i = 0; i < starDensity; i++) {
newStars.push({
x: pseudoRandom(i + 5000) * width,
y: pseudoRandom(i + 6000) * height,
size: pseudoRandom(i + 7000) * 1.5,
opacity: 0.1 + pseudoRandom(i + 8000) * 0.5,
});
}
// Init Gas Clusters
const newParticles = [];
const clusterCount = colors.length * 3; // More clusters
const groupCenters = Array.from({ length: clusterCount }).map((_, i) => ({
x: (pseudoRandom(i * 15) * 0.8 + 0.1) * width,
y: (pseudoRandom(i * 25 + 7) * 0.8 + 0.1) * height,
}));
for (let i = 0; i < particleCount; i++) {
const clusterIdx = i % clusterCount;
const colorIdx = clusterIdx % colors.length;
const center = groupCenters[clusterIdx];
const angle = pseudoRandom(i + 100) * Math.PI * 2;
const dist = pseudoRandom(i + 200) * (width * 0.12);
const bx = center.x + Math.cos(angle) * dist;
const by = center.y + Math.sin(angle) * dist;
newParticles.push({
x: bx,
y: by,
baseX: bx,
baseY: by,
vx: 0,
vy: 0,
size: 40 + pseudoRandom(i + 300) * 80, // Larger, softer blobs
color: colors[colorIdx],
alpha: 0,
targetAlpha: 0.05 + pseudoRandom(i + 400) * 0.15,
phase: pseudoRandom(i + 500) * Math.PI * 2,
});
}
return { stars: newStars, particles: newParticles };
};
const resize = () => {
const parent = canvas.parentElement;
if (parent) {
const rect = parent.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
canvas.width = rect.width * dpr;
canvas.height = rect.height * dpr;
ctx.scale(dpr, dpr);
canvas.style.width = `${rect.width}px`;
canvas.style.height = `${rect.height}px`;
const scene = initScene(rect.width, rect.height);
stars = scene.stars;
particles = scene.particles;
}
};
const resizeObserver = new ResizeObserver(() => resize());
if (canvas.parentElement) resizeObserver.observe(canvas.parentElement);
resize();
const mouse = { x: -1000, y: -1000, px: -1000, py: -1000, down: false };
const handleMouseMove = (e: MouseEvent) => {
const rect = canvas.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
mouse.px = mouse.x;
mouse.py = mouse.y;
if (x < 0 || y < 0 || x > rect.width || y > rect.height) {
mouse.x = -1000;
mouse.y = -1000;
mouse.down = false;
} else {
mouse.x = x;
mouse.y = y;
}
};
const handleMouseDown = (e: MouseEvent) => {
const rect = canvas.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
if (x >= 0 && y >= 0 && x <= rect.width && y <= rect.height) {
mouse.down = true;
}
};
const handleMouseUp = () => {
mouse.down = false;
};
window.addEventListener('mousemove', handleMouseMove);
window.addEventListener('mousedown', handleMouseDown);
window.addEventListener('mouseup', handleMouseUp);
let time = 0;
const draw = (now = 0) => {
const decision = gate(now);
if (decision === 'halt') return;
if (decision === 'skip') {
animationFrameId = requestAnimationFrame(draw);
return;
}
const width = canvas.width / (window.devicePixelRatio || 1);
const height = canvas.height / (window.devicePixelRatio || 1);
if (!width || !height) {
animationFrameId = requestAnimationFrame(draw);
return;
}
ctx.clearRect(0, 0, width, height);
// 1. Dynamic Celestial Background
const isLight = theme.palette.mode === 'light';
const skyGrad = ctx.createRadialGradient(
width / 2,
height / 2,
0,
width / 2,
height / 2,
width,
);
if (isLight) {
// Vibrant Deep Primary for light mode - but dark enough for glowing gas to pop
skyGrad.addColorStop(0, alpha(colors[colors.length - 1], 0.7));
skyGrad.addColorStop(0.5, alpha(colors[colors.length - 1], 0.9));
skyGrad.addColorStop(1, alpha(colors[colors.length - 1], 1.0));
} else {
// True Deep Space with visible tints for dark mode
skyGrad.addColorStop(0, '#1a1a3a'); // More blue/purple tint
skyGrad.addColorStop(0.5, '#0a0a1a');
skyGrad.addColorStop(1, '#050508');
}
ctx.fillStyle = skyGrad;
ctx.fillRect(0, 0, width, height);
// 2. Draw Distant Stars
ctx.fillStyle = '#fff';
stars.forEach((s) => {
ctx.globalAlpha = s.opacity * (0.7 + Math.sin(time * 2 + s.x) * 0.3);
ctx.beginPath();
ctx.arc(s.x, s.y, s.size, 0, Math.PI * 2);
ctx.fill();
});
ctx.globalAlpha = 1.0;
time += speed;
// 3. Draw Gas Clouds
particles.forEach((p) => {
// Restoration Force (Airy return)
const dxBase = p.baseX - p.x;
const dyBase = p.baseY - p.y;
p.vx += dxBase * returnSpeed;
p.vy += dyBase * returnSpeed;
// Mouse Interaction (Airy Smear)
const dx = mouse.x - p.x;
const dy = mouse.y - p.y;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist < smearRadius) {
const force = (smearRadius - dist) / smearRadius;
if (mouse.down) {
// High turbulence on click
const angle = Math.atan2(dy, dx);
p.vx += Math.cos(angle + Math.PI / 2) * turbulenceStrength * 2;
p.vy += Math.sin(angle + Math.PI / 2) * turbulenceStrength * 2;
} else {
// Smoke-like smear
const mdx = mouse.x - mouse.px;
const mdy = mouse.y - mouse.py;
p.vx += mdx * force * 0.15;
p.vy += mdy * force * 0.15;
p.vx -= (dx / dist) * force * 0.8;
p.vy -= (dy / dist) * force * 0.8;
}
}
// Smooth drift
p.vx += Math.sin(time + p.phase) * 0.03;
p.vy += Math.cos(time * 0.8 + p.phase) * 0.03;
// High friction for gas feel
p.vx *= 0.95;
p.vy *= 0.95;
p.x += p.vx;
p.y += p.vy;
if (p.alpha < p.targetAlpha) p.alpha += 0.001;
// Render as very soft additive blob
const grad = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, p.size);
const colorAlpha = p.alpha * globalOpacity;
grad.addColorStop(0, alpha(p.color, colorAlpha));
grad.addColorStop(0.6, alpha(p.color, colorAlpha * 0.2));
grad.addColorStop(1, alpha(p.color, 0));
ctx.fillStyle = grad;
ctx.globalCompositeOperation = 'screen';
ctx.beginPath();
ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
ctx.fill();
});
ctx.globalCompositeOperation = 'source-over';
animationFrameId = requestAnimationFrame(draw);
};
draw();
return () => {
resizeObserver.disconnect();
window.removeEventListener('mousemove', handleMouseMove);
window.removeEventListener('mousedown', handleMouseDown);
window.removeEventListener('mouseup', handleMouseUp);
cancelAnimationFrame(animationFrameId);
};
}, [
seed,
palette,
ratingsCount,
followersCount,
theme,
particleCount,
speed,
globalOpacity,
smearRadius,
turbulenceStrength,
returnSpeed,
starDensity,
gate,
]);
return (
<canvas
ref={canvasRef}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
pointerEvents: 'none',
zIndex: 0,
}}
/>
);
};
export default NebulaBanner;
@@ -1,6 +1,6 @@
import React, { useRef, useEffect, useMemo } from 'react'; import React, { useMemo } from 'react';
import { alpha, useTheme } from '@mui/material'; import { alpha, useTheme } from '@mui/material';
import { useBannerFrameGate } from './BannerPerformanceWrapper'; import { CANVAS_STYLE, useCanvasBanner } from './useCanvasBanner';
interface FirefliesBannerProps { interface FirefliesBannerProps {
username: string; username: string;
@@ -17,27 +17,37 @@ interface FirefliesBannerProps {
}; };
} }
const FirefliesBanner: React.FC<FirefliesBannerProps> = ({ username, palette, settings }) => { interface Firefly {
const canvasRef = useRef<HTMLCanvasElement>(null); x: number;
const theme = useTheme(); y: number;
const gate = useBannerFrameGate(); vx: number;
vy: number;
size: number;
color: string;
phase: number;
noiseOffset: number;
isEvading: boolean;
}
const count = settings?.count ?? 150; const hashString = (str: string): number => {
const baseSpeed = settings?.speed ?? 0.8; // Slower default cruise
const evasionRadius = settings?.evasionRadius ?? 180;
const evasionStrength = settings?.evasionStrength ?? 0.6; // High evasion
const glowSizeMultiplier = settings?.glowSize ?? 4;
const flickerSpeed = settings?.flickerSpeed ?? 0.05;
const getSeed = (str: string) => {
let hash = 0; let hash = 0;
for (let i = 0; i < str.length; i++) { for (let i = 0; i < str.length; i++) {
hash = str.charCodeAt(i) + ((hash << 5) - hash); hash = str.charCodeAt(i) + ((hash << 5) - hash);
} }
return hash; return hash;
}; };
const seed = useMemo(() => getSeed(username), [username]); const FirefliesBanner: React.FC<FirefliesBannerProps> = ({ username, palette, settings }) => {
const theme = useTheme();
const count = settings?.count ?? 150;
const baseSpeed = settings?.speed ?? 0.8;
const evasionRadius = settings?.evasionRadius ?? 180;
const evasionStrength = settings?.evasionStrength ?? 0.6;
const glowSizeMultiplier = settings?.glowSize ?? 4;
const flickerSpeed = settings?.flickerSpeed ?? 0.05;
const seed = useMemo(() => hashString(username), [username]);
const brightColors = useMemo(() => { const brightColors = useMemo(() => {
const colors = const colors =
@@ -54,38 +64,15 @@ const FirefliesBanner: React.FC<FirefliesBannerProps> = ({ username, palette, se
return sorted.slice(0, 3); return sorted.slice(0, 3);
}, [palette, theme]); }, [palette, theme]);
useEffect(() => { const ref = useCanvasBanner<{ swarm: Firefly[]; time: number }>({
const canvas = canvasRef.current; init: (width, height) => {
if (!canvas) return;
const ctx = canvas.getContext('2d', { alpha: true });
if (!ctx) return;
let animationFrameId: number;
interface Firefly {
x: number;
y: number;
vx: number;
vy: number;
size: number;
color: string;
phase: number;
noiseOffset: number;
isEvading: boolean;
}
let swarm: Firefly[] = [];
const pseudoRandom = (offset: number) => { const pseudoRandom = (offset: number) => {
const x = Math.sin(seed + offset) * 10000; const x = Math.sin(seed + offset) * 10000;
return x - Math.floor(x); return x - Math.floor(x);
}; };
const swarm: Firefly[] = [];
const initSwarm = (width: number, height: number) => {
const newSwarm = [];
for (let i = 0; i < count; i++) { for (let i = 0; i < count; i++) {
newSwarm.push({ swarm.push({
x: pseudoRandom(i) * width, x: pseudoRandom(i) * width,
y: pseudoRandom(i + 500) * height, y: pseudoRandom(i + 500) * height,
vx: (pseudoRandom(i + 1000) - 0.5) * baseSpeed, vx: (pseudoRandom(i + 1000) - 0.5) * baseSpeed,
@@ -97,60 +84,20 @@ const FirefliesBanner: React.FC<FirefliesBannerProps> = ({ username, palette, se
isEvading: false, isEvading: false,
}); });
} }
return newSwarm; return { swarm, time: 0 };
},
draw: ({ ctx, width, height, state, mouse }) => {
const { swarm } = state;
state.time += flickerSpeed;
const time = state.time;
const pseudoRandom = (offset: number) => {
const x = Math.sin(seed + offset) * 10000;
return x - Math.floor(x);
}; };
const resize = () => {
const parent = canvas.parentElement;
if (parent) {
const rect = parent.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
canvas.width = rect.width * dpr;
canvas.height = rect.height * dpr;
ctx.scale(dpr, dpr);
canvas.style.width = `${rect.width}px`;
canvas.style.height = `${rect.height}px`;
swarm = initSwarm(rect.width, rect.height);
}
};
const resizeObserver = new ResizeObserver(() => resize());
if (canvas.parentElement) resizeObserver.observe(canvas.parentElement);
resize();
const mouse = { x: -1000, y: -1000 };
const handleMouseMove = (e: MouseEvent) => {
const rect = canvas.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
if (x < 0 || y < 0 || x > rect.width || y > rect.height) {
mouse.x = -1000;
mouse.y = -1000;
} else {
mouse.x = x;
mouse.y = y;
}
};
window.addEventListener('mousemove', handleMouseMove);
let time = 0;
const draw = (now = 0) => {
const decision = gate(now);
if (decision === 'halt') return;
if (decision === 'skip') {
animationFrameId = requestAnimationFrame(draw);
return;
}
const width = canvas.width / (window.devicePixelRatio || 1);
const height = canvas.height / (window.devicePixelRatio || 1);
if (!width || !height) {
animationFrameId = requestAnimationFrame(draw);
return;
}
ctx.clearRect(0, 0, width, height); ctx.clearRect(0, 0, width, height);
// Vibrant theme-aware night sky background
const isLight = theme.palette.mode === 'light'; const isLight = theme.palette.mode === 'light';
const bgGrad = ctx.createRadialGradient( const bgGrad = ctx.createRadialGradient(
width / 2, width / 2,
@@ -161,32 +108,25 @@ const FirefliesBanner: React.FC<FirefliesBannerProps> = ({ username, palette, se
width, width,
); );
if (isLight) { if (isLight) {
// For light themes, we still need a "dusk/night" feel for fireflies to make sense.
// We use a deeper, more saturated version of the theme's colors.
bgGrad.addColorStop(0, alpha(brightColors[2] || '#1a1a2e', 0.85)); bgGrad.addColorStop(0, alpha(brightColors[2] || '#1a1a2e', 0.85));
bgGrad.addColorStop(1, alpha(brightColors[2] || '#16213e', 1.0)); bgGrad.addColorStop(1, alpha(brightColors[2] || '#16213e', 1.0));
} else { } else {
bgGrad.addColorStop(0, '#141a2a'); // Noticeable blue-grey tint bgGrad.addColorStop(0, '#141a2a');
bgGrad.addColorStop(1, '#05080f'); bgGrad.addColorStop(1, '#05080f');
} }
ctx.fillStyle = bgGrad; ctx.fillStyle = bgGrad;
ctx.fillRect(0, 0, width, height); ctx.fillRect(0, 0, width, height);
time += flickerSpeed;
swarm.forEach((f, i) => { swarm.forEach((f, i) => {
// 1. Autonomous Chaotic Movement (Perlin + Jitter)
const driftX = Math.sin(time * 0.5 + f.noiseOffset) * 0.05; const driftX = Math.sin(time * 0.5 + f.noiseOffset) * 0.05;
const driftY = Math.cos(time * 0.4 + f.noiseOffset) * 0.05; const driftY = Math.cos(time * 0.4 + f.noiseOffset) * 0.05;
// High-frequency jitter for insect feel
const jitterX = (pseudoRandom(i + time * 10) - 0.5) * 0.2; const jitterX = (pseudoRandom(i + time * 10) - 0.5) * 0.2;
const jitterY = (pseudoRandom(i + 500 + time * 10) - 0.5) * 0.2; const jitterY = (pseudoRandom(i + 500 + time * 10) - 0.5) * 0.2;
f.vx += driftX + jitterX; f.vx += driftX + jitterX;
f.vy += driftY + jitterY; f.vy += driftY + jitterY;
// 2. Mouse Evasion (Fast Dash)
const dx = mouse.x - f.x; const dx = mouse.x - f.x;
const dy = mouse.y - f.y; const dy = mouse.y - f.y;
const dist = Math.sqrt(dx * dx + dy * dy); const dist = Math.sqrt(dx * dx + dy * dy);
@@ -195,12 +135,10 @@ const FirefliesBanner: React.FC<FirefliesBannerProps> = ({ username, palette, se
if (f.isEvading) { if (f.isEvading) {
const force = (evasionRadius - dist) / evasionRadius; const force = (evasionRadius - dist) / evasionRadius;
const angle = Math.atan2(dy, dx); const angle = Math.atan2(dy, dx);
// Sharp acceleration away
f.vx -= Math.cos(angle) * force * evasionStrength * 1.5; f.vx -= Math.cos(angle) * force * evasionStrength * 1.5;
f.vy -= Math.sin(angle) * force * evasionStrength * 1.5; f.vy -= Math.sin(angle) * force * evasionStrength * 1.5;
} }
// 3. Dynamic Speed Limits
const currentSpeed = Math.sqrt(f.vx * f.vx + f.vy * f.vy); const currentSpeed = Math.sqrt(f.vx * f.vx + f.vy * f.vy);
const maxSpeed = f.isEvading ? baseSpeed * 10 : baseSpeed * 1.5; const maxSpeed = f.isEvading ? baseSpeed * 10 : baseSpeed * 1.5;
@@ -209,25 +147,21 @@ const FirefliesBanner: React.FC<FirefliesBannerProps> = ({ username, palette, se
f.vy = (f.vy / currentSpeed) * maxSpeed; f.vy = (f.vy / currentSpeed) * maxSpeed;
} }
// 4. Smooth Friction
f.vx *= 0.97; f.vx *= 0.97;
f.vy *= 0.97; f.vy *= 0.97;
f.x += f.vx; f.x += f.vx;
f.y += f.vy; f.y += f.vy;
// Screen Wrap
const margin = 20; const margin = 20;
if (f.x < -margin) f.x = width + margin; if (f.x < -margin) f.x = width + margin;
if (f.x > width + margin) f.x = -margin; if (f.x > width + margin) f.x = -margin;
if (f.y < -margin) f.y = height + margin; if (f.y < -margin) f.y = height + margin;
if (f.y > height + margin) f.y = -margin; if (f.y > height + margin) f.y = -margin;
// Flicker
const flicker = 0.4 + Math.sin(time + f.phase) * 0.6; const flicker = 0.4 + Math.sin(time + f.phase) * 0.6;
const opacity = flicker > 0 ? flicker : 0; const opacity = flicker > 0 ? flicker : 0;
// Render
const grad = ctx.createRadialGradient(f.x, f.y, 0, f.x, f.y, f.size * glowSizeMultiplier); const grad = ctx.createRadialGradient(f.x, f.y, 0, f.x, f.y, f.size * glowSizeMultiplier);
grad.addColorStop(0, alpha(f.color, opacity)); grad.addColorStop(0, alpha(f.color, opacity));
grad.addColorStop(0.4, alpha(f.color, opacity * 0.3)); grad.addColorStop(0.4, alpha(f.color, opacity * 0.3));
@@ -243,18 +177,8 @@ const FirefliesBanner: React.FC<FirefliesBannerProps> = ({ username, palette, se
ctx.arc(f.x, f.y, f.size * 0.5, 0, Math.PI * 2); ctx.arc(f.x, f.y, f.size * 0.5, 0, Math.PI * 2);
ctx.fill(); ctx.fill();
}); });
},
animationFrameId = requestAnimationFrame(draw); deps: [
};
draw();
return () => {
resizeObserver.disconnect();
window.removeEventListener('mousemove', handleMouseMove);
cancelAnimationFrame(animationFrameId);
};
}, [
seed, seed,
brightColors, brightColors,
count, count,
@@ -264,23 +188,10 @@ const FirefliesBanner: React.FC<FirefliesBannerProps> = ({ username, palette, se
glowSizeMultiplier, glowSizeMultiplier,
flickerSpeed, flickerSpeed,
theme, theme,
gate, ],
]); });
return ( return <canvas ref={ref} style={CANVAS_STYLE} />;
<canvas
ref={canvasRef}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
pointerEvents: 'none',
zIndex: 0,
}}
/>
);
}; };
export default FirefliesBanner; export default FirefliesBanner;
@@ -0,0 +1,202 @@
import React, { useMemo } from 'react';
import { alpha, useTheme } from '@mui/material';
import { CANVAS_STYLE, useCanvasBanner } from './useCanvasBanner';
interface GenerativeBannerProps {
username: string;
palette: string[];
ratingsCount: number;
followersCount: number;
settings?: {
nodeCountBase?: number;
connectionDist?: number;
speed?: number;
opacity?: number;
};
}
interface Node {
x: number;
y: number;
vx: number;
vy: number;
baseX: number;
baseY: number;
color: string;
size: number;
}
const hashString = (str: string): number => {
let hash = 0;
for (let i = 0; i < str.length; i++) {
hash = str.charCodeAt(i) + ((hash << 5) - hash);
}
return hash;
};
const GenerativeBanner: React.FC<GenerativeBannerProps> = ({
username,
palette,
ratingsCount,
followersCount,
settings,
}) => {
const theme = useTheme();
const nodeCountBase = settings?.nodeCountBase ?? 60;
const connectionDist = settings?.connectionDist ?? 140;
const speed = settings?.speed ?? 0.005;
const globalOpacity = settings?.opacity ?? 0.35;
const seed = useMemo(() => hashString(username), [username]);
const colors = useMemo(
() =>
palette.length >= 2
? palette
: [theme.palette.primary.main, theme.palette.secondary.main, theme.palette.info.main],
[palette, theme],
);
const nodeCount = Math.min(nodeCountBase + Math.floor(ratingsCount * 1.5), 120);
const ref = useCanvasBanner<{ nodes: Node[] }>({
init: (width, height) => {
const pseudoRandom = (offset: number) => {
const x = Math.sin(seed + offset) * 10000;
return x - Math.floor(x);
};
const nodes: Node[] = [];
for (let i = 0; i < nodeCount; i++) {
const bx = pseudoRandom(i) * width;
const by = pseudoRandom(i + 100) * height;
nodes.push({
x: bx,
y: by,
baseX: bx,
baseY: by,
vx: 0,
vy: 0,
color: colors[i % colors.length],
size: 1 + pseudoRandom(i + 200) * 3,
});
}
return { nodes };
},
draw: ({ ctx, width, height, state, mouse }) => {
const { nodes } = state;
const pseudoRandom = (offset: number) => {
const x = Math.sin(seed + offset) * 10000;
return x - Math.floor(x);
};
ctx.clearRect(0, 0, width, height);
nodes.forEach((node) => {
const dxBase = node.baseX - node.x;
const dyBase = node.baseY - node.y;
node.vx += dxBase * speed;
node.vy += dyBase * speed;
const dxMouse = mouse.x - node.x;
const dyMouse = mouse.y - node.y;
const distMouse = Math.sqrt(dxMouse * dxMouse + dyMouse * dyMouse);
if (mouse.down) {
const dragDist = 300;
if (distMouse < dragDist) {
const power = (dragDist - distMouse) / dragDist;
node.vx += dxMouse * power * 0.08;
node.vy += dyMouse * power * 0.08;
}
} else {
const hoverDist = 150;
if (distMouse < hoverDist) {
const power = (hoverDist - distMouse) / hoverDist;
node.vx -= dxMouse * power * 0.02;
node.vy -= dyMouse * power * 0.02;
}
}
node.vx *= 0.88;
node.vy *= 0.88;
node.x += node.vx;
node.y += node.vy;
});
ctx.lineWidth = 1;
for (let i = 0; i < nodes.length; i++) {
let currentConnections = 0;
for (let j = i + 1; j < nodes.length && currentConnections < 6; j++) {
const dx = nodes[i].x - nodes[j].x;
const dy = nodes[i].y - nodes[j].y;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist < connectionDist) {
currentConnections++;
const opacity = (1 - dist / connectionDist) * globalOpacity;
ctx.beginPath();
ctx.moveTo(nodes[i].x, nodes[i].y);
ctx.lineTo(nodes[j].x, nodes[j].y);
const lineGrad = ctx.createLinearGradient(
nodes[i].x,
nodes[i].y,
nodes[j].x,
nodes[j].y,
);
lineGrad.addColorStop(0, alpha(nodes[i].color, opacity));
lineGrad.addColorStop(1, alpha(nodes[j].color, opacity));
ctx.strokeStyle = lineGrad;
ctx.stroke();
if (dist < connectionDist * 0.6 && pseudoRandom(i + j) > 0.92) {
for (let k = j + 1; k < nodes.length; k++) {
const d3x = nodes[i].x - nodes[k].x;
const d3y = nodes[i].y - nodes[k].y;
if (Math.sqrt(d3x * d3x + d3y * d3y) < connectionDist * 0.6) {
ctx.beginPath();
ctx.moveTo(nodes[i].x, nodes[i].y);
ctx.lineTo(nodes[j].x, nodes[j].y);
ctx.lineTo(nodes[k].x, nodes[k].y);
ctx.closePath();
ctx.fillStyle = alpha(nodes[i].color, 0.04);
ctx.fill();
break;
}
}
}
}
}
}
nodes.forEach((node) => {
ctx.beginPath();
ctx.arc(node.x, node.y, node.size, 0, Math.PI * 2);
ctx.fillStyle = node.color;
ctx.fill();
ctx.beginPath();
ctx.arc(node.x, node.y, node.size * 2.5, 0, Math.PI * 2);
ctx.fillStyle = alpha(node.color, 0.15);
ctx.fill();
});
},
observeTouch: true,
deps: [seed, colors, followersCount, theme, nodeCount, connectionDist, speed, globalOpacity],
});
return (
<canvas
ref={ref}
style={{
...CANVAS_STYLE,
mixBlendMode: theme.palette.mode === 'light' ? 'multiply' : 'screen',
}}
/>
);
};
export default GenerativeBanner;
@@ -1,6 +1,6 @@
import React, { useRef, useEffect, useMemo } from 'react'; import React, { useMemo } from 'react';
import { alpha, useTheme } from '@mui/material'; import { alpha, useTheme } from '@mui/material';
import { useBannerFrameGate } from './BannerPerformanceWrapper'; import { CANVAS_STYLE, useCanvasBanner } from './useCanvasBanner';
interface GenerativeFlowProps { interface GenerativeFlowProps {
username: string; username: string;
@@ -16,10 +16,29 @@ interface GenerativeFlowProps {
}; };
} }
interface Blob {
x: number;
y: number;
ox: number;
oy: number;
vx: number;
vy: number;
size: number;
color: string;
phase: number;
speedFactor: number;
}
const hashString = (str: string): number => {
let hash = 0;
for (let i = 0; i < str.length; i++) {
hash = str.charCodeAt(i) + ((hash << 5) - hash);
}
return hash;
};
const GenerativeFlowBanner: React.FC<GenerativeFlowProps> = ({ username, palette, settings }) => { const GenerativeFlowBanner: React.FC<GenerativeFlowProps> = ({ username, palette, settings }) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
const theme = useTheme(); const theme = useTheme();
const gate = useBannerFrameGate();
const blobCount = settings?.blobCount ?? 8; const blobCount = settings?.blobCount ?? 8;
const flowSpeed = settings?.flowSpeed ?? 0.008; const flowSpeed = settings?.flowSpeed ?? 0.008;
@@ -27,15 +46,7 @@ const GenerativeFlowBanner: React.FC<GenerativeFlowProps> = ({ username, palette
const blurAmount = settings?.blur ?? 60; const blurAmount = settings?.blur ?? 60;
const distortionFactor = settings?.distortion ?? 1.2; const distortionFactor = settings?.distortion ?? 1.2;
const getSeed = (str: string) => { const seed = useMemo(() => hashString(username), [username]);
let hash = 0;
for (let i = 0; i < str.length; i++) {
hash = str.charCodeAt(i) + ((hash << 5) - hash);
}
return hash;
};
const seed = useMemo(() => getSeed(username), [username]);
const brightColors = useMemo(() => { const brightColors = useMemo(() => {
const colors = const colors =
@@ -52,42 +63,17 @@ const GenerativeFlowBanner: React.FC<GenerativeFlowProps> = ({ username, palette
return sorted.slice(0, 4); return sorted.slice(0, 4);
}, [palette, theme]); }, [palette, theme]);
useEffect(() => { const ref = useCanvasBanner<{ blobs: Blob[]; time: number }>({
const canvas = canvasRef.current; init: (width, height) => {
if (!canvas) return;
const ctx = canvas.getContext('2d', { alpha: true });
if (!ctx) return;
let animationFrameId: number;
let time = 0;
interface Blob {
x: number;
y: number;
ox: number;
oy: number;
vx: number;
vy: number;
size: number;
color: string;
phase: number;
speedFactor: number;
}
let blobs: Blob[] = [];
const pseudoRandom = (offset: number) => { const pseudoRandom = (offset: number) => {
const x = Math.sin(seed + offset) * 10000; const x = Math.sin(seed + offset) * 10000;
return x - Math.floor(x); return x - Math.floor(x);
}; };
const blobs: Blob[] = [];
const initBlobs = (width: number, height: number) => {
const newBlobs = [];
for (let i = 0; i < blobCount; i++) { for (let i = 0; i < blobCount; i++) {
const rx = pseudoRandom(i) * width; const rx = pseudoRandom(i) * width;
const ry = pseudoRandom(i + 500) * height; const ry = pseudoRandom(i + 500) * height;
newBlobs.push({ blobs.push({
x: rx, x: rx,
y: ry, y: ry,
ox: rx, ox: rx,
@@ -100,59 +86,15 @@ const GenerativeFlowBanner: React.FC<GenerativeFlowProps> = ({ username, palette
speedFactor: 0.5 + pseudoRandom(i + 2000), speedFactor: 0.5 + pseudoRandom(i + 2000),
}); });
} }
return newBlobs; return { blobs, time: 0 };
}; },
draw: ({ ctx, width, height, state, mouse }) => {
const resize = () => { const { blobs } = state;
const parent = canvas.parentElement; state.time += flowSpeed;
if (parent) { const time = state.time;
const rect = parent.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
canvas.width = rect.width * dpr;
canvas.height = rect.height * dpr;
ctx.scale(dpr, dpr);
canvas.style.width = `${rect.width}px`;
canvas.style.height = `${rect.height}px`;
blobs = initBlobs(rect.width, rect.height);
}
};
const resizeObserver = new ResizeObserver(() => resize());
if (canvas.parentElement) resizeObserver.observe(canvas.parentElement);
resize();
const mouse = { x: -1000, y: -1000 };
const handleMouseMove = (e: MouseEvent) => {
const rect = canvas.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
if (x < 0 || y < 0 || x > rect.width || y > rect.height) {
mouse.x = -1000;
mouse.y = -1000;
} else {
mouse.x = x;
mouse.y = y;
}
};
window.addEventListener('mousemove', handleMouseMove);
const draw = (now = 0) => {
const decision = gate(now);
if (decision === 'halt') return;
if (decision === 'skip') {
animationFrameId = requestAnimationFrame(draw);
return;
}
const width = canvas.width / (window.devicePixelRatio || 1);
const height = canvas.height / (window.devicePixelRatio || 1);
if (!width || !height) {
animationFrameId = requestAnimationFrame(draw);
return;
}
ctx.clearRect(0, 0, width, height); ctx.clearRect(0, 0, width, height);
// Background Base
const isLight = theme.palette.mode === 'light'; const isLight = theme.palette.mode === 'light';
const bgGrad = ctx.createRadialGradient( const bgGrad = ctx.createRadialGradient(
width / 2, width / 2,
@@ -163,7 +105,6 @@ const GenerativeFlowBanner: React.FC<GenerativeFlowProps> = ({ username, palette
width, width,
); );
if (isLight) { if (isLight) {
// For light themes, use a deeper, more saturated version of the theme color
bgGrad.addColorStop( bgGrad.addColorStop(
0, 0,
alpha(brightColors[brightColors.length - 1] || brightColors[0], 0.6), alpha(brightColors[brightColors.length - 1] || brightColors[0], 0.6),
@@ -179,18 +120,13 @@ const GenerativeFlowBanner: React.FC<GenerativeFlowProps> = ({ username, palette
ctx.fillStyle = bgGrad; ctx.fillStyle = bgGrad;
ctx.fillRect(0, 0, width, height); ctx.fillRect(0, 0, width, height);
time += flowSpeed;
// Apply filter for "Liquid" look
ctx.filter = `blur(${blurAmount}px)`; ctx.filter = `blur(${blurAmount}px)`;
ctx.globalCompositeOperation = 'screen'; ctx.globalCompositeOperation = 'screen';
blobs.forEach((b) => { blobs.forEach((b) => {
// Harmonic autonomous drift
const driftX = Math.sin(time * b.speedFactor + b.phase) * (width / 5); const driftX = Math.sin(time * b.speedFactor + b.phase) * (width / 5);
const driftY = Math.cos(time * 0.8 * b.speedFactor + b.phase) * (height / 5); const driftY = Math.cos(time * 0.8 * b.speedFactor + b.phase) * (height / 5);
// Mouse distortion
const dxMouse = mouse.x - b.ox; const dxMouse = mouse.x - b.ox;
const dyMouse = mouse.y - b.oy; const dyMouse = mouse.y - b.oy;
const distMouse = Math.sqrt(dxMouse * dxMouse + dyMouse * dyMouse); const distMouse = Math.sqrt(dxMouse * dxMouse + dyMouse * dyMouse);
@@ -202,7 +138,6 @@ const GenerativeFlowBanner: React.FC<GenerativeFlowProps> = ({ username, palette
b.x = b.ox + driftX + mX; b.x = b.ox + driftX + mX;
b.y = b.oy + driftY + mY; b.y = b.oy + driftY + mY;
// Pulsing size
const pulse = 1 + Math.sin(time + b.phase) * 0.1; const pulse = 1 + Math.sin(time + b.phase) * 0.1;
const currentSize = b.size * pulse; const currentSize = b.size * pulse;
@@ -218,43 +153,11 @@ const GenerativeFlowBanner: React.FC<GenerativeFlowProps> = ({ username, palette
ctx.filter = 'none'; ctx.filter = 'none';
ctx.globalCompositeOperation = 'source-over'; ctx.globalCompositeOperation = 'source-over';
},
deps: [seed, brightColors, blobCount, flowSpeed, vibrancy, blurAmount, distortionFactor, theme],
});
animationFrameId = requestAnimationFrame(draw); return <canvas ref={ref} style={CANVAS_STYLE} />;
};
draw();
return () => {
resizeObserver.disconnect();
window.removeEventListener('mousemove', handleMouseMove);
cancelAnimationFrame(animationFrameId);
};
}, [
seed,
brightColors,
blobCount,
flowSpeed,
vibrancy,
blurAmount,
distortionFactor,
theme,
gate,
]);
return (
<canvas
ref={canvasRef}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
pointerEvents: 'none',
zIndex: 0,
}}
/>
);
}; };
export default GenerativeFlowBanner; export default GenerativeFlowBanner;
@@ -0,0 +1,199 @@
import React, { useMemo } from 'react';
import { alpha, useTheme } from '@mui/material';
import { CANVAS_STYLE, useCanvasBanner } from './useCanvasBanner';
interface HextechBannerProps {
username: string;
palette: string[];
ratingsCount: number;
followersCount: number;
settings?: {
gridSize?: number;
pulseSpeed?: number;
glowIntensity?: number;
streamCount?: number;
distortion?: number;
};
}
interface Stream {
segments: { x: number; y: number }[];
color: string;
life: number;
speed: number;
}
const hashString = (str: string): number => {
let hash = 0;
for (let i = 0; i < str.length; i++) {
hash = str.charCodeAt(i) + ((hash << 5) - hash);
}
return hash;
};
const HextechBanner: React.FC<HextechBannerProps> = ({ username, palette, settings }) => {
const theme = useTheme();
const gridSize = settings?.gridSize ?? 40;
const pulseSpeed = settings?.pulseSpeed ?? 0.02;
const glowIntensity = settings?.glowIntensity ?? 0.8;
const streamCount = settings?.streamCount ?? 12;
const distortionFactor = settings?.distortion ?? 0.5;
const seed = useMemo(() => hashString(username), [username]);
const brightColors = useMemo(() => {
const colors =
palette.length >= 2
? palette
: [theme.palette.primary.main, theme.palette.secondary.main, theme.palette.info.main];
const getBrightness = (hex: string) => {
const r = parseInt(hex.slice(1, 3), 16) || 0;
const g = parseInt(hex.slice(3, 5), 16) || 0;
const b = parseInt(hex.slice(5, 7), 16) || 0;
return (r * 299 + g * 587 + b * 114) / 1000;
};
const sorted = [...colors].sort((a, b) => getBrightness(b) - getBrightness(a));
return sorted.slice(0, 3);
}, [palette, theme]);
const ref = useCanvasBanner<{ streams: Stream[]; time: number }>({
init: () => ({ streams: [], time: 0 }),
draw: ({ ctx, width, height, state, mouse }) => {
state.time += pulseSpeed;
const time = state.time;
const pseudoRandom = (offset: number) => {
const x = Math.sin(seed + offset) * 10000;
return x - Math.floor(x);
};
const drawHexagon = (x: number, y: number, r: number, tilt: number) => {
ctx.beginPath();
for (let i = 0; i < 6; i++) {
const angle = (Math.PI / 3) * i + tilt;
const px = x + r * Math.cos(angle);
const py = y + r * Math.sin(angle);
if (i === 0) ctx.moveTo(px, py);
else ctx.lineTo(px, py);
}
ctx.closePath();
};
ctx.clearRect(0, 0, width, height);
const isLight = theme.palette.mode === 'light';
const bgGrad = ctx.createRadialGradient(
width / 2,
height / 2,
0,
width / 2,
height / 2,
width,
);
if (isLight) {
bgGrad.addColorStop(0, alpha(brightColors[2] || brightColors[0], 0.65));
bgGrad.addColorStop(1, alpha(brightColors[2] || brightColors[0], 0.95));
} else {
bgGrad.addColorStop(0, '#0a051a');
bgGrad.addColorStop(1, '#020105');
}
ctx.fillStyle = bgGrad;
ctx.fillRect(0, 0, width, height);
ctx.lineWidth = 1;
const h = gridSize * Math.sqrt(3);
const w = gridSize * 2;
for (let y = -gridSize; y < height + gridSize; y += h / 2) {
for (let x = -gridSize; x < width + gridSize; x += w * 0.75) {
const xOffset = Math.floor(y / (h / 2)) % 2 === 0 ? 0 : w * 0.375;
const finalX = x + xOffset;
const finalY = y;
const dist = Math.sqrt(Math.pow(finalX - mouse.x, 2) + Math.pow(finalY - mouse.y, 2));
const mouseImpact = Math.max(0, (200 - dist) / 200) * (mouse.down ? 2 : 1);
const dx =
Math.sin(time + finalX * 0.01) * gridSize * distortionFactor * (1 + mouseImpact);
const dy =
Math.cos(time + finalY * 0.01) * gridSize * distortionFactor * (1 + mouseImpact);
const drawX = finalX + dx;
const drawY = finalY + dy;
const pulseVal = 0.1 + Math.sin(time * 2 + (finalX + finalY) * 0.005) * 0.1;
const opacity = (0.1 + pulseVal + mouseImpact * 0.3) * glowIntensity;
ctx.strokeStyle = alpha(brightColors[0], opacity);
drawHexagon(drawX, drawY, gridSize * 0.9, time * 0.1);
ctx.stroke();
if (pseudoRandom(Math.floor(finalX) + Math.floor(finalY)) > 0.92) {
ctx.fillStyle = alpha(brightColors[1], opacity * 0.5);
ctx.font = `${gridSize * 0.5}px serif`;
ctx.fillText('⚡', drawX - 5, drawY + 5);
}
}
}
if (state.streams.length < streamCount && Math.random() > 0.9) {
const color = brightColors[Math.floor(pseudoRandom(time) * brightColors.length)];
state.streams.push({
segments: [{ x: pseudoRandom(time + 1) * width, y: pseudoRandom(time + 2) * height }],
color,
life: 1.0,
speed: 2 + pseudoRandom(time + 3) * 4,
});
}
ctx.globalCompositeOperation = 'screen';
state.streams.forEach((s, idx) => {
s.life -= 0.005;
const last = s.segments[s.segments.length - 1];
const ang = pseudoRandom(time + idx) * Math.PI * 2;
const nextX = last.x + Math.cos(ang) * s.speed;
const nextY = last.y + Math.sin(ang) * s.speed;
s.segments.push({ x: nextX, y: nextY });
if (s.segments.length > 20) s.segments.shift();
ctx.beginPath();
ctx.strokeStyle = alpha(s.color, s.life * glowIntensity);
ctx.lineWidth = 2;
s.segments.forEach((seg, i) => {
if (i === 0) ctx.moveTo(seg.x, seg.y);
else ctx.lineTo(seg.x, seg.y);
});
ctx.stroke();
const head = s.segments[s.segments.length - 1];
const g = ctx.createRadialGradient(head.x, head.y, 0, head.x, head.y, 15);
g.addColorStop(0, alpha(s.color, s.life));
g.addColorStop(1, alpha(s.color, 0));
ctx.fillStyle = g;
ctx.beginPath();
ctx.arc(head.x, head.y, 15, 0, Math.PI * 2);
ctx.fill();
});
ctx.globalCompositeOperation = 'source-over';
state.streams = state.streams.filter((s) => s.life > 0);
},
deps: [
seed,
brightColors,
gridSize,
pulseSpeed,
glowIntensity,
streamCount,
distortionFactor,
theme,
],
});
return <canvas ref={ref} style={CANVAS_STYLE} />;
};
export default HextechBanner;
@@ -0,0 +1,190 @@
import React, { useMemo } from 'react';
import { alpha, useTheme } from '@mui/material';
import { CANVAS_STYLE, useCanvasBanner } from './useCanvasBanner';
interface HextechCorruptionBannerProps {
username: string;
palette: string[];
ratingsCount: number;
followersCount: number;
settings?: {
cellCount?: number;
shimmerSpeed?: number;
glowIntensity?: number;
instability?: number;
shellDarkness?: number;
};
}
interface ControlPoint {
x: number;
y: number;
ox: number;
oy: number;
vx: number;
vy: number;
phase: number;
color: string;
}
const hashString = (str: string): number => {
let hash = 0;
for (let i = 0; i < str.length; i++) {
hash = str.charCodeAt(i) + ((hash << 5) - hash);
}
return hash;
};
const HextechCorruptionBanner: React.FC<HextechCorruptionBannerProps> = ({
username,
palette,
settings,
}) => {
const theme = useTheme();
const cellCount = settings?.cellCount ?? 40;
const shimmerSpeed = settings?.shimmerSpeed ?? 0.015;
const glowIntensity = settings?.glowIntensity ?? 0.9;
const instability = settings?.instability ?? 0.6;
const shellDarkness = settings?.shellDarkness ?? 0.85;
const seed = useMemo(() => hashString(username), [username]);
const brightColors = useMemo(() => {
const colors =
palette.length >= 2
? palette
: [theme.palette.primary.main, theme.palette.secondary.main, theme.palette.info.main];
const getBrightness = (hex: string) => {
const r = parseInt(hex.slice(1, 3), 16) || 0;
const g = parseInt(hex.slice(3, 5), 16) || 0;
const b = parseInt(hex.slice(5, 7), 16) || 0;
return (r * 299 + g * 587 + b * 114) / 1000;
};
const sorted = [...colors].sort((a, b) => getBrightness(b) - getBrightness(a));
return sorted.slice(0, 3);
}, [palette, theme]);
const ref = useCanvasBanner<{ points: ControlPoint[]; time: number }>({
init: (width, height) => {
const pseudoRandom = (offset: number) => {
const x = Math.sin(seed + offset) * 10000;
return x - Math.floor(x);
};
const points: ControlPoint[] = [];
for (let i = 0; i < cellCount; i++) {
const rx = pseudoRandom(i) * width;
const ry = pseudoRandom(i + 500) * height;
points.push({
x: rx,
y: ry,
ox: rx,
oy: ry,
vx: 0,
vy: 0,
phase: pseudoRandom(i + 1000) * Math.PI * 2,
color: brightColors[i % brightColors.length],
});
}
return { points, time: 0 };
},
draw: ({ ctx, width, height, state, mouse }) => {
const { points } = state;
state.time += shimmerSpeed;
const time = state.time;
ctx.clearRect(0, 0, width, height);
ctx.globalCompositeOperation = 'source-over';
points.forEach((p) => {
const dxMouse = mouse.x - p.x;
const dyMouse = mouse.y - p.y;
const distMouse = Math.sqrt(dxMouse * dxMouse + dyMouse * dyMouse);
const mouseForce = Math.max(0, (300 - distMouse) / 300);
const driftX = Math.sin(time + p.phase) * 20 * instability;
const driftY = Math.cos(time * 0.8 + p.phase) * 20 * instability;
p.x = p.ox + driftX + (mouse.down ? -dxMouse * mouseForce * 0.5 : 0);
p.y = p.oy + driftY + (mouse.down ? -dyMouse * mouseForce * 0.5 : 0);
const pulse = 0.7 + Math.sin(time * 3 + p.phase) * 0.3;
const glowSize = 60 + mouseForce * 40;
const grad = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, glowSize);
grad.addColorStop(0, alpha(p.color, 0.8 * glowIntensity * pulse));
grad.addColorStop(0.5, alpha(p.color, 0.3 * glowIntensity));
grad.addColorStop(1, alpha(p.color, 0));
ctx.fillStyle = grad;
ctx.beginPath();
ctx.arc(p.x, p.y, glowSize, 0, Math.PI * 2);
ctx.fill();
});
ctx.globalCompositeOperation = 'source-over';
const offCanvas = document.createElement('canvas');
const dpr = window.devicePixelRatio || 1;
offCanvas.width = width * dpr;
offCanvas.height = height * dpr;
const offCtx = offCanvas.getContext('2d');
if (offCtx) {
offCtx.scale(dpr, dpr);
const shellColor =
theme.palette.mode === 'light'
? alpha('#1a0a2a', shellDarkness)
: alpha('#05020a', shellDarkness);
offCtx.fillStyle = shellColor;
offCtx.fillRect(0, 0, width, height);
offCtx.globalCompositeOperation = 'destination-out';
points.forEach((p) => {
const holeSize = 15 + Math.sin(time + p.phase) * 5 + instability * 10;
offCtx.beginPath();
offCtx.arc(p.x, p.y, holeSize, 0, Math.PI * 2);
offCtx.fill();
for (let j = 0; j < 3; j++) {
const angle = p.phase + j * Math.PI * 0.6;
const hx = p.x + Math.cos(angle) * (holeSize * 1.5);
const hy = p.y + Math.sin(angle) * (holeSize * 1.5);
offCtx.beginPath();
offCtx.arc(hx, hy, holeSize * 0.4, 0, Math.PI * 2);
offCtx.fill();
}
});
ctx.drawImage(offCanvas, 0, 0, width, height);
}
ctx.globalCompositeOperation = 'screen';
ctx.lineWidth = 2;
points.forEach((p) => {
const pulse = 0.5 + Math.sin(time * 2 + p.phase) * 0.5;
ctx.strokeStyle = alpha(p.color, pulse * 0.4);
ctx.beginPath();
ctx.arc(p.x, p.y, 20 + pulse * 5, 0, Math.PI * 2);
ctx.stroke();
});
ctx.globalCompositeOperation = 'source-over';
},
deps: [
seed,
brightColors,
cellCount,
shimmerSpeed,
glowIntensity,
instability,
shellDarkness,
theme,
],
});
return <canvas ref={ref} style={CANVAS_STYLE} />;
};
export default HextechCorruptionBanner;
@@ -0,0 +1,55 @@
import React, { useState } from 'react';
interface MatrixBannerProps {
username: string;
palette: string[];
ratingsCount: number;
followersCount: number;
settings?: { opacity?: number };
}
const MatrixBanner: React.FC<MatrixBannerProps> = ({ username, settings }) => {
const [durations] = useState(() => Array.from({ length: 20 }, () => 2 + Math.random() * 3));
return (
<div
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
background: 'rgba(0,0,0,0.8)',
color: '#0f0',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontFamily: 'monospace',
zIndex: 0,
overflow: 'hidden',
opacity: settings?.opacity || 0.5,
}}
>
<div>
{durations.map((dur, i) => (
<div key={i} style={{ animation: `matrixFall ${dur}s linear infinite` }}>
{username.split('').map((char, j) => (
<span key={j} style={{ display: 'block' }}>
{char}
</span>
))}
</div>
))}
</div>
<style>
{`
@keyframes matrixFall {
0% { transform: translateY(-100%); }
100% { transform: translateY(100%); }
}
`}
</style>
</div>
);
};
export default MatrixBanner;
@@ -0,0 +1,237 @@
import React, { useMemo } from 'react';
import { alpha, useTheme } from '@mui/material';
import { CANVAS_STYLE, useCanvasBanner } from './useCanvasBanner';
interface NebulaBannerProps {
username: string;
palette: string[];
ratingsCount: number;
followersCount: number;
settings?: {
particleCount?: number;
speed?: number;
opacity?: number;
smearRadius?: number;
turbulence?: number;
returnSpeed?: number;
starDensity?: number;
};
}
interface Particle {
x: number;
y: number;
baseX: number;
baseY: number;
vx: number;
vy: number;
size: number;
color: string;
alpha: number;
targetAlpha: number;
phase: number;
}
interface Star {
x: number;
y: number;
size: number;
opacity: number;
}
const hashString = (str: string): number => {
let hash = 0;
for (let i = 0; i < str.length; i++) {
hash = str.charCodeAt(i) + ((hash << 5) - hash);
}
return hash;
};
const NebulaBanner: React.FC<NebulaBannerProps> = ({
username,
palette,
ratingsCount,
followersCount,
settings,
}) => {
const theme = useTheme();
const particleCount = settings?.particleCount ?? 400;
const speed = settings?.speed ?? 0.0015;
const globalOpacity = settings?.opacity ?? 0.6;
const smearRadius = settings?.smearRadius ?? 120;
const turbulenceStrength = settings?.turbulence ?? 0.4;
const returnSpeed = settings?.returnSpeed ?? 0.004;
const starDensity = settings?.starDensity ?? 150;
const seed = useMemo(() => hashString(username), [username]);
const colors = useMemo(
() =>
palette.length >= 2
? palette
: [theme.palette.primary.main, theme.palette.secondary.main, theme.palette.info.main],
[palette, theme],
);
const ref = useCanvasBanner<{ stars: Star[]; particles: Particle[]; time: number }>({
init: (width, height) => {
const pseudoRandom = (offset: number) => {
const x = Math.sin(seed + offset) * 10000;
return x - Math.floor(x);
};
const stars: Star[] = [];
for (let i = 0; i < starDensity; i++) {
stars.push({
x: pseudoRandom(i + 5000) * width,
y: pseudoRandom(i + 6000) * height,
size: pseudoRandom(i + 7000) * 1.5,
opacity: 0.1 + pseudoRandom(i + 8000) * 0.5,
});
}
const clusterCount = colors.length * 3;
const groupCenters = Array.from({ length: clusterCount }).map((_, i) => ({
x: (pseudoRandom(i * 15) * 0.8 + 0.1) * width,
y: (pseudoRandom(i * 25 + 7) * 0.8 + 0.1) * height,
}));
const particles: Particle[] = [];
for (let i = 0; i < particleCount; i++) {
const clusterIdx = i % clusterCount;
const colorIdx = clusterIdx % colors.length;
const center = groupCenters[clusterIdx];
const angle = pseudoRandom(i + 100) * Math.PI * 2;
const dist = pseudoRandom(i + 200) * (width * 0.12);
const bx = center.x + Math.cos(angle) * dist;
const by = center.y + Math.sin(angle) * dist;
particles.push({
x: bx,
y: by,
baseX: bx,
baseY: by,
vx: 0,
vy: 0,
size: 40 + pseudoRandom(i + 300) * 80,
color: colors[colorIdx],
alpha: 0,
targetAlpha: 0.05 + pseudoRandom(i + 400) * 0.15,
phase: pseudoRandom(i + 500) * Math.PI * 2,
});
}
return { stars, particles, time: 0 };
},
draw: ({ ctx, width, height, state, mouse }) => {
const { stars, particles } = state;
ctx.clearRect(0, 0, width, height);
const isLight = theme.palette.mode === 'light';
const skyGrad = ctx.createRadialGradient(
width / 2,
height / 2,
0,
width / 2,
height / 2,
width,
);
if (isLight) {
skyGrad.addColorStop(0, alpha(colors[colors.length - 1], 0.7));
skyGrad.addColorStop(0.5, alpha(colors[colors.length - 1], 0.9));
skyGrad.addColorStop(1, alpha(colors[colors.length - 1], 1.0));
} else {
skyGrad.addColorStop(0, '#1a1a3a');
skyGrad.addColorStop(0.5, '#0a0a1a');
skyGrad.addColorStop(1, '#050508');
}
ctx.fillStyle = skyGrad;
ctx.fillRect(0, 0, width, height);
ctx.fillStyle = '#fff';
stars.forEach((s) => {
ctx.globalAlpha = s.opacity * (0.7 + Math.sin(state.time * 2 + s.x) * 0.3);
ctx.beginPath();
ctx.arc(s.x, s.y, s.size, 0, Math.PI * 2);
ctx.fill();
});
ctx.globalAlpha = 1.0;
state.time += speed;
const time = state.time;
particles.forEach((p) => {
const dxBase = p.baseX - p.x;
const dyBase = p.baseY - p.y;
p.vx += dxBase * returnSpeed;
p.vy += dyBase * returnSpeed;
const dx = mouse.x - p.x;
const dy = mouse.y - p.y;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist < smearRadius) {
const force = (smearRadius - dist) / smearRadius;
if (mouse.down) {
const angle = Math.atan2(dy, dx);
p.vx += Math.cos(angle + Math.PI / 2) * turbulenceStrength * 2;
p.vy += Math.sin(angle + Math.PI / 2) * turbulenceStrength * 2;
} else {
const mdx = mouse.x - mouse.px;
const mdy = mouse.y - mouse.py;
p.vx += mdx * force * 0.15;
p.vy += mdy * force * 0.15;
p.vx -= (dx / dist) * force * 0.8;
p.vy -= (dy / dist) * force * 0.8;
}
}
p.vx += Math.sin(time + p.phase) * 0.03;
p.vy += Math.cos(time * 0.8 + p.phase) * 0.03;
p.vx *= 0.95;
p.vy *= 0.95;
p.x += p.vx;
p.y += p.vy;
if (p.alpha < p.targetAlpha) p.alpha += 0.001;
const grad = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, p.size);
const colorAlpha = p.alpha * globalOpacity;
grad.addColorStop(0, alpha(p.color, colorAlpha));
grad.addColorStop(0.6, alpha(p.color, colorAlpha * 0.2));
grad.addColorStop(1, alpha(p.color, 0));
ctx.fillStyle = grad;
ctx.globalCompositeOperation = 'screen';
ctx.beginPath();
ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
ctx.fill();
});
ctx.globalCompositeOperation = 'source-over';
},
trackPrev: true,
deps: [
seed,
colors,
ratingsCount,
followersCount,
theme,
particleCount,
speed,
globalOpacity,
smearRadius,
turbulenceStrength,
returnSpeed,
starDensity,
],
});
return <canvas ref={ref} style={CANVAS_STYLE} />;
};
export default NebulaBanner;
+10
View File
@@ -0,0 +1,10 @@
export { default as FirefliesBanner } from './FirefliesBanner';
export { default as GenerativeBanner } from './GenerativeBanner';
export { default as GenerativeFlowBanner } from './GenerativeFlowBanner';
export { default as HextechBanner } from './HextechBanner';
export { default as HextechCorruptionBanner } from './HextechCorruptionBanner';
export { default as MatrixBanner } from './MatrixBanner';
export { default as NebulaBanner } from './NebulaBanner';
export { BannerPerformanceWrapper, useBannerFrameGate } from './BannerPerformanceWrapper';
export { useCanvasBanner, CANVAS_STYLE } from './useCanvasBanner';
export type { CanvasMouse, DrawArgs, UseCanvasBannerOptions } from './useCanvasBanner';
@@ -0,0 +1,158 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { act, render } from '@testing-library/react';
import { BannerPerformanceWrapper } from './BannerPerformanceWrapper';
import { CANVAS_STYLE, useCanvasBanner } from './useCanvasBanner';
type ROCallback = (entries: ResizeObserverEntry[]) => void;
let roCallback: ROCallback | null = null;
let roObserveMock = vi.fn();
let roDisconnectMock = vi.fn();
class FakeResizeObserver {
constructor(cb: ROCallback) {
roCallback = cb;
}
observe = roObserveMock;
disconnect = roDisconnectMock;
unobserve = vi.fn();
}
class FakeIntersectionObserver {
constructor() {}
observe = vi.fn();
disconnect = vi.fn();
unobserve = vi.fn();
takeRecords = () => [];
root = null;
rootMargin = '';
thresholds = [];
}
let rafCallbacks: FrameRequestCallback[] = [];
const flushRaf = () => {
const cbs = rafCallbacks;
rafCallbacks = [];
cbs.forEach((cb) => cb(performance.now()));
};
beforeEach(() => {
roCallback = null;
roObserveMock = vi.fn();
roDisconnectMock = vi.fn();
rafCallbacks = [];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).ResizeObserver = FakeResizeObserver;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IntersectionObserver = FakeIntersectionObserver;
Object.defineProperty(window, 'matchMedia', {
configurable: true,
writable: true,
value: () => ({
matches: false,
media: '',
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
}),
});
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => {
rafCallbacks.push(cb);
return rafCallbacks.length;
});
vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(() => {});
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockReturnValue({
width: 200,
height: 100,
top: 0,
left: 0,
right: 200,
bottom: 100,
x: 0,
y: 0,
toJSON: () => ({}),
});
// jsdom doesn't implement Canvas 2D — stub it.
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockImplementation(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
() =>
({
scale: vi.fn(),
setTransform: vi.fn(),
}) as any,
);
});
afterEach(() => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
delete (globalThis as any).ResizeObserver;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
delete (globalThis as any).IntersectionObserver;
vi.restoreAllMocks();
});
const Harness = ({
init,
draw,
}: {
init: (w: number, h: number) => { count: number };
draw: (ctx: { state: { count: number } }) => void;
}) => {
const ref = useCanvasBanner<{ count: number }>({
init,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
draw: draw as any,
deps: [],
});
return (
<BannerPerformanceWrapper>
<canvas ref={ref} style={CANVAS_STYLE} data-testid="banner-canvas" />
</BannerPerformanceWrapper>
);
};
describe('useCanvasBanner', () => {
it('renders a canvas and calls init with the parent size', () => {
const init = vi.fn(() => ({ count: 1 }));
const draw = vi.fn();
const { getByTestId } = render(<Harness init={init} draw={draw} />);
expect(getByTestId('banner-canvas').tagName).toBe('CANVAS');
expect(init).toHaveBeenCalledWith(200, 100);
});
it('runs draw at least once on the next animation frame', () => {
const init = vi.fn(() => ({ count: 7 }));
const draw = vi.fn();
render(<Harness init={init} draw={draw} />);
act(() => {
flushRaf();
});
expect(draw).toHaveBeenCalled();
const arg = draw.mock.calls[0][0];
expect(arg.state.count).toBe(7);
expect(arg.width).toBe(200);
expect(arg.height).toBe(100);
});
it('re-runs init when ResizeObserver fires', () => {
const init = vi.fn(() => ({ count: 0 }));
const draw = vi.fn();
render(<Harness init={init} draw={draw} />);
expect(init).toHaveBeenCalledTimes(1);
act(() => {
roCallback?.([{} as ResizeObserverEntry]);
});
expect(init).toHaveBeenCalledTimes(2);
});
it('disconnects ResizeObserver on unmount', () => {
const init = vi.fn(() => ({ count: 0 }));
const draw = vi.fn();
const { unmount } = render(<Harness init={init} draw={draw} />);
unmount();
expect(roDisconnectMock).toHaveBeenCalled();
});
});
@@ -0,0 +1,187 @@
import { useEffect, useRef, type CSSProperties, type DependencyList, type RefObject } from 'react';
import { useBannerFrameGate } from './BannerPerformanceWrapper';
export interface CanvasMouse {
x: number;
y: number;
px: number;
py: number;
down: boolean;
}
export interface DrawArgs<S> {
ctx: CanvasRenderingContext2D;
width: number;
height: number;
state: S;
mouse: CanvasMouse;
now: number;
}
export interface UseCanvasBannerOptions<S> {
init: (width: number, height: number) => S;
draw: (args: DrawArgs<S>) => void;
observeTouch?: boolean;
trackPrev?: boolean;
deps: DependencyList;
}
const SENTINEL = -1000;
const makeMouse = (): CanvasMouse => ({
x: SENTINEL,
y: SENTINEL,
px: SENTINEL,
py: SENTINEL,
down: false,
});
export function useCanvasBanner<S>({
init,
draw,
observeTouch = false,
trackPrev = false,
deps,
}: UseCanvasBannerOptions<S>): RefObject<HTMLCanvasElement | null> {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const gate = useBannerFrameGate();
// Refs allow the effect dep list to drive re-init without losing identity
// of init/draw callbacks across renders.
const initRef = useRef(init);
const drawRef = useRef(draw);
initRef.current = init;
drawRef.current = draw;
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d', { alpha: true });
if (!ctx) return;
let raf = 0;
let state: S | null = null;
const mouse = makeMouse();
const dpr = () => window.devicePixelRatio || 1;
const resize = () => {
const parent = canvas.parentElement;
if (!parent) return;
const rect = parent.getBoundingClientRect();
const ratio = dpr();
canvas.width = rect.width * ratio;
canvas.height = rect.height * ratio;
canvas.style.width = `${rect.width}px`;
canvas.style.height = `${rect.height}px`;
// Reset before scaling so repeated resizes don't compound the transform.
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.scale(ratio, ratio);
state = initRef.current(rect.width, rect.height);
};
const ro = new ResizeObserver(resize);
if (canvas.parentElement) ro.observe(canvas.parentElement);
resize();
const updateFromClient = (clientX: number, clientY: number) => {
const rect = canvas.getBoundingClientRect();
const x = clientX - rect.left;
const y = clientY - rect.top;
if (trackPrev) {
mouse.px = mouse.x;
mouse.py = mouse.y;
}
if (x < 0 || y < 0 || x > rect.width || y > rect.height) {
mouse.x = SENTINEL;
mouse.y = SENTINEL;
mouse.down = false;
return false;
}
mouse.x = x;
mouse.y = y;
return true;
};
const onMouseMove = (e: MouseEvent) => {
updateFromClient(e.clientX, e.clientY);
};
const onMouseDown = (e: MouseEvent) => {
const rect = canvas.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
if (x >= 0 && y >= 0 && x <= rect.width && y <= rect.height) {
mouse.down = true;
}
};
const onMouseUp = () => {
mouse.down = false;
};
const onTouchMove = (e: TouchEvent) => {
if (e.touches.length > 0) {
updateFromClient(e.touches[0].clientX, e.touches[0].clientY);
}
};
const onTouchStart = (e: TouchEvent) => {
if (e.touches.length > 0 && updateFromClient(e.touches[0].clientX, e.touches[0].clientY)) {
mouse.down = true;
}
};
window.addEventListener('mousemove', onMouseMove);
window.addEventListener('mousedown', onMouseDown);
window.addEventListener('mouseup', onMouseUp);
if (observeTouch) {
window.addEventListener('touchmove', onTouchMove, { passive: false });
window.addEventListener('touchstart', onTouchStart);
window.addEventListener('touchend', onMouseUp);
}
const tick = (now = 0) => {
const decision = gate(now);
if (decision === 'halt') return;
if (decision === 'skip') {
raf = requestAnimationFrame(tick);
return;
}
const ratio = dpr();
const width = canvas.width / ratio;
const height = canvas.height / ratio;
if (width && height && state !== null) {
drawRef.current({ ctx, width, height, state, mouse, now });
}
raf = requestAnimationFrame(tick);
};
raf = requestAnimationFrame(tick);
return () => {
ro.disconnect();
window.removeEventListener('mousemove', onMouseMove);
window.removeEventListener('mousedown', onMouseDown);
window.removeEventListener('mouseup', onMouseUp);
if (observeTouch) {
window.removeEventListener('touchmove', onTouchMove);
window.removeEventListener('touchstart', onTouchStart);
window.removeEventListener('touchend', onMouseUp);
}
cancelAnimationFrame(raf);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [gate, observeTouch, trackPrev, ...deps]);
return canvasRef;
}
export const CANVAS_STYLE: CSSProperties = {
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
pointerEvents: 'none',
zIndex: 0,
};
+31 -46
View File
@@ -1,26 +1,14 @@
import { createContext, useCallback, useEffect, useState, type ReactNode } from 'react'; import { createContext, useCallback, useEffect, type ReactNode } from 'react';
import api from '../lib/api'; import { useQueryClient } from '@tanstack/react-query';
import { useAuth } from '../hooks/useAuth'; import { useAuth } from '../hooks/useAuth';
import { queryKeys } from '../api/keys';
import { useNotificationsQuery, type Notification } from '../api/queries/useNotifications';
import {
useMarkAllNotificationsRead,
useMarkNotificationRead,
} from '../api/mutations/useMarkNotifications';
export interface Notification { export type { Notification };
id: number;
actor_username: string;
actor_avatar: string | null;
notification_type:
| 'reply'
| 'mention'
| 'follow'
| 'ticket_new'
| 'ticket_reply'
| 'profile_comment';
rating: number | null;
reply: number | null;
is_read: boolean;
created_at: string;
flavor_name: string | null;
flavor_id: number | null;
profile_owner_username: string | null;
}
export interface NotificationContextValue { export interface NotificationContextValue {
notifications: Notification[]; notifications: Notification[];
@@ -33,49 +21,40 @@ export const NotificationContext = createContext<NotificationContextValue | null
export const NotificationProvider = ({ children }: { children: ReactNode }) => { export const NotificationProvider = ({ children }: { children: ReactNode }) => {
const { user, setUser, refetchUser } = useAuth(); const { user, setUser, refetchUser } = useAuth();
const [notifications, setNotifications] = useState<Notification[]>([]); const qc = useQueryClient();
const fetchNotifications = useCallback(async () => { const { data: notifications = [], refetch } = useNotificationsQuery(!!user);
try { const markAllMutation = useMarkAllNotificationsRead();
const res = await api.get('notifications/'); const markOneMutation = useMarkNotificationRead();
const data = Array.isArray(res.data) ? res.data : res.data.results || [];
setNotifications(data);
} catch {
/* ignore */
}
}, []);
useEffect(() => {
if (user) {
fetchNotifications();
}
}, [user, fetchNotifications]);
// Refetch the auth user on the same 60s cadence so the unread count badge
// stays in sync with anything that mutated server-side outside this client.
useEffect(() => { useEffect(() => {
const interval = setInterval(() => { const interval = setInterval(() => {
if (localStorage.getItem('access')) { if (localStorage.getItem('access')) {
fetchNotifications();
refetchUser(); refetchUser();
} }
}, 60000); }, 60_000);
return () => clearInterval(interval); return () => clearInterval(interval);
}, [fetchNotifications, refetchUser]); }, [refetchUser]);
const fetchNotifications = useCallback(async () => {
await refetch();
}, [refetch]);
const markAllRead = useCallback(async () => { const markAllRead = useCallback(async () => {
try { try {
await api.post('notifications/mark_all_read/'); await markAllMutation.mutateAsync();
setNotifications((prev) => prev.map((n) => ({ ...n, is_read: true })));
if (user) setUser({ ...user, unread_notifications_count: 0 }); if (user) setUser({ ...user, unread_notifications_count: 0 });
} catch { } catch {
/* ignore */ /* ignore */
} }
}, [user, setUser]); }, [markAllMutation, setUser, user]);
const markRead = useCallback( const markRead = useCallback(
async (id: number) => { async (id: number) => {
try { try {
await api.post(`notifications/${id}/mark_read/`); await markOneMutation.mutateAsync(id);
setNotifications((prev) => prev.map((n) => (n.id === id ? { ...n, is_read: true } : n)));
if (user) { if (user) {
setUser({ setUser({
...user, ...user,
@@ -86,9 +65,15 @@ export const NotificationProvider = ({ children }: { children: ReactNode }) => {
/* ignore */ /* ignore */
} }
}, },
[user, setUser], [markOneMutation, setUser, user],
); );
// Clear cached notifications on logout so the next user doesn't briefly
// see the previous user's data.
useEffect(() => {
if (!user) qc.removeQueries({ queryKey: queryKeys.notifications() });
}, [user, qc]);
return ( return (
<NotificationContext.Provider <NotificationContext.Provider
value={{ notifications, fetchNotifications, markAllRead, markRead }} value={{ notifications, fetchNotifications, markAllRead, markRead }}
+12 -2
View File
@@ -37,7 +37,13 @@ api.interceptors.request.use((config) => {
return config; return config;
}); });
type RetriableConfig = AxiosRequestConfig & { _retry?: boolean }; type RetriableConfig = AxiosRequestConfig & {
_retry?: boolean;
/** When true, a 401 that survives refresh will silently reject instead of
* navigating to /login. Use for background probes (e.g. /users/me/) so
* anonymous visitors aren't bounced off public pages. */
skipAuthRedirect?: boolean;
};
let refreshPromise: Promise<string | null> | null = null; let refreshPromise: Promise<string | null> | null = null;
@@ -76,7 +82,11 @@ api.interceptors.response.use(
refreshPromise = null; refreshPromise = null;
if (!newAccess) { if (!newAccess) {
if (typeof window !== 'undefined' && window.location.pathname !== '/login') { if (
!original.skipAuthRedirect &&
typeof window !== 'undefined' &&
window.location.pathname !== '/login'
) {
window.location.href = '/login'; window.location.href = '/login';
} }
return Promise.reject(error); return Promise.reject(error);
+63 -56
View File
@@ -1,4 +1,5 @@
import React, { useCallback, useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { import {
Typography, Typography,
Box, Box,
@@ -43,7 +44,21 @@ import PlayArrowIcon from '@mui/icons-material/PlayArrow';
import InfoIcon from '@mui/icons-material/Info'; import InfoIcon from '@mui/icons-material/Info';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import api from '../lib/api'; import {
useAdminConfig,
useAdminJobs,
useAdminStats,
useAdminUsers,
} from '../api/queries/useAdminQueries';
import { useBannersList } from '../api/queries/useBanners';
import {
useActivateBanner,
useSendTestEmail,
useToggleBannerEnabled,
useTriggerJob,
useUpdateAdminConfig,
useUpdateJobSchedule,
} from '../api/mutations/useAdminMutations';
import { useTitle } from '../hooks/useTitle'; import { useTitle } from '../hooks/useTitle';
import { formatDate } from '../utils/date'; import { formatDate } from '../utils/date';
import BannerSettingsDialog from '../components/BannerSettingsDialog'; import BannerSettingsDialog from '../components/BannerSettingsDialog';
@@ -125,59 +140,56 @@ const AdminDashboard: React.FC = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const isMobile = useMediaQuery('(max-width:900px)'); const isMobile = useMediaQuery('(max-width:900px)');
useTitle(t('admin.title')); useTitle(t('admin.title'));
const [stats, setStats] = useState<Stats | null>(null); const qc = useQueryClient();
const [users, setUsers] = useState<AdminUser[]>([]); const { data: stats = null, isLoading: statsLoading } = useAdminStats() as unknown as {
const [banners, setBanners] = useState<Banner[]>([]); data: Stats | null | undefined;
const [jobs, setJobs] = useState<Job[]>([]); isLoading: boolean;
const [config, setConfig] = useState<SystemConfig | null>(null); };
const [loading, setLoading] = useState(true); const { data: users = [], isLoading: usersLoading } = useAdminUsers() as unknown as {
data: AdminUser[];
isLoading: boolean;
};
const { data: banners = [], isLoading: bannersLoading } = useBannersList() as unknown as {
data: Banner[];
isLoading: boolean;
};
const { data: jobs = [], isLoading: jobsLoading } = useAdminJobs() as unknown as {
data: Job[];
isLoading: boolean;
};
const { data: config = null, isLoading: configLoading } = useAdminConfig() as unknown as {
data: SystemConfig | null | undefined;
isLoading: boolean;
};
const updateConfigMutation = useUpdateAdminConfig();
const triggerJobMutation = useTriggerJob();
const updateJobScheduleMutation = useUpdateJobSchedule();
const activateBannerMutation = useActivateBanner();
const toggleBannerEnabledMutation = useToggleBannerEnabled();
const sendTestEmailMutation = useSendTestEmail();
const loading = statsLoading || usersLoading || bannersLoading || jobsLoading || configLoading;
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [currentTab, setCurrentTab] = useState(0); const [currentTab, setCurrentTab] = useState(0);
const [selectedBanner, setSelectedBanner] = useState<Banner | null>(null); const [selectedBanner, setSelectedBanner] = useState<Banner | null>(null);
const [isSettingsDialogOpen, setIsSettingsDialogOpen] = useState(false); const [isSettingsDialogOpen, setIsSettingsDialogOpen] = useState(false);
const { notify } = useToast(); const { notify } = useToast();
const fetchData = useCallback(async (silent = false) => { // Poll the jobs query faster while anything is running on the Jobs tab.
if (!silent) setLoading(true);
try {
const [statsRes, usersRes, bannersRes, jobsRes, configRes] = await Promise.all([
api.get('admin-custom/stats/'),
api.get('admin-custom/users/'),
api.get('banners/'),
api.get('admin-custom/jobs/'),
api.get('admin-custom/config/'),
]);
setStats(statsRes.data);
setUsers(usersRes.data);
const bannersData = Array.isArray(bannersRes.data)
? bannersRes.data
: bannersRes.data.results || [];
setBanners(bannersData);
setJobs(jobsRes.data);
setConfig(configRes.data);
} catch (err) {
console.error('Data fetch error:', err);
}
if (!silent) setLoading(false);
}, []);
// Poll for job updates if any are running or pending
useEffect(() => { useEffect(() => {
const anyRunning = jobs.some((j) => j.status === 'running' || j.status === 'pending'); const anyRunning = jobs.some((j) => j.status === 'running' || j.status === 'pending');
if (anyRunning && currentTab === 3) { if (anyRunning && currentTab === 3) {
const interval = setInterval(() => fetchData(true), 3000); const interval = setInterval(() => {
qc.invalidateQueries({ queryKey: ['adminJobs'] });
}, 3000);
return () => clearInterval(interval); return () => clearInterval(interval);
} }
}, [jobs, currentTab, fetchData]); }, [jobs, currentTab, qc]);
const handleUpdateConfig = async (key: string, value: unknown) => { const handleUpdateConfig = async (key: string, value: unknown) => {
try { try {
const res = await api.patch('admin-custom/config/', { [key]: value }); await updateConfigMutation.mutateAsync({ key, value });
setConfig(res.data);
} catch { } catch {
notify({ message: 'Failed to update configuration', severity: 'error' }); notify({ message: 'Failed to update configuration', severity: 'error' });
} }
@@ -185,8 +197,7 @@ const AdminDashboard: React.FC = () => {
const handleTriggerJob = async (id: number) => { const handleTriggerJob = async (id: number) => {
try { try {
await api.post(`admin-custom/${id}/trigger_job/`); await triggerJobMutation.mutateAsync(id);
fetchData(true);
} catch { } catch {
notify({ message: 'Failed to trigger job', severity: 'error' }); notify({ message: 'Failed to trigger job', severity: 'error' });
} }
@@ -196,8 +207,7 @@ const AdminDashboard: React.FC = () => {
try { try {
const data: Record<string, unknown> = { interval_hours: interval }; const data: Record<string, unknown> = { interval_hours: interval };
if (nextRun) data.next_run = nextRun; if (nextRun) data.next_run = nextRun;
await api.patch(`admin-custom/${id}/update_job_schedule/`, data); await updateJobScheduleMutation.mutateAsync({ id, data });
fetchData(true);
} catch { } catch {
notify({ message: 'Failed to update schedule', severity: 'error' }); notify({ message: 'Failed to update schedule', severity: 'error' });
} }
@@ -205,8 +215,7 @@ const AdminDashboard: React.FC = () => {
const handleActivateBanner = async (id: number) => { const handleActivateBanner = async (id: number) => {
try { try {
await api.post(`banners/${id}/activate/`); await activateBannerMutation.mutateAsync(id);
fetchData();
} catch (err) { } catch (err) {
console.error(err); console.error(err);
} }
@@ -214,8 +223,7 @@ const AdminDashboard: React.FC = () => {
const handleToggleEnabled = async (id: number) => { const handleToggleEnabled = async (id: number) => {
try { try {
await api.post(`banners/${id}/toggle_enabled/`); await toggleBannerEnabledMutation.mutateAsync(id);
fetchData();
} catch (err) { } catch (err) {
const error = err as { response?: { data?: { error?: string } } }; const error = err as { response?: { data?: { error?: string } } };
notify({ notify({
@@ -225,13 +233,9 @@ const AdminDashboard: React.FC = () => {
} }
}; };
useEffect(() => {
fetchData();
}, [fetchData]);
const handleSendTestEmail = async () => { const handleSendTestEmail = async () => {
try { try {
await api.post('admin-custom/send_test_email/'); await sendTestEmailMutation.mutateAsync();
notify({ message: t('admin.testEmailSuccess'), severity: 'success' }); notify({ message: t('admin.testEmailSuccess'), severity: 'success' });
} catch (err) { } catch (err) {
const error = err as { response?: { data?: { error?: string } } }; const error = err as { response?: { data?: { error?: string } } };
@@ -616,7 +620,10 @@ const AdminDashboard: React.FC = () => {
title="No banner models found" title="No banner models found"
subtitle="No banners registered in the database." subtitle="No banners registered in the database."
action={ action={
<Button variant="outlined" onClick={() => fetchData()}> <Button
variant="outlined"
onClick={() => qc.invalidateQueries({ queryKey: ['banners'] })}
>
Retry Loading Retry Loading
</Button> </Button>
} }
@@ -857,7 +864,7 @@ const AdminDashboard: React.FC = () => {
setSelectedBanner(null); setSelectedBanner(null);
}} }}
banner={selectedBanner} banner={selectedBanner}
onSave={fetchData} onSave={() => qc.invalidateQueries({ queryKey: ['banners'] })}
/> />
</PageShell> </PageShell>
); );
+18 -29
View File
@@ -1,60 +1,48 @@
import React, { useState, useEffect, useCallback } from 'react'; import React, { useEffect, useState } from 'react';
import { Box, Button, Stack, CircularProgress, TextField } from '@mui/material'; import { Box, Button, Stack, CircularProgress, TextField } from '@mui/material';
import ArrowBackIcon from '@mui/icons-material/ArrowBack'; import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import DeleteIcon from '@mui/icons-material/Delete'; import DeleteIcon from '@mui/icons-material/Delete';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useNavigate, useParams } from 'react-router-dom'; import { useNavigate, useParams } from 'react-router-dom';
import api from '../lib/api'; import { useAdminRating } from '../api/queries/useAdminQueries';
import { useDeleteRating, useUpdateRating } from '../api/mutations/useRatingMutations';
import { useTitle } from '../hooks/useTitle'; import { useTitle } from '../hooks/useTitle';
import MentionTextField from '../components/MentionTextField'; import MentionTextField from '../components/MentionTextField';
import { PageShell, SectionHeader, FormCard } from '../components/ui'; import { PageShell, SectionHeader, FormCard } from '../components/ui';
import { useToast } from '../hooks/useToast'; import { useToast } from '../hooks/useToast';
import { useConfirm } from '../hooks/useConfirm'; import { useConfirm } from '../hooks/useConfirm';
interface AdminRating {
id: number;
user: string;
score: number;
comment: string | null;
flavor_name: string;
}
const AdminRatingDetail: React.FC = () => { const AdminRatingDetail: React.FC = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
const { data: rating, isLoading: loading, error } = useAdminRating(id);
const updateRating = useUpdateRating();
const deleteRating = useDeleteRating();
const [score, setScore] = useState(0); const [score, setScore] = useState(0);
const [comment, setComment] = useState(''); const [comment, setComment] = useState('');
const [rating, setRating] = useState<AdminRating | null>(null);
const [loading, setLoading] = useState(true);
const { notify } = useToast(); const { notify } = useToast();
const { confirm } = useConfirm(); const { confirm } = useConfirm();
const fetchRating = useCallback(async () => { useEffect(() => {
try { if (rating) {
const res = await api.get<AdminRating>(`ratings/${id}/`); setScore(rating.score);
setRating(res.data); setComment(rating.comment || '');
setScore(res.data.score);
setComment(res.data.comment || '');
} catch (err) {
console.error(err);
navigate('/admin-panel');
} finally {
setLoading(false);
} }
}, [id, navigate]); }, [rating]);
useEffect(() => { useEffect(() => {
fetchRating(); if (error) navigate('/admin-panel');
}, [fetchRating]); }, [error, navigate]);
useTitle(rating ? `${t('admin.manageRating')}: ${rating.flavor_name}` : 'Rating Management'); useTitle(rating ? `${t('admin.manageRating')}: ${rating.flavor_name}` : 'Rating Management');
const handleUpdate = async (e: React.FormEvent) => { const handleUpdate = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (!id) return;
try { try {
await api.patch(`ratings/${id}/`, { score, comment }); await updateRating.mutateAsync({ id: Number(id), score, comment });
fetchRating();
notify({ message: 'Rating updated!', severity: 'success' }); notify({ message: 'Rating updated!', severity: 'success' });
} catch { } catch {
notify({ message: 'Update failed', severity: 'error' }); notify({ message: 'Update failed', severity: 'error' });
@@ -62,9 +50,10 @@ const AdminRatingDetail: React.FC = () => {
}; };
const handleDelete = async () => { const handleDelete = async () => {
if (!id) return;
if (!(await confirm({ message: 'Delete this rating and all replies?', danger: true }))) return; if (!(await confirm({ message: 'Delete this rating and all replies?', danger: true }))) return;
try { try {
await api.delete(`ratings/${id}/`); await deleteRating.mutateAsync(Number(id));
navigate('/admin-panel'); navigate('/admin-panel');
} catch { } catch {
notify({ message: 'Delete failed', severity: 'error' }); notify({ message: 'Delete failed', severity: 'error' });
+17 -27
View File
@@ -1,56 +1,45 @@
import React, { useState, useEffect, useCallback } from 'react'; import React, { useEffect, useState } from 'react';
import { Box, Button, Stack, CircularProgress } from '@mui/material'; import { Box, Button, Stack, CircularProgress } from '@mui/material';
import ArrowBackIcon from '@mui/icons-material/ArrowBack'; import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import DeleteIcon from '@mui/icons-material/Delete'; import DeleteIcon from '@mui/icons-material/Delete';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useNavigate, useParams } from 'react-router-dom'; import { useNavigate, useParams } from 'react-router-dom';
import api from '../lib/api'; import { useAdminReply } from '../api/queries/useAdminQueries';
import { useDeleteReply } from '../api/mutations/useRatingMutations';
import { useUpdateReply } from '../api/mutations/useReplyMutations';
import { useTitle } from '../hooks/useTitle'; import { useTitle } from '../hooks/useTitle';
import MentionTextField from '../components/MentionTextField'; import MentionTextField from '../components/MentionTextField';
import { PageShell, SectionHeader, FormCard } from '../components/ui'; import { PageShell, SectionHeader, FormCard } from '../components/ui';
import { useToast } from '../hooks/useToast'; import { useToast } from '../hooks/useToast';
import { useConfirm } from '../hooks/useConfirm'; import { useConfirm } from '../hooks/useConfirm';
interface AdminReply {
id: number;
user: string;
text: string;
}
const AdminReplyDetail: React.FC = () => { const AdminReplyDetail: React.FC = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
const { data: reply, isLoading: loading, error } = useAdminReply(id);
const updateReply = useUpdateReply();
const deleteReply = useDeleteReply();
const [text, setText] = useState(''); const [text, setText] = useState('');
const [reply, setReply] = useState<AdminReply | null>(null);
const [loading, setLoading] = useState(true);
const { notify } = useToast(); const { notify } = useToast();
const { confirm } = useConfirm(); const { confirm } = useConfirm();
const fetchReply = useCallback(async () => { useEffect(() => {
try { if (reply) setText(reply.text);
const res = await api.get<AdminReply>(`replies/${id}/`); }, [reply]);
setReply(res.data);
setText(res.data.text);
} catch (err) {
console.error(err);
navigate('/admin-panel');
} finally {
setLoading(false);
}
}, [id, navigate]);
useEffect(() => { useEffect(() => {
fetchReply(); if (error) navigate('/admin-panel');
}, [fetchReply]); }, [error, navigate]);
useTitle(reply ? `${t('admin.manageReply')}` : 'Reply Management'); useTitle(reply ? `${t('admin.manageReply')}` : 'Reply Management');
const handleUpdate = async (e: React.FormEvent) => { const handleUpdate = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (!id) return;
try { try {
await api.patch(`replies/${id}/`, { text }); await updateReply.mutateAsync({ replyId: Number(id), text });
fetchReply();
notify({ message: 'Reply updated!', severity: 'success' }); notify({ message: 'Reply updated!', severity: 'success' });
} catch { } catch {
notify({ message: 'Update failed', severity: 'error' }); notify({ message: 'Update failed', severity: 'error' });
@@ -58,9 +47,10 @@ const AdminReplyDetail: React.FC = () => {
}; };
const handleDelete = async () => { const handleDelete = async () => {
if (!id) return;
if (!(await confirm({ message: 'Delete this reply?', danger: true }))) return; if (!(await confirm({ message: 'Delete this reply?', danger: true }))) return;
try { try {
await api.delete(`replies/${id}/`); await deleteReply.mutateAsync(Number(id));
navigate('/admin-panel'); navigate('/admin-panel');
} catch { } catch {
notify({ message: 'Delete failed', severity: 'error' }); notify({ message: 'Delete failed', severity: 'error' });
+39 -31
View File
@@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { import {
Typography, Typography,
Box, Box,
@@ -24,7 +24,14 @@ import HistoryIcon from '@mui/icons-material/History';
import RateReviewIcon from '@mui/icons-material/RateReview'; import RateReviewIcon from '@mui/icons-material/RateReview';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useNavigate, useParams } from 'react-router-dom'; import { useNavigate, useParams } from 'react-router-dom';
import api from '../lib/api'; import { useAdminUserDetail } from '../api/queries/useAdminQueries';
import { useDeleteUser, useToggleUserActive } from '../api/mutations/useAdminMutations';
import {
useDeleteRating,
useDeleteReply,
useUpdateRating,
} from '../api/mutations/useRatingMutations';
import { useUpdateReply } from '../api/mutations/useReplyMutations';
import { useTitle } from '../hooks/useTitle'; import { useTitle } from '../hooks/useTitle';
import { formatDate } from '../utils/date'; import { formatDate } from '../utils/date';
import MentionTextField from '../components/MentionTextField'; import MentionTextField from '../components/MentionTextField';
@@ -72,10 +79,23 @@ const AdminUserDetail: React.FC = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
const [user, setUser] = useState<AdminUser | null>(null); const {
const [loading, setLoading] = useState(true); data: user,
const [activeTab, setActiveTab] = useState(0); isLoading: loading,
error,
} = useAdminUserDetail(id) as {
data: AdminUser | undefined;
isLoading: boolean;
error: unknown;
};
const toggleActive = useToggleUserActive();
const deleteUserMutation = useDeleteUser();
const updateRatingMutation = useUpdateRating();
const deleteRatingMutation = useDeleteRating();
const updateReplyMutation = useUpdateReply();
const deleteReplyMutation = useDeleteReply();
const [activeTab, setActiveTab] = useState(0);
const [editingRatingId, setEditingRatingId] = useState<number | null>(null); const [editingRatingId, setEditingRatingId] = useState<number | null>(null);
const [editScore, setEditScore] = useState(0); const [editScore, setEditScore] = useState(0);
const [editComment, setEditComment] = useState(''); const [editComment, setEditComment] = useState('');
@@ -86,29 +106,16 @@ const AdminUserDetail: React.FC = () => {
const { notify } = useToast(); const { notify } = useToast();
const { confirm } = useConfirm(); const { confirm } = useConfirm();
const fetchUser = useCallback(async () => {
try {
const res = await api.get(`admin-custom/${id}/user_detail/`);
setUser(res.data);
} catch (err) {
console.error(err);
navigate('/admin-panel');
} finally {
setLoading(false);
}
}, [id, navigate]);
useEffect(() => { useEffect(() => {
fetchUser(); if (error) navigate('/admin-panel');
}, [fetchUser]); }, [error, navigate]);
useTitle(user ? `${t('admin.manageUser')}: ${user.username}` : 'User Management'); useTitle(user ? `${t('admin.manageUser')}: ${user.username}` : 'User Management');
const handleToggleActive = async () => { const handleToggleActive = async () => {
if (!user) return; if (!user || !id) return;
try { try {
await api.patch(`admin-custom/${id}/user_detail/`, { is_active: !user.is_active }); await toggleActive.mutateAsync({ id, isActive: user.is_active });
fetchUser();
} catch { } catch {
notify({ message: 'Action failed', severity: 'error' }); notify({ message: 'Action failed', severity: 'error' });
} }
@@ -116,9 +123,12 @@ const AdminUserDetail: React.FC = () => {
const handleUpdateRating = async (ratingId: number) => { const handleUpdateRating = async (ratingId: number) => {
try { try {
await api.patch(`ratings/${ratingId}/`, { score: editScore, comment: editComment }); await updateRatingMutation.mutateAsync({
id: ratingId,
score: editScore,
comment: editComment,
});
setEditingRatingId(null); setEditingRatingId(null);
fetchUser();
} catch { } catch {
notify({ message: 'Update failed', severity: 'error' }); notify({ message: 'Update failed', severity: 'error' });
} }
@@ -127,8 +137,7 @@ const AdminUserDetail: React.FC = () => {
const handleDeleteRating = async (ratingId: number) => { const handleDeleteRating = async (ratingId: number) => {
if (!(await confirm({ message: 'Delete this rating?', danger: true }))) return; if (!(await confirm({ message: 'Delete this rating?', danger: true }))) return;
try { try {
await api.delete(`ratings/${ratingId}/`); await deleteRatingMutation.mutateAsync(ratingId);
fetchUser();
} catch { } catch {
notify({ message: 'Delete failed', severity: 'error' }); notify({ message: 'Delete failed', severity: 'error' });
} }
@@ -136,9 +145,8 @@ const AdminUserDetail: React.FC = () => {
const handleUpdateReply = async (replyId: number) => { const handleUpdateReply = async (replyId: number) => {
try { try {
await api.patch(`replies/${replyId}/`, { text: editReplyText }); await updateReplyMutation.mutateAsync({ replyId, text: editReplyText });
setEditingReplyId(null); setEditingReplyId(null);
fetchUser();
} catch { } catch {
notify({ message: 'Update failed', severity: 'error' }); notify({ message: 'Update failed', severity: 'error' });
} }
@@ -147,14 +155,14 @@ const AdminUserDetail: React.FC = () => {
const handleDeleteReply = async (replyId: number) => { const handleDeleteReply = async (replyId: number) => {
if (!(await confirm({ message: 'Delete this reply?', danger: true }))) return; if (!(await confirm({ message: 'Delete this reply?', danger: true }))) return;
try { try {
await api.delete(`replies/${replyId}/`); await deleteReplyMutation.mutateAsync(replyId);
fetchUser();
} catch { } catch {
notify({ message: 'Delete failed', severity: 'error' }); notify({ message: 'Delete failed', severity: 'error' });
} }
}; };
const handleDelete = async () => { const handleDelete = async () => {
if (!id) return;
const ok = await confirm({ const ok = await confirm({
title: 'Delete user', title: 'Delete user',
message: 'PERMANENTLY DELETE THIS USER AND ALL DATA? This cannot be undone.', message: 'PERMANENTLY DELETE THIS USER AND ALL DATA? This cannot be undone.',
@@ -163,7 +171,7 @@ const AdminUserDetail: React.FC = () => {
}); });
if (!ok) return; if (!ok) return;
try { try {
await api.delete(`admin-custom/${id}/user_detail/`); await deleteUserMutation.mutateAsync(id);
navigate('/admin-panel'); navigate('/admin-panel');
} catch { } catch {
notify({ message: 'Delete failed', severity: 'error' }); notify({ message: 'Delete failed', severity: 'error' });
+28 -111
View File
@@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { import {
Typography, Typography,
Box, Box,
@@ -26,7 +26,14 @@ import NotificationsIcon from '@mui/icons-material/Notifications';
import PeopleIcon from '@mui/icons-material/People'; import PeopleIcon from '@mui/icons-material/People';
import ArrowBackIcon from '@mui/icons-material/ArrowBack'; import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import { Link, useNavigate } from 'react-router-dom'; import { Link, useNavigate } from 'react-router-dom';
import api from '../lib/api'; import {
useCommunityFeed,
useFollowedTopFlavors,
useFollowingList,
type FeedRating,
} from '../api/queries/useCommunityFeed';
import { useNotificationsQuery } from '../api/queries/useNotifications';
import { useCreateReply } from '../api/mutations/useRatingMutations';
import { useTitle } from '../hooks/useTitle'; import { useTitle } from '../hooks/useTitle';
import RichText from '../components/RichText'; import RichText from '../components/RichText';
import { formatDate } from '../utils/date'; import { formatDate } from '../utils/date';
@@ -36,61 +43,6 @@ import RatingBadge from '../components/RatingBadge';
import { PageShell, HeroBackdrop, SectionHeader, GlassCard, EmptyState } from '../components/ui'; import { PageShell, HeroBackdrop, SectionHeader, GlassCard, EmptyState } from '../components/ui';
import { useToast } from '../hooks/useToast'; import { useToast } from '../hooks/useToast';
interface Reply {
id: number;
user: string;
text: string;
created_at: string;
}
interface FeedRating {
id: number;
user: string;
user_id: number;
user_avatar: string | null;
flavor: number;
flavor_name: string;
flavor_image: string | null;
category_name: string;
category_slug: string;
score: number;
comment: string;
created_at: string;
replies: Reply[];
}
interface FollowedUser {
id: number;
username: string;
avatar: string | null;
}
interface Notification {
id: number;
actor_username: string;
actor_avatar: string | null;
notification_type:
| 'reply'
| 'mention'
| 'follow'
| 'ticket_new'
| 'ticket_reply'
| 'profile_comment';
rating: number | null;
reply: number | null;
is_read: boolean;
created_at: string;
flavor_name: string | null;
flavor_id: number | null;
}
interface TopFlavor {
id: number;
name: string;
image_url: string | null;
average_rating: number;
}
interface CommunityFeedProps { interface CommunityFeedProps {
adminMode?: boolean; adminMode?: boolean;
} }
@@ -125,17 +77,23 @@ const CommunityFeed: React.FC<CommunityFeedProps> = ({ adminMode }) => {
useTitle(t('community.title')); useTitle(t('community.title'));
const navigate = useNavigate(); const navigate = useNavigate();
const { notify } = useToast(); const { notify } = useToast();
const [ratings, setRatings] = useState<FeedRating[]>([]);
const [following, setFollowing] = useState<FollowedUser[]>([]);
const [notifications, setNotifications] = useState<Notification[]>([]);
const [loading, setLoading] = useState(true);
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [followingSearch, setFollowingSearch] = useState(''); const [followingSearch, setFollowingSearch] = useState('');
const [topFollowed, setTopFollowed] = useState<TopFlavor[]>([]);
const [replyInputs, setReplyInputs] = useState<Record<number, string>>({}); const [replyInputs, setReplyInputs] = useState<Record<number, string>>({});
const [expandedReplies, setExpandedReplies] = useState<Record<number, boolean>>({}); const [expandedReplies, setExpandedReplies] = useState<Record<number, boolean>>({});
const isAuthed = !!localStorage.getItem('access');
const { data: feed, isLoading: feedLoading, error: feedError } = useCommunityFeed(page);
const { data: following = [] } = useFollowingList();
const { data: notificationsAll = [] } = useNotificationsQuery(isAuthed);
const { data: topFollowed = [] } = useFollowedTopFlavors();
const createReply = useCreateReply();
const ratings: FeedRating[] = feed?.ratings ?? [];
const totalPages = feed?.totalPages ?? 1;
const notifications = notificationsAll.slice(0, 5);
const loading = feedLoading;
const filteredFollowing = following.filter((user) => const filteredFollowing = following.filter((user) =>
user.username.toLowerCase().includes(followingSearch.toLowerCase()), user.username.toLowerCase().includes(followingSearch.toLowerCase()),
); );
@@ -145,52 +103,14 @@ const CommunityFeed: React.FC<CommunityFeedProps> = ({ adminMode }) => {
else navigate('/'); else navigate('/');
}; };
const fetchFeedData = useCallback( useEffect(() => {
async (pageNum: number) => { if (!isAuthed) navigate('/login');
setLoading(true); }, [isAuthed, navigate]);
try {
const [feedRes, followingRes, topFollowedRes, notifRes] = await Promise.all([
api.get(`ratings/feed/?page=${pageNum}`),
api.get('users/following_list/'),
api.get('flavors/followed_top/'),
api.get('notifications/'),
]);
const feedData = feedRes.data.results || (Array.isArray(feedRes.data) ? feedRes.data : []);
setRatings(feedData);
const count = feedRes.data.count || 0;
setTotalPages(Math.ceil(count / 10));
const followData = Array.isArray(followingRes.data)
? followingRes.data
: followingRes.data.results || [];
setFollowing(followData);
setTopFollowed(Array.isArray(topFollowedRes.data) ? topFollowedRes.data.slice(0, 5) : []);
const notifData = Array.isArray(notifRes.data)
? notifRes.data
: notifRes.data.results || [];
setNotifications(notifData.slice(0, 5));
} catch (err) {
const status = (err as { response?: { status?: number } }).response?.status;
if (status === 401) navigate('/login');
console.error('Failed to fetch feed data', err);
} finally {
setLoading(false);
}
},
[navigate],
);
useEffect(() => { useEffect(() => {
const token = localStorage.getItem('access'); const status = (feedError as { response?: { status?: number } } | null)?.response?.status;
if (!token) { if (status === 401) navigate('/login');
navigate('/login'); }, [feedError, navigate]);
return;
}
fetchFeedData(page);
}, [page, navigate, fetchFeedData]);
const handlePageChange = (_: React.ChangeEvent<unknown>, value: number) => { const handlePageChange = (_: React.ChangeEvent<unknown>, value: number) => {
setPage(value); setPage(value);
@@ -205,10 +125,7 @@ const CommunityFeed: React.FC<CommunityFeedProps> = ({ adminMode }) => {
const text = replyInputs[ratingId]; const text = replyInputs[ratingId];
if (!text) return; if (!text) return;
try { try {
const res = await api.post(`ratings/${ratingId}/reply/`, { text }); await createReply.mutateAsync({ ratingId, text });
setRatings((prev) =>
prev.map((r) => (r.id === ratingId ? { ...r, replies: [...r.replies, res.data] } : r)),
);
setReplyInputs((prev) => ({ ...prev, [ratingId]: '' })); setReplyInputs((prev) => ({ ...prev, [ratingId]: '' }));
} catch { } catch {
notify({ message: 'Failed to send reply', severity: 'error' }); notify({ message: 'Failed to send reply', severity: 'error' });
+17 -69
View File
@@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react'; import React, { useEffect, useMemo, useState } from 'react';
import { import {
Typography, Typography,
CardContent, CardContent,
@@ -32,7 +32,13 @@ import SearchIcon from '@mui/icons-material/Search';
import ColorThief from 'colorthief'; import ColorThief from 'colorthief';
import { useNavigate, Link } from 'react-router-dom'; import { useNavigate, Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import api from '../lib/api'; import { useDashboard } from '../api/queries/useDashboard';
import {
useDeleteRating,
useDeleteReply,
useCreateReply,
useUpdateRating,
} from '../api/mutations/useRatingMutations';
import { useTitle } from '../hooks/useTitle'; import { useTitle } from '../hooks/useTitle';
import RatingBadge from '../components/RatingBadge'; import RatingBadge from '../components/RatingBadge';
import StatusBadge from '../components/StatusBadge'; import StatusBadge from '../components/StatusBadge';
@@ -43,56 +49,17 @@ import { PageShell, GlassCard, GlassSurface, EmptyState } from '../components/ui
import { useToast } from '../hooks/useToast'; import { useToast } from '../hooks/useToast';
import { useConfirm } from '../hooks/useConfirm'; import { useConfirm } from '../hooks/useConfirm';
interface Reply {
id: number;
user: string;
text: string;
created_at: string;
}
interface RatedItem {
id: number;
flavor: number;
flavor_name: string;
flavor_image: string | null;
category_name: string;
category_slug: string;
score: number;
comment: string | null;
created_at: string;
replies: Reply[];
is_legacy?: boolean;
is_available?: boolean;
}
interface MissingFlavor {
id: number;
name: string;
image_url: string | null;
category_name: string;
category_slug: string;
average_rating: number;
followed_average_rating?: number;
is_legacy?: boolean;
is_available?: boolean;
}
interface DashboardData {
user: { username: string; avatar: string | null };
rated_count: number;
missing_count: number;
missing_flavors: MissingFlavor[];
my_ratings: RatedItem[];
}
const Dashboard: React.FC = () => { const Dashboard: React.FC = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
const theme = useTheme(); const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('lg')); const isMobile = useMediaQuery(theme.breakpoints.down('lg'));
useTitle(t('dashboard.title')); useTitle(t('dashboard.title'));
const [data, setData] = useState<DashboardData | null>(null); const { data, isLoading: loading } = useDashboard();
const [loading, setLoading] = useState(true); const updateRating = useUpdateRating();
const deleteRating = useDeleteRating();
const createReply = useCreateReply();
const deleteReply = useDeleteReply();
const [activeTab, setActiveTab] = useState(0); const [activeTab, setActiveTab] = useState(0);
const [palette, setPalette] = useState<string[]>([]); const [palette, setPalette] = useState<string[]>([]);
@@ -125,21 +92,6 @@ const Dashboard: React.FC = () => {
else navigate('/'); else navigate('/');
}; };
const fetchData = useCallback(async () => {
try {
const res = await api.get('users/dashboard/');
setData(res.data);
} catch (err) {
console.error(err);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchData();
}, [fetchData]);
useEffect(() => { useEffect(() => {
if (data?.user?.avatar) { if (data?.user?.avatar) {
const img = new Image(); const img = new Image();
@@ -177,9 +129,8 @@ const Dashboard: React.FC = () => {
const handleUpdateRating = async (ratingId: number) => { const handleUpdateRating = async (ratingId: number) => {
try { try {
await api.patch(`ratings/${ratingId}/`, { score: editScore, comment: editComment }); await updateRating.mutateAsync({ id: ratingId, score: editScore, comment: editComment });
setEditingRatingId(null); setEditingRatingId(null);
fetchData();
} catch { } catch {
notify({ message: 'Failed to update review', severity: 'error' }); notify({ message: 'Failed to update review', severity: 'error' });
} }
@@ -188,8 +139,7 @@ const Dashboard: React.FC = () => {
const handleDeleteRating = async (ratingId: number) => { const handleDeleteRating = async (ratingId: number) => {
if (!(await confirm({ message: 'Delete this review?', danger: true }))) return; if (!(await confirm({ message: 'Delete this review?', danger: true }))) return;
try { try {
await api.delete(`ratings/${ratingId}/`); await deleteRating.mutateAsync(ratingId);
fetchData();
} catch { } catch {
notify({ message: 'Failed to delete review', severity: 'error' }); notify({ message: 'Failed to delete review', severity: 'error' });
} }
@@ -199,10 +149,9 @@ const Dashboard: React.FC = () => {
const text = replyInputs[ratingId]; const text = replyInputs[ratingId];
if (!text) return; if (!text) return;
try { try {
await api.post(`ratings/${ratingId}/reply/`, { text }); await createReply.mutateAsync({ ratingId, text });
setReplyInputs({ ...replyInputs, [ratingId]: '' }); setReplyInputs({ ...replyInputs, [ratingId]: '' });
setExpandedReplies({ ...expandedReplies, [ratingId]: true }); setExpandedReplies({ ...expandedReplies, [ratingId]: true });
fetchData();
} catch { } catch {
notify({ message: 'Failed to send reply', severity: 'error' }); notify({ message: 'Failed to send reply', severity: 'error' });
} }
@@ -211,8 +160,7 @@ const Dashboard: React.FC = () => {
const handleDeleteReply = async (replyId: number) => { const handleDeleteReply = async (replyId: number) => {
if (!(await confirm({ message: 'Delete this reply?', danger: true }))) return; if (!(await confirm({ message: 'Delete this reply?', danger: true }))) return;
try { try {
await api.delete(`replies/${replyId}/`); await deleteReply.mutateAsync(replyId);
fetchData();
} catch { } catch {
notify({ message: 'Failed to delete reply', severity: 'error' }); notify({ message: 'Failed to delete reply', severity: 'error' });
} }
+8 -50
View File
@@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react'; import React, { useState } from 'react';
import { import {
Typography, Typography,
CardContent, CardContent,
@@ -12,78 +12,36 @@ import {
CircularProgress, CircularProgress,
} from '@mui/material'; } from '@mui/material';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import api from '../lib/api'; import { useCategories, useFlavorsList } from '../api/queries/useFlavorsList';
import { useCreateRating } from '../api/mutations/useRatingMutations';
import RatingBadge from '../components/RatingBadge'; import RatingBadge from '../components/RatingBadge';
import StatusBadge from '../components/StatusBadge'; import StatusBadge from '../components/StatusBadge';
import { PageShell, HeroBackdrop, GlassCard, SectionHeader } from '../components/ui'; import { PageShell, HeroBackdrop, GlassCard, SectionHeader } from '../components/ui';
import { useToast } from '../hooks/useToast'; import { useToast } from '../hooks/useToast';
interface Rating {
id: number;
user: string;
score: number;
comment: string;
created_at: string;
}
interface Flavor {
id: number;
name: string;
category_name: string;
description: string;
average_rating: number;
user_rating: number | null;
ratings: Rating[];
image_url: string | null;
is_available: boolean;
is_legacy?: boolean;
shop_url: string | null;
}
interface Category {
id: number;
name: string;
slug: string;
}
interface RatingInput { interface RatingInput {
score: number; score: number;
comment: string; comment: string;
} }
const FlavorList: React.FC = () => { const FlavorList: React.FC = () => {
const [categories, setCategories] = useState<Category[]>([]); const { data: categories = [], isLoading: catLoading } = useCategories();
const [flavors, setFlavors] = useState<Flavor[]>([]); const { data: flavors = [], isLoading: flavLoading } = useFlavorsList();
const [loading, setLoading] = useState(true); const createRating = useCreateRating();
const [ratingInput, setRatingInput] = useState<Record<number, RatingInput>>({}); const [ratingInput, setRatingInput] = useState<Record<number, RatingInput>>({});
const { notify } = useToast(); const { notify } = useToast();
useEffect(() => { const loading = catLoading || flavLoading;
const fetchData = async () => {
try {
const [catRes, flavRes] = await Promise.all([api.get('categories/'), api.get('flavors/')]);
setCategories(catRes.data);
setFlavors(flavRes.data);
} catch (err) {
console.error(err);
} finally {
setLoading(false);
}
};
fetchData();
}, []);
const handleRate = async (flavorId: number) => { const handleRate = async (flavorId: number) => {
const input = ratingInput[flavorId]; const input = ratingInput[flavorId];
if (!input) return; if (!input) return;
try { try {
await api.post('ratings/', { await createRating.mutateAsync({
flavor: flavorId, flavor: flavorId,
score: input.score, score: input.score,
comment: input.comment, comment: input.comment,
}); });
const res = await api.get('flavors/');
setFlavors(res.data);
notify({ message: 'Rating submitted!', severity: 'success' }); notify({ message: 'Rating submitted!', severity: 'success' });
} catch { } catch {
notify({ notify({
+24 -126
View File
@@ -16,7 +16,14 @@ import { useTranslation } from 'react-i18next';
import ChevronLeftIcon from '@mui/icons-material/ChevronLeft'; import ChevronLeftIcon from '@mui/icons-material/ChevronLeft';
import ChevronRightIcon from '@mui/icons-material/ChevronRight'; import ChevronRightIcon from '@mui/icons-material/ChevronRight';
import EmojiEventsIcon from '@mui/icons-material/EmojiEvents'; import EmojiEventsIcon from '@mui/icons-material/EmojiEvents';
import api from '../lib/api'; import { useCategories } from '../api/queries/useFlavorsList';
import {
useFlavorSearch,
useFollowingFeedTop,
useNewestFlavors,
useRecentReviews,
useTopFlavors,
} from '../api/queries/useMainPageQueries';
import { useTitle } from '../hooks/useTitle'; import { useTitle } from '../hooks/useTitle';
import RatingBadge from '../components/RatingBadge'; import RatingBadge from '../components/RatingBadge';
import StatusBadge from '../components/StatusBadge'; import StatusBadge from '../components/StatusBadge';
@@ -29,50 +36,6 @@ import {
FlavorCard, FlavorCard,
} from '../components/ui'; } from '../components/ui';
interface Category {
id: number;
name: string;
slug: string;
}
interface Flavor {
id: number;
name: string;
category_name: string;
description: string;
average_rating: number;
image_url: string | null;
is_available: boolean;
is_legacy: boolean;
ratings: FeedRating[];
}
interface FeedRating {
id: number;
user: string;
user_avatar: string | null;
flavor: number;
flavor_name: string;
flavor_image: string | null;
flavor_category?: string;
score: number;
comment: string;
created_at: string;
replies?: unknown[];
}
interface Review {
id: number;
user: string;
user_avatar: string | null;
flavor_name: string;
flavor_image: string | null;
score: number;
comment: string;
created_at: string;
flavor: number;
}
interface MainPageProps { interface MainPageProps {
adminMode?: boolean; adminMode?: boolean;
} }
@@ -80,99 +43,34 @@ interface MainPageProps {
const MainPage: React.FC<MainPageProps> = ({ adminMode }) => { const MainPage: React.FC<MainPageProps> = ({ adminMode }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
const [topFlavors, setTopFlavors] = useState<Flavor[]>([]);
const [newestFlavors, setNewestFlavors] = useState<Flavor[]>([]);
const [recentReviews, setRecentReviews] = useState<Review[]>([]);
const [allFlavors, setAllFlavors] = useState<Flavor[]>([]);
const [feedRatings, setFeedRatings] = useState<FeedRating[]>([]);
const [categories, setCategories] = useState<Category[]>([]);
const [selectedCategorySlug, setSelectedCategorySlug] = useState<string>(''); const [selectedCategorySlug, setSelectedCategorySlug] = useState<string>('');
const [loading, setLoading] = useState(true);
const [categoryLoading, setCategoryLoading] = useState(false);
const [activeIndex, setActiveIndex] = useState(0); const [activeIndex, setActiveIndex] = useState(0);
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null); const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const isFirstRun = useRef(true);
const location = useLocation(); const location = useLocation();
const query = new URLSearchParams(location.search).get('q') || ''; const query = new URLSearchParams(location.search).get('q') || '';
const isLoggedIn = !!localStorage.getItem('access'); const isLoggedIn = !!localStorage.getItem('access');
useEffect(() => { const { data: searchResults = [], isLoading: searchLoading } = useFlavorSearch(query);
const fetchData = async () => { const {
setLoading(true); data: topFlavors = [],
try { isLoading: topLoading,
if (query) { isFetching: topFetching,
const res = await api.get(`flavors/?search=${encodeURIComponent(query)}`); } = useTopFlavors(selectedCategorySlug || null);
setAllFlavors(Array.isArray(res.data) ? res.data : res.data.results || []); const { data: newestFlavors = [], isLoading: newestLoading } = useNewestFlavors();
} else { const { data: recentReviews = [], isLoading: recentLoading } = useRecentReviews();
const requests = [ const { data: categories = [], isLoading: catLoading } = useCategories();
api.get('flavors/top/'), const { data: feedRatings = [] } = useFollowingFeedTop(isLoggedIn && !query);
api.get('ratings/recent/'),
api.get('flavors/newest/'),
api.get('categories/'),
];
if (isLoggedIn) { const allFlavors = searchResults;
requests.push(api.get('ratings/feed/')); const loading = query
} ? searchLoading
: topLoading || newestLoading || recentLoading || catLoading;
const results = await Promise.all(requests); const categoryLoading = topFetching && !topLoading;
setTopFlavors(
Array.isArray(results[0].data) ? results[0].data : results[0].data.results || [],
);
setRecentReviews(
Array.isArray(results[1].data) ? results[1].data : results[1].data.results || [],
);
setNewestFlavors(
Array.isArray(results[2].data) ? results[2].data : results[2].data.results || [],
);
setCategories(
Array.isArray(results[3].data) ? results[3].data : results[3].data.results || [],
);
if (isLoggedIn && results[4]) {
const feedData = Array.isArray(results[4].data)
? results[4].data
: results[4].data.results || [];
setFeedRatings(feedData.slice(0, 6));
}
}
} catch (err) {
console.error(err);
} finally {
setLoading(false);
}
};
fetchData();
}, [query, isLoggedIn]);
useEffect(() => { useEffect(() => {
if (query) return;
const fetchTopFlavors = async () => {
setCategoryLoading(true);
try {
const url = selectedCategorySlug
? `flavors/top/?category=${selectedCategorySlug}`
: 'flavors/top/';
const res = await api.get(url);
setTopFlavors(Array.isArray(res.data) ? res.data : res.data.results || []);
setActiveIndex(0); setActiveIndex(0);
} catch (err) { }, [selectedCategorySlug]);
console.error(err);
} finally {
setCategoryLoading(false);
}
};
if (isFirstRun.current) {
isFirstRun.current = false;
return;
}
fetchTopFlavors();
}, [selectedCategorySlug, query]);
const handleTabChange = (_event: React.SyntheticEvent, newValue: string) => { const handleTabChange = (_event: React.SyntheticEvent, newValue: string) => {
setSelectedCategorySlug(newValue); setSelectedCategorySlug(newValue);
+33 -84
View File
@@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { useParams, useNavigate, Link } from 'react-router-dom'; import { useParams, useNavigate, Link } from 'react-router-dom';
import { import {
Typography, Typography,
@@ -26,7 +26,17 @@ import VerifiedIcon from '@mui/icons-material/Verified';
import PersonAddIcon from '@mui/icons-material/PersonAdd'; import PersonAddIcon from '@mui/icons-material/PersonAdd';
import PersonRemoveIcon from '@mui/icons-material/PersonRemove'; import PersonRemoveIcon from '@mui/icons-material/PersonRemove';
import ColorThief from 'colorthief'; import ColorThief from 'colorthief';
import api from '../lib/api'; import {
usePublicProfile,
type MiniUser,
type PublicProfileRating,
} from '../api/queries/usePublicProfile';
import { useCurrentUserLite } from '../api/queries/useCurrentUserLite';
import {
useAddProfileComment,
useDeleteProfileComment,
useFollowToggle,
} from '../api/mutations/useSocialMutations';
import { useTitle } from '../hooks/useTitle'; import { useTitle } from '../hooks/useTitle';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import RatingBadge from '../components/RatingBadge'; import RatingBadge from '../components/RatingBadge';
@@ -37,46 +47,6 @@ import { PageShell, GlassCard, GlassSurface, FormCard, EmptyState } from '../com
import { useToast } from '../hooks/useToast'; import { useToast } from '../hooks/useToast';
import { useConfirm } from '../hooks/useConfirm'; import { useConfirm } from '../hooks/useConfirm';
interface Rating {
id: number;
flavor: number;
flavor_name: string;
flavor_image: string | null;
category_name: string;
score: number;
comment: string;
created_at: string;
}
interface ProfileComment {
id: number;
author_username: string;
author_avatar: string | null;
text: string;
created_at: string;
}
interface MiniUser {
id: number;
username: string;
avatar: string | null;
is_following?: boolean;
}
interface ProfileData {
id: number;
username: string;
theme: string;
avatar: string | null;
following_count: number;
followers_count: number;
is_following: boolean;
ratings: Rating[];
comments: ProfileComment[];
followers: MiniUser[];
following: MiniUser[];
}
interface PublicProfileProps { interface PublicProfileProps {
adminMode?: boolean; adminMode?: boolean;
} }
@@ -86,38 +56,18 @@ const PublicProfile: React.FC<PublicProfileProps> = ({ adminMode }) => {
const theme = useTheme(); const theme = useTheme();
const { username } = useParams<{ username: string }>(); const { username } = useParams<{ username: string }>();
const navigate = useNavigate(); const navigate = useNavigate();
const [data, setData] = useState<ProfileData | null>(null); const { data, isLoading: loading } = usePublicProfile(username);
const [loading, setLoading] = useState(true); const { data: currentUser } = useCurrentUserLite();
const followToggle = useFollowToggle();
const addComment = useAddProfileComment();
const deleteCommentMutation = useDeleteProfileComment();
const [activeTab, setActiveTab] = useState(0); const [activeTab, setActiveTab] = useState(0);
const [categoryTab, setCategoryTab] = useState(0); const [categoryTab, setCategoryTab] = useState(0);
const [currentUser, setCurrentUser] = useState<{
username: string;
is_superuser: boolean;
} | null>(null);
const [palette, setPalette] = useState<string[]>([]); const [palette, setPalette] = useState<string[]>([]);
const [newComment, setNewComment] = useState(''); const [newComment, setNewComment] = useState('');
const { notify } = useToast(); const { notify } = useToast();
const { confirm } = useConfirm(); const { confirm } = useConfirm();
const fetchProfile = useCallback(async () => {
try {
const [profileRes, meRes] = await Promise.all([
api.get(`users/profile/${username}/`),
localStorage.getItem('access') ? api.get('users/me/') : Promise.resolve({ data: null }),
]);
setData(profileRes.data);
if (meRes.data) setCurrentUser(meRes.data);
} catch (err) {
console.error(err);
} finally {
setLoading(false);
}
}, [username]);
useEffect(() => {
fetchProfile();
}, [fetchProfile]);
useEffect(() => { useEffect(() => {
if (data?.avatar) { if (data?.avatar) {
const img = new Image(); const img = new Image();
@@ -156,13 +106,10 @@ const PublicProfile: React.FC<PublicProfileProps> = ({ adminMode }) => {
const handleFollowToggle = async () => { const handleFollowToggle = async () => {
if (!data) return; if (!data) return;
try { try {
if (data.is_following) { await followToggle.mutateAsync({
await api.post(`users/${data.id}/unfollow/`); userId: data.id,
setData({ ...data, is_following: false, followers_count: data.followers_count - 1 }); currentlyFollowing: data.is_following,
} else { });
await api.post(`users/${data.id}/follow/`);
setData({ ...data, is_following: true, followers_count: data.followers_count + 1 });
}
} catch { } catch {
notify({ message: 'Failed to update follow status. Please login.', severity: 'error' }); notify({ message: 'Failed to update follow status. Please login.', severity: 'error' });
} }
@@ -170,9 +117,10 @@ const PublicProfile: React.FC<PublicProfileProps> = ({ adminMode }) => {
const handleMiniFollowToggle = async (user: MiniUser) => { const handleMiniFollowToggle = async (user: MiniUser) => {
try { try {
if (user.is_following) await api.post(`users/${user.id}/unfollow/`); await followToggle.mutateAsync({
else await api.post(`users/${user.id}/follow/`); userId: user.id,
fetchProfile(); currentlyFollowing: !!user.is_following,
});
} catch { } catch {
notify({ message: 'Failed', severity: 'error' }); notify({ message: 'Failed', severity: 'error' });
} }
@@ -182,9 +130,8 @@ const PublicProfile: React.FC<PublicProfileProps> = ({ adminMode }) => {
e.preventDefault(); e.preventDefault();
if (!newComment.trim() || !data) return; if (!newComment.trim() || !data) return;
try { try {
await api.post(`users/${data.id}/add_comment/`, { text: newComment }); await addComment.mutateAsync({ userId: data.id, text: newComment });
setNewComment(''); setNewComment('');
fetchProfile();
} catch { } catch {
notify({ message: 'Failed to add comment', severity: 'error' }); notify({ message: 'Failed to add comment', severity: 'error' });
} }
@@ -194,8 +141,7 @@ const PublicProfile: React.FC<PublicProfileProps> = ({ adminMode }) => {
if (!data) return; if (!data) return;
if (!(await confirm({ message: 'Delete this comment?', danger: true }))) return; if (!(await confirm({ message: 'Delete this comment?', danger: true }))) return;
try { try {
await api.delete(`users/${data.id}/delete_comment/${commentId}/`); await deleteCommentMutation.mutateAsync({ userId: data.id, commentId });
fetchProfile();
} catch { } catch {
notify({ message: 'Failed to delete comment', severity: 'error' }); notify({ message: 'Failed to delete comment', severity: 'error' });
} }
@@ -218,15 +164,18 @@ const PublicProfile: React.FC<PublicProfileProps> = ({ adminMode }) => {
</PageShell> </PageShell>
); );
const categoryGroups = data.ratings.reduce<Record<string, Rating[]>>((acc, rating) => { const categoryGroups = data.ratings.reduce<Record<string, PublicProfileRating[]>>(
(acc, rating) => {
const cat = rating.category_name || 'Other'; const cat = rating.category_name || 'Other';
if (!acc[cat]) acc[cat] = []; if (!acc[cat]) acc[cat] = [];
acc[cat].push(rating); acc[cat].push(rating);
return acc; return acc;
}, {}); },
{},
);
const ratingCategories = ['All', ...Object.keys(categoryGroups)]; const ratingCategories = ['All', ...Object.keys(categoryGroups)];
const currentRatings: Rating[] = const currentRatings: PublicProfileRating[] =
categoryTab === 0 ? data.ratings : categoryGroups[ratingCategories[categoryTab]]; categoryTab === 0 ? data.ratings : categoryGroups[ratingCategories[categoryTab]];
const tiers = [ const tiers = [
+37 -47
View File
@@ -1,4 +1,15 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { useMe } from '../api/queries/useMe';
import { useBannersList } from '../api/queries/useBanners';
import {
useChangePassword,
useConfirmAccountDeletion,
useConfirmEmail,
useRequestAccountDeletion,
useUpdateAvatar,
useUpdatePreferences,
useUpdateProfile,
} from '../api/mutations/useSettingsMutations';
import { import {
Typography, Typography,
Box, Box,
@@ -16,7 +27,6 @@ import {
} from '@mui/material'; } from '@mui/material';
import ArrowBackIcon from '@mui/icons-material/ArrowBack'; import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import api from '../lib/api';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useTitle } from '../hooks/useTitle'; import { useTitle } from '../hooks/useTitle';
import type { CatppuccinTheme } from '../theme'; import type { CatppuccinTheme } from '../theme';
@@ -28,11 +38,6 @@ interface SettingsProps {
onThemeChange: (newTheme: CatppuccinTheme) => void; onThemeChange: (newTheme: CatppuccinTheme) => void;
} }
interface Banner {
id: number;
name: string;
}
const readErr = (err: unknown): string | undefined => const readErr = (err: unknown): string | undefined =>
(err as { response?: { data?: { error?: string } } }).response?.data?.error; (err as { response?: { data?: { error?: string } } }).response?.data?.error;
@@ -41,6 +46,16 @@ const Settings: React.FC<SettingsProps> = ({ themeName, onThemeChange }) => {
const navigate = useNavigate(); const navigate = useNavigate();
useTitle(t('nav.settings')); useTitle(t('nav.settings'));
const { confirm } = useConfirm(); const { confirm } = useConfirm();
const { data: me } = useMe();
const { data: banners = [] } = useBannersList();
const updateAvatarMutation = useUpdateAvatar();
const updatePrefs = useUpdatePreferences();
const updateProfileMutation = useUpdateProfile();
const confirmEmailMutation = useConfirmEmail();
const changePasswordMutation = useChangePassword();
const requestDeletion = useRequestAccountDeletion();
const confirmDeletion = useConfirmAccountDeletion();
const [username, setUsername] = useState(''); const [username, setUsername] = useState('');
const [email, setEmail] = useState(''); const [email, setEmail] = useState('');
const [currentEmail, setCurrentEmail] = useState(''); const [currentEmail, setCurrentEmail] = useState('');
@@ -53,7 +68,6 @@ const Settings: React.FC<SettingsProps> = ({ themeName, onThemeChange }) => {
const [deletionCode, setDeletionCode] = useState(''); const [deletionCode, setDeletionCode] = useState('');
const [isDeleting, setIsDeleting] = useState(false); const [isDeleting, setIsDeleting] = useState(false);
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null); const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
const [banners, setBanners] = useState<Banner[]>([]);
const [selectedBannerId, setSelectedBannerId] = useState<number | string>(''); const [selectedBannerId, setSelectedBannerId] = useState<number | string>('');
const handleGoBack = () => { const handleGoBack = () => {
@@ -62,33 +76,14 @@ const Settings: React.FC<SettingsProps> = ({ themeName, onThemeChange }) => {
}; };
useEffect(() => { useEffect(() => {
const fetchUser = async () => { if (!me) return;
try { setUsername(me.username);
const [userRes, bannersRes] = await Promise.all([ setEmail(me.email);
api.get('users/me/'), setCurrentEmail(me.email);
api.get('banners/'), setAvatar(me.avatar);
]); setSelectedBannerId(me.selected_banner || '');
if (me.language) setLanguage(me.language);
setUsername(userRes.data.username); }, [me]);
setEmail(userRes.data.email);
setCurrentEmail(userRes.data.email);
setAvatar(userRes.data.avatar);
setSelectedBannerId(userRes.data.selected_banner || '');
if (userRes.data.language) {
setLanguage(userRes.data.language);
}
const bannersData: Banner[] = Array.isArray(bannersRes.data)
? bannersRes.data
: bannersRes.data.results || [];
setBanners(bannersData);
} catch (err) {
console.error(err);
}
};
fetchUser();
}, []);
const handleAvatarUpload = async (e: React.ChangeEvent<HTMLInputElement>) => { const handleAvatarUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]; const file = e.target.files?.[0];
@@ -99,13 +94,8 @@ const Settings: React.FC<SettingsProps> = ({ themeName, onThemeChange }) => {
return; return;
} }
const formData = new FormData();
formData.append('avatar', file);
try { try {
const res = await api.post('users/update_avatar/', formData, { const res = await updateAvatarMutation.mutateAsync(file);
headers: { 'Content-Type': 'multipart/form-data' },
});
setAvatar(res.data.avatar); setAvatar(res.data.avatar);
setMessage({ type: 'success', text: t('settings.avatarSuccess') }); setMessage({ type: 'success', text: t('settings.avatarSuccess') });
} catch (err) { } catch (err) {
@@ -117,7 +107,7 @@ const Settings: React.FC<SettingsProps> = ({ themeName, onThemeChange }) => {
setLanguage(newLang); setLanguage(newLang);
i18n.changeLanguage(newLang); i18n.changeLanguage(newLang);
try { try {
await api.patch('users/update_preferences/', { language: newLang }); await updatePrefs.mutateAsync({ language: newLang });
} catch { } catch {
console.error('Failed to update language on server'); console.error('Failed to update language on server');
} }
@@ -126,7 +116,7 @@ const Settings: React.FC<SettingsProps> = ({ themeName, onThemeChange }) => {
const handleUpdateProfile = async (e: React.FormEvent) => { const handleUpdateProfile = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
try { try {
const res = await api.patch('users/update_profile/', { username, email }); const res = await updateProfileMutation.mutateAsync({ username, email });
if (res.data.message) { if (res.data.message) {
setPendingConfirmation(true); setPendingConfirmation(true);
setMessage({ type: 'success', text: `${t('settings.updateSuccess')} ${res.data.message}` }); setMessage({ type: 'success', text: `${t('settings.updateSuccess')} ${res.data.message}` });
@@ -142,7 +132,7 @@ const Settings: React.FC<SettingsProps> = ({ themeName, onThemeChange }) => {
const handleConfirmEmail = async (e: React.FormEvent) => { const handleConfirmEmail = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
try { try {
const res = await api.post('users/confirm_email/', { code: confirmationCode }); const res = await confirmEmailMutation.mutateAsync(confirmationCode);
setMessage({ type: 'success', text: t('settings.updateSuccess') }); setMessage({ type: 'success', text: t('settings.updateSuccess') });
setCurrentEmail(res.data.email); setCurrentEmail(res.data.email);
setPendingConfirmation(false); setPendingConfirmation(false);
@@ -155,7 +145,7 @@ const Settings: React.FC<SettingsProps> = ({ themeName, onThemeChange }) => {
const handleChangePassword = async (e: React.FormEvent) => { const handleChangePassword = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
try { try {
await api.post('users/change_password/', { await changePasswordMutation.mutateAsync({
old_password: oldPassword, old_password: oldPassword,
new_password: newPassword, new_password: newPassword,
}); });
@@ -169,7 +159,7 @@ const Settings: React.FC<SettingsProps> = ({ themeName, onThemeChange }) => {
const handleRequestDeletion = async () => { const handleRequestDeletion = async () => {
try { try {
await api.post('users/request_account_deletion/'); await requestDeletion.mutateAsync();
setIsDeleting(true); setIsDeleting(true);
setMessage({ type: 'success', text: t('settings.codeSent') }); setMessage({ type: 'success', text: t('settings.codeSent') });
} catch { } catch {
@@ -187,7 +177,7 @@ const Settings: React.FC<SettingsProps> = ({ themeName, onThemeChange }) => {
}); });
if (!ok) return; if (!ok) return;
try { try {
await api.post('users/confirm_account_deletion/', { code: deletionCode }); await confirmDeletion.mutateAsync(deletionCode);
localStorage.removeItem('access'); localStorage.removeItem('access');
localStorage.removeItem('refresh'); localStorage.removeItem('refresh');
window.location.href = '/'; window.location.href = '/';
@@ -281,7 +271,7 @@ const Settings: React.FC<SettingsProps> = ({ themeName, onThemeChange }) => {
const newId = e.target.value; const newId = e.target.value;
setSelectedBannerId(newId); setSelectedBannerId(newId);
try { try {
await api.patch('users/update_preferences/', { selected_banner: newId }); await updatePrefs.mutateAsync({ selected_banner: newId });
} catch { } catch {
console.error('Failed to update banner preference'); console.error('Failed to update banner preference');
} }
+36 -80
View File
@@ -1,4 +1,4 @@
import React, { useState, useEffect, useCallback } from 'react'; import React, { useState, useMemo } from 'react';
import { import {
Typography, Typography,
Box, Box,
@@ -21,48 +21,21 @@ import DeleteIcon from '@mui/icons-material/Delete';
import ArrowBackIcon from '@mui/icons-material/ArrowBack'; import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import api from '../lib/api'; import { useTickets } from '../api/queries/useTickets';
import { useMe } from '../api/queries/useMe';
import { useNotificationsQuery } from '../api/queries/useNotifications';
import {
useAddTicketMessage,
useCreateTicket,
useDeleteTicket,
useUpdateTicketStatus,
} from '../api/mutations/useTicketMutations';
import { useTitle } from '../hooks/useTitle'; import { useTitle } from '../hooks/useTitle';
import { formatDate } from '../utils/date'; import { formatDate } from '../utils/date';
import { PageShell, SectionHeader, GlassCard, FormCard, EmptyState } from '../components/ui'; import { PageShell, SectionHeader, GlassCard, FormCard, EmptyState } from '../components/ui';
import { useToast } from '../hooks/useToast'; import { useToast } from '../hooks/useToast';
import { useConfirm } from '../hooks/useConfirm'; import { useConfirm } from '../hooks/useConfirm';
interface Message {
id: number;
username: string;
text: string;
created_at: string;
is_admin: boolean;
}
type TicketStatus = 'open' | 'in_progress' | 'resolved' | 'closed';
interface Ticket {
id: number;
user: number;
username: string;
user_email: string;
user_avatar: string | null;
subject: string;
description: string;
status: TicketStatus;
created_at: string;
messages: Message[];
}
interface CurrentUser {
id: number;
username: string;
is_superuser: boolean;
}
interface NotificationDTO {
is_read: boolean;
notification_type: string;
ticket: number | null;
}
type ChipStatusColor = 'error' | 'warning' | 'success' | 'default'; type ChipStatusColor = 'error' | 'warning' | 'success' | 'default';
type ButtonStatusColor = 'error' | 'warning' | 'success' | 'primary'; type ButtonStatusColor = 'error' | 'warning' | 'success' | 'primary';
@@ -71,47 +44,36 @@ const Support: React.FC = () => {
const theme = useTheme(); const theme = useTheme();
const navigate = useNavigate(); const navigate = useNavigate();
useTitle(t('support.title')); useTitle(t('support.title'));
const [tickets, setTickets] = useState<Ticket[]>([]); const { data: tickets = [], isLoading: loading } = useTickets();
const [loading, setLoading] = useState(true); const { data: currentUser = null } = useMe();
const [showCreate, setShowCreate] = useState(false); const { data: notifications = [] } = useNotificationsQuery(true);
const createTicketMutation = useCreateTicket();
const addMessage = useAddTicketMessage();
const updateStatusMutation = useUpdateTicketStatus();
const deleteTicketMutation = useDeleteTicket();
const [showCreate, setShowCreate] = useState(false);
const [subject, setSubject] = useState(''); const [subject, setSubject] = useState('');
const [description, setDescription] = useState(''); const [description, setDescription] = useState('');
const [replyText, setReplyText] = useState<Record<number, string>>({}); const [replyText, setReplyText] = useState<Record<number, string>>({});
const [expandedId, setExpandedId] = useState<number | null>(null); const [expandedId, setExpandedId] = useState<number | null>(null);
const [readTicketIds, setReadTicketIds] = useState<Set<number>>(new Set());
const { notify } = useToast(); const { notify } = useToast();
const { confirm } = useConfirm(); const { confirm } = useConfirm();
const [currentUser, setCurrentUser] = useState<CurrentUser | null>(null);
const [unreadTicketIds, setUnreadTicketIds] = useState<Set<number>>(new Set());
const fetchTickets = useCallback(async () => { const unreadTicketIds = useMemo(() => {
try { const ids = new Set<number>();
const [ticketRes, userRes, notifRes] = await Promise.all([ notifications.forEach((n) => {
api.get('tickets/'), // Notification carries .reply or .rating in its current shape; tickets
api.get('users/me/'), // arrive as ticket_new / ticket_reply with the ticket id encoded in
api.get('notifications/'), // .reply (legacy field name re-purposed by the serializer).
]); const ticketId = (n as unknown as { ticket?: number | null }).ticket;
setTickets(ticketRes.data.results || ticketRes.data); if (!n.is_read && n.notification_type.startsWith('ticket') && ticketId != null) {
setCurrentUser(userRes.data); if (!readTicketIds.has(ticketId)) ids.add(ticketId);
const notifs: NotificationDTO[] = notifRes.data.results || notifRes.data;
const unreadIds = new Set<number>(
notifs
.filter((n) => !n.is_read && n.notification_type.startsWith('ticket') && n.ticket != null)
.map((n) => n.ticket as number),
);
setUnreadTicketIds(unreadIds);
} catch (err) {
console.error(err);
} finally {
setLoading(false);
} }
}, []); });
return ids;
useEffect(() => { }, [notifications, readTicketIds]);
fetchTickets();
}, [fetchTickets]);
const handleExpand = (ticketId: number) => { const handleExpand = (ticketId: number) => {
if (expandedId === ticketId) { if (expandedId === ticketId) {
@@ -120,20 +82,17 @@ const Support: React.FC = () => {
} }
setExpandedId(ticketId); setExpandedId(ticketId);
if (unreadTicketIds.has(ticketId)) { if (unreadTicketIds.has(ticketId)) {
const newUnread = new Set(unreadTicketIds); setReadTicketIds((prev) => new Set(prev).add(ticketId));
newUnread.delete(ticketId);
setUnreadTicketIds(newUnread);
} }
}; };
const handleCreate = async (e: React.FormEvent) => { const handleCreate = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
try { try {
await api.post('tickets/', { subject, description }); await createTicketMutation.mutateAsync({ subject, description });
setSubject(''); setSubject('');
setDescription(''); setDescription('');
setShowCreate(false); setShowCreate(false);
fetchTickets();
} catch { } catch {
notify({ message: 'Failed to create ticket', severity: 'error' }); notify({ message: 'Failed to create ticket', severity: 'error' });
} }
@@ -143,9 +102,8 @@ const Support: React.FC = () => {
const text = replyText[ticketId]; const text = replyText[ticketId];
if (!text) return; if (!text) return;
try { try {
await api.post(`tickets/${ticketId}/add_message/`, { text }); await addMessage.mutateAsync({ ticketId, text });
setReplyText({ ...replyText, [ticketId]: '' }); setReplyText({ ...replyText, [ticketId]: '' });
fetchTickets();
} catch { } catch {
notify({ message: 'Failed to send message', severity: 'error' }); notify({ message: 'Failed to send message', severity: 'error' });
} }
@@ -153,8 +111,7 @@ const Support: React.FC = () => {
const handleUpdateStatus = async (ticketId: number, status: string) => { const handleUpdateStatus = async (ticketId: number, status: string) => {
try { try {
await api.post(`tickets/${ticketId}/update_status/`, { status }); await updateStatusMutation.mutateAsync({ ticketId, status });
fetchTickets();
} catch { } catch {
notify({ message: 'Failed to update status', severity: 'error' }); notify({ message: 'Failed to update status', severity: 'error' });
} }
@@ -163,8 +120,7 @@ const Support: React.FC = () => {
const handleDeleteTicket = async (ticketId: number) => { const handleDeleteTicket = async (ticketId: number) => {
if (!(await confirm({ message: 'Delete this ticket?', danger: true }))) return; if (!(await confirm({ message: 'Delete this ticket?', danger: true }))) return;
try { try {
await api.delete(`tickets/${ticketId}/`); await deleteTicketMutation.mutateAsync(ticketId);
fetchTickets();
} catch { } catch {
notify({ message: 'Failed to delete ticket', severity: 'error' }); notify({ message: 'Failed to delete ticket', severity: 'error' });
} }
@@ -1,10 +1,10 @@
import React, { useEffect, useState } from 'react'; import React from 'react';
import { useParams, useNavigate } from 'react-router-dom'; import { useParams, useNavigate } from 'react-router-dom';
import { Box, CircularProgress, Button } from '@mui/material'; import { Box, CircularProgress, Button } from '@mui/material';
import ArrowBackIcon from '@mui/icons-material/ArrowBack'; import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import api from '../../lib/api';
import { useTitle } from '../../hooks/useTitle'; import { useTitle } from '../../hooks/useTitle';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useCategoryFlavors } from '../../api/queries/useCategoryFlavors';
import { import {
PageShell, PageShell,
HeroBackdrop, HeroBackdrop,
@@ -13,35 +13,12 @@ import {
EmptyState, EmptyState,
} from '../../components/ui'; } from '../../components/ui';
interface Rating {
id: number;
user: string;
score: number;
comment: string;
created_at: string;
}
interface Flavor {
id: number;
name: string;
category_name: string;
description: string;
average_rating: number;
user_rating: number | null;
ratings: Rating[];
image_url: string | null;
is_available: boolean;
is_legacy: boolean;
shop_url: string | null;
}
const CategoryFlavors: React.FC = () => { const CategoryFlavors: React.FC = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const { slug } = useParams<{ slug: string }>(); const { slug } = useParams<{ slug: string }>();
const navigate = useNavigate(); const navigate = useNavigate();
const [flavors, setFlavors] = useState<Flavor[]>([]); const { data: flavors = [], isLoading: loading } = useCategoryFlavors(slug);
const [categoryName, setCategoryName] = useState(''); const categoryName = flavors[0]?.category_name || '';
const [loading, setLoading] = useState(true);
const handleGoBack = () => { const handleGoBack = () => {
if (window.history.length > 1) { if (window.history.length > 1) {
@@ -51,25 +28,6 @@ const CategoryFlavors: React.FC = () => {
} }
}; };
useEffect(() => {
const fetchFlavors = async () => {
setLoading(true);
try {
const res = await api.get(`flavors/?category__slug=${slug}`);
const data: Flavor[] = Array.isArray(res.data) ? res.data : res.data.results || [];
setFlavors(data);
if (data.length > 0) {
setCategoryName(data[0].category_name);
}
} catch (err) {
console.error(err);
} finally {
setLoading(false);
}
};
fetchFlavors();
}, [slug]);
useTitle(categoryName || 'Flavors'); useTitle(categoryName || 'Flavors');
if (loading) if (loading)
+44 -86
View File
@@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { useParams, Link, useNavigate } from 'react-router-dom'; import { useParams, Link, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { import {
@@ -20,7 +20,17 @@ import {
Switch, Switch,
Divider, Divider,
} from '@mui/material'; } from '@mui/material';
import api from '../../lib/api'; import { useFlavorDetail, type FlavorDetailRating } from '../../api/queries/useFlavorDetail';
import { useCurrentUserLite } from '../../api/queries/useCurrentUserLite';
import { useUpdateFlavor } from '../../api/mutations/useFlavorMutations';
import {
useCreateRating,
useDeleteRating,
useCreateReply,
useDeleteReply,
useUpdateRating,
} from '../../api/mutations/useRatingMutations';
import { useUpdateReply } from '../../api/mutations/useReplyMutations';
import { useTitle } from '../../hooks/useTitle'; import { useTitle } from '../../hooks/useTitle';
import ArrowBackIcon from '@mui/icons-material/ArrowBack'; import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import ShoppingCartIcon from '@mui/icons-material/ShoppingCart'; import ShoppingCartIcon from '@mui/icons-material/ShoppingCart';
@@ -38,38 +48,6 @@ import { PageShell, HeroBackdrop, GlassCard, SectionHeader, EmptyState } from '.
import { useToast } from '../../hooks/useToast'; import { useToast } from '../../hooks/useToast';
import { useConfirm } from '../../hooks/useConfirm'; import { useConfirm } from '../../hooks/useConfirm';
interface Reply {
id: number;
user: string;
text: string;
created_at: string;
}
interface Rating {
id: number;
user: string;
user_avatar: string | null;
score: number;
comment: string;
created_at: string;
replies: Reply[];
}
interface Flavor {
id: number;
name: string;
category_name: string;
category_slug: string;
description: string;
average_rating: number;
user_rating: number | null;
ratings: Rating[];
image_url: string | null;
is_available: boolean;
is_legacy: boolean;
shop_url: string | null;
}
interface FlavorDetailProps { interface FlavorDetailProps {
adminMode?: boolean; adminMode?: boolean;
} }
@@ -81,11 +59,18 @@ const FlavorDetail: React.FC<FlavorDetailProps> = ({ adminMode }) => {
const navigate = useNavigate(); const navigate = useNavigate();
const { notify } = useToast(); const { notify } = useToast();
const { confirm } = useConfirm(); const { confirm } = useConfirm();
const [flavor, setFlavor] = useState<Flavor | null>(null); const { data: flavor, isLoading: loading } = useFlavorDetail(id);
const [loading, setLoading] = useState(true); const { data: meData } = useCurrentUserLite();
const currentUser = meData?.username ?? null;
const updateFlavor = useUpdateFlavor();
const createRating = useCreateRating();
const deleteRatingMutation = useDeleteRating();
const updateRating = useUpdateRating();
const createReply = useCreateReply();
const updateReply = useUpdateReply();
const deleteReplyMutation = useDeleteReply();
const [replyInputs, setReplyInputs] = useState<{ [key: number]: string }>({}); const [replyInputs, setReplyInputs] = useState<{ [key: number]: string }>({});
const [expandedReplies, setExpandedReplies] = useState<{ [key: number]: boolean }>({}); const [expandedReplies, setExpandedReplies] = useState<{ [key: number]: boolean }>({});
const [currentUser, setCurrentUser] = useState<string | null>(null);
// Product Edit state (Admin) // Product Edit state (Admin)
const [isAdminEditing, setIsAdminEditing] = useState(false); const [isAdminEditing, setIsAdminEditing] = useState(false);
@@ -111,44 +96,25 @@ const FlavorDetail: React.FC<FlavorDetailProps> = ({ adminMode }) => {
const [editingReplyId, setEditingReplyId] = useState<number | null>(null); const [editingReplyId, setEditingReplyId] = useState<number | null>(null);
const [editReplyText, setEditReplyText] = useState(''); const [editReplyText, setEditReplyText] = useState('');
const fetchFlavor = useCallback(async () => {
try {
const res = await api.get(`flavors/${id}/`);
setFlavor(res.data);
setEditFlavorData({
name: res.data.name,
description: res.data.description,
shop_url: res.data.shop_url || '',
is_available: res.data.is_available,
is_legacy: res.data.is_legacy,
});
} catch (err) {
console.error(err);
} finally {
setLoading(false);
}
}, [id]);
useEffect(() => { useEffect(() => {
const getUser = async () => { if (flavor) {
try { setEditFlavorData({
const res = await api.get('users/me/'); name: flavor.name,
setCurrentUser(res.data.username); description: flavor.description,
} catch { shop_url: flavor.shop_url || '',
/* ignore */ is_available: flavor.is_available,
is_legacy: flavor.is_legacy,
});
} }
}; }, [flavor]);
getUser();
fetchFlavor();
}, [fetchFlavor]);
useTitle(flavor?.name || t('common.loading')); useTitle(flavor?.name || t('common.loading'));
const handleAdminUpdate = async () => { const handleAdminUpdate = async () => {
if (!id) return;
try { try {
await api.patch(`flavors/${id}/`, editFlavorData); await updateFlavor.mutateAsync({ id, data: editFlavorData });
setIsAdminEditing(false); setIsAdminEditing(false);
fetchFlavor();
} catch { } catch {
notify({ message: 'Admin update failed', severity: 'error' }); notify({ message: 'Admin update failed', severity: 'error' });
} }
@@ -156,17 +122,14 @@ const FlavorDetail: React.FC<FlavorDetailProps> = ({ adminMode }) => {
const handleImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => { const handleImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]; const file = e.target.files?.[0];
if (!file) return; if (!file || !id) return;
const formData = new FormData(); const formData = new FormData();
formData.append('image', file); formData.append('image', file);
try { try {
setUploadingImage(true); setUploadingImage(true);
await api.patch(`flavors/${id}/`, formData, { await updateFlavor.mutateAsync({ id, data: formData, isFormData: true });
headers: { 'Content-Type': 'multipart/form-data' },
});
fetchFlavor();
} catch { } catch {
notify({ message: 'Image upload failed', severity: 'error' }); notify({ message: 'Image upload failed', severity: 'error' });
} finally { } finally {
@@ -180,11 +143,11 @@ const FlavorDetail: React.FC<FlavorDetailProps> = ({ adminMode }) => {
notify({ message: 'Please select a score', severity: 'warning' }); notify({ message: 'Please select a score', severity: 'warning' });
return; return;
} }
if (!flavor) return;
try { try {
await api.post('ratings/', { flavor: flavor?.id, score: newScore, comment: newComment }); await createRating.mutateAsync({ flavor: flavor.id, score: newScore, comment: newComment });
setNewScore(null); setNewScore(null);
setNewComment(''); setNewComment('');
fetchFlavor();
} catch (err) { } catch (err) {
const msg = (err as { response?: { data?: { error?: string } } }).response?.data?.error; const msg = (err as { response?: { data?: { error?: string } } }).response?.data?.error;
notify({ message: msg || 'Failed to submit rating', severity: 'error' }); notify({ message: msg || 'Failed to submit rating', severity: 'error' });
@@ -195,10 +158,9 @@ const FlavorDetail: React.FC<FlavorDetailProps> = ({ adminMode }) => {
const text = replyInputs[ratingId]; const text = replyInputs[ratingId];
if (!text) return; if (!text) return;
try { try {
await api.post(`ratings/${ratingId}/reply/`, { text }); await createReply.mutateAsync({ ratingId, text });
setReplyInputs({ ...replyInputs, [ratingId]: '' }); setReplyInputs({ ...replyInputs, [ratingId]: '' });
setExpandedReplies((prev) => ({ ...prev, [ratingId]: true })); setExpandedReplies((prev) => ({ ...prev, [ratingId]: true }));
fetchFlavor();
} catch { } catch {
notify({ message: 'Failed to submit reply', severity: 'error' }); notify({ message: 'Failed to submit reply', severity: 'error' });
} }
@@ -207,9 +169,8 @@ const FlavorDetail: React.FC<FlavorDetailProps> = ({ adminMode }) => {
const handleUpdateReply = async (replyId: number) => { const handleUpdateReply = async (replyId: number) => {
if (!editReplyText) return; if (!editReplyText) return;
try { try {
await api.patch(`replies/${replyId}/`, { text: editReplyText }); await updateReply.mutateAsync({ replyId, text: editReplyText });
setEditingReplyId(null); setEditingReplyId(null);
fetchFlavor();
} catch { } catch {
notify({ message: 'Failed to update reply', severity: 'error' }); notify({ message: 'Failed to update reply', severity: 'error' });
} }
@@ -218,8 +179,7 @@ const FlavorDetail: React.FC<FlavorDetailProps> = ({ adminMode }) => {
const handleDeleteReply = async (replyId: number) => { const handleDeleteReply = async (replyId: number) => {
if (!(await confirm({ message: 'Delete this reply?', danger: true }))) return; if (!(await confirm({ message: 'Delete this reply?', danger: true }))) return;
try { try {
await api.delete(`replies/${replyId}/`); await deleteReplyMutation.mutateAsync(replyId);
fetchFlavor();
} catch { } catch {
notify({ message: 'Failed to delete reply', severity: 'error' }); notify({ message: 'Failed to delete reply', severity: 'error' });
} }
@@ -228,14 +188,13 @@ const FlavorDetail: React.FC<FlavorDetailProps> = ({ adminMode }) => {
const handleDeleteRating = async (ratingId: number) => { const handleDeleteRating = async (ratingId: number) => {
if (!(await confirm({ message: 'Delete this review?', danger: true }))) return; if (!(await confirm({ message: 'Delete this review?', danger: true }))) return;
try { try {
await api.delete(`ratings/${ratingId}/`); await deleteRatingMutation.mutateAsync(ratingId);
fetchFlavor();
} catch { } catch {
notify({ message: 'Failed to delete review', severity: 'error' }); notify({ message: 'Failed to delete review', severity: 'error' });
} }
}; };
const startEdit = (rating: Rating) => { const startEdit = (rating: FlavorDetailRating) => {
setEditMode(rating.id); setEditMode(rating.id);
setEditScore(rating.score); setEditScore(rating.score);
setEditComment(rating.comment || ''); setEditComment(rating.comment || '');
@@ -243,9 +202,8 @@ const FlavorDetail: React.FC<FlavorDetailProps> = ({ adminMode }) => {
const handleUpdateRating = async (ratingId: number) => { const handleUpdateRating = async (ratingId: number) => {
try { try {
await api.patch(`ratings/${ratingId}/`, { score: editScore, comment: editComment }); await updateRating.mutateAsync({ id: ratingId, score: editScore, comment: editComment });
setEditMode(null); setEditMode(null);
fetchFlavor();
} catch { } catch {
notify({ message: 'Failed to update review', severity: 'error' }); notify({ message: 'Failed to update review', severity: 'error' });
} }
@@ -544,7 +502,7 @@ const FlavorDetail: React.FC<FlavorDetailProps> = ({ adminMode }) => {
{flavor.ratings.length === 0 ? ( {flavor.ratings.length === 0 ? (
<EmptyState title={t('dashboard.noRatings')} /> <EmptyState title={t('dashboard.noRatings')} />
) : ( ) : (
flavor.ratings.map((rating: Rating) => ( flavor.ratings.map((rating: FlavorDetailRating) => (
<GlassCard key={rating.id}> <GlassCard key={rating.id}>
<CardContent sx={{ p: 3 }}> <CardContent sx={{ p: 3 }}>
{editMode === rating.id ? ( {editMode === rating.id ? (
+18
View File
@@ -0,0 +1,18 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import type { ReactNode } from 'react';
/**
* Build a fresh QueryClient per test so cache state never leaks across cases.
* Disable retries so failed mocks fail fast.
*/
export const createTestQueryClient = () =>
new QueryClient({
defaultOptions: {
queries: { retry: false, gcTime: 0, staleTime: 0 },
mutations: { retry: false },
},
});
export const withQueryClient = (children: ReactNode, client = createTestQueryClient()) => (
<QueryClientProvider client={client}>{children}</QueryClientProvider>
);