mirror of
https://github.com/narrrl/holy-flavor-rater.git
synced 2026-11-03 16:41:27 +01:00
typed api
This commit is contained in:
1 parent
5219a66722
commit
fa37f8eb79
61 files changed
+2961
-2578
No files matched your search
Generated
+55
@@ -13,6 +13,8 @@
|
||||
"@emotion/styled": "^11.14.1",
|
||||
"@mui/icons-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",
|
||||
"colorthief": "^2.6.0",
|
||||
"i18next": "^24.2.2",
|
||||
@@ -2400,6 +2402,59 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "10.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
|
||||
|
||||
@@ -20,6 +20,8 @@
|
||||
"@emotion/styled": "^11.14.1",
|
||||
"@mui/icons-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",
|
||||
"colorthief": "^2.6.0",
|
||||
"i18next": "^24.2.2",
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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 ?? []);
|
||||
},
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,19 +1,25 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { QueryClientProvider } from '@tanstack/react-query';
|
||||
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
|
||||
import { ThemeProvider } from '../contexts/ThemeContext';
|
||||
import { AuthProvider } from '../contexts/AuthContext';
|
||||
import { NotificationProvider } from '../contexts/NotificationContext';
|
||||
import { ToastProvider } from '../contexts/ToastContext';
|
||||
import { ConfirmProvider } from '../contexts/ConfirmContext';
|
||||
import { queryClient } from './queryClient';
|
||||
import '../i18n';
|
||||
|
||||
export const AppProviders = ({ children }: { children: ReactNode }) => (
|
||||
<ThemeProvider>
|
||||
<AuthProvider>
|
||||
<NotificationProvider>
|
||||
<ToastProvider>
|
||||
<ConfirmProvider>{children}</ConfirmProvider>
|
||||
</ToastProvider>
|
||||
</NotificationProvider>
|
||||
</AuthProvider>
|
||||
</ThemeProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ThemeProvider>
|
||||
<AuthProvider>
|
||||
<NotificationProvider>
|
||||
<ToastProvider>
|
||||
<ConfirmProvider>{children}</ConfirmProvider>
|
||||
</ToastProvider>
|
||||
</NotificationProvider>
|
||||
</AuthProvider>
|
||||
</ThemeProvider>
|
||||
{import.meta.env.DEV && <ReactQueryDevtools initialIsOpen={false} />}
|
||||
</QueryClientProvider>
|
||||
);
|
||||
@@ -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 },
|
||||
},
|
||||
});
|
||||
@@ -1,21 +1,16 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import api from '../lib/api';
|
||||
import GenerativeBanner from './GenerativeBanner';
|
||||
import NebulaBanner from './NebulaBanner';
|
||||
import FirefliesBanner from './FirefliesBanner';
|
||||
import HextechBanner from './HextechBanner';
|
||||
import HextechCorruptionBanner from './HextechCorruptionBanner';
|
||||
import GenerativeFlowBanner from './GenerativeFlowBanner';
|
||||
import { BannerPerformanceWrapper } from './BannerPerformanceWrapper';
|
||||
|
||||
interface Banner {
|
||||
id: number;
|
||||
name: string;
|
||||
slug: string;
|
||||
description: string;
|
||||
is_active: boolean;
|
||||
settings: any;
|
||||
}
|
||||
import React from 'react';
|
||||
import { useActiveBanner } from '../api/queries/useActiveBanner';
|
||||
import type { BannerConfig } from '../api/types';
|
||||
import {
|
||||
BannerPerformanceWrapper,
|
||||
FirefliesBanner,
|
||||
GenerativeBanner,
|
||||
GenerativeFlowBanner,
|
||||
HextechBanner,
|
||||
HextechCorruptionBanner,
|
||||
MatrixBanner,
|
||||
NebulaBanner,
|
||||
} from './banners';
|
||||
|
||||
interface DynamicBannerProps {
|
||||
username: string;
|
||||
@@ -24,90 +19,29 @@ interface DynamicBannerProps {
|
||||
followersCount: number;
|
||||
}
|
||||
|
||||
const MatrixBanner: React.FC<DynamicBannerProps & { settings: Banner['settings'] | null }> = ({
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
const pickBanner = (banner: Banner | null, props: DynamicBannerProps): React.ReactElement => {
|
||||
const pickBanner = (
|
||||
banner: BannerConfig | null | undefined,
|
||||
props: DynamicBannerProps,
|
||||
): React.ReactElement => {
|
||||
const settings = banner?.settings ?? undefined;
|
||||
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 === 'nebula') return <NebulaBanner {...props} settings={banner.settings} />;
|
||||
if (banner.slug === 'fireflies') return <FirefliesBanner {...props} settings={banner.settings} />;
|
||||
if (banner.slug === 'hextech') return <HextechBanner {...props} settings={banner.settings} />;
|
||||
if (banner.slug === 'matrix') return <MatrixBanner {...props} settings={settings} />;
|
||||
if (banner.slug === 'nebula') return <NebulaBanner {...props} settings={settings} />;
|
||||
if (banner.slug === 'fireflies') return <FirefliesBanner {...props} settings={settings} />;
|
||||
if (banner.slug === 'hextech') return <HextechBanner {...props} settings={settings} />;
|
||||
if (banner.slug === 'hextech-corruption')
|
||||
return <HextechCorruptionBanner {...props} settings={banner.settings} />;
|
||||
return <HextechCorruptionBanner {...props} settings={settings} />;
|
||||
if (banner.slug === 'generative-flow')
|
||||
return <GenerativeFlowBanner {...props} settings={banner.settings} />;
|
||||
return <GenerativeFlowBanner {...props} settings={settings} />;
|
||||
return <GenerativeBanner {...props} />;
|
||||
};
|
||||
|
||||
const DynamicBanner: React.FC<DynamicBannerProps> = (props) => {
|
||||
const [banner, setBanner] = useState<Banner | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const { data: banner, isLoading } = useActiveBanner(props.username);
|
||||
|
||||
useEffect(() => {
|
||||
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;
|
||||
if (isLoading) return null;
|
||||
|
||||
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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
File renamed without changes.
File renamed without changes.
+58
-147
@@ -1,6 +1,6 @@
|
||||
import React, { useRef, useEffect, useMemo } from 'react';
|
||||
import React, { useMemo } from 'react';
|
||||
import { alpha, useTheme } from '@mui/material';
|
||||
import { useBannerFrameGate } from './BannerPerformanceWrapper';
|
||||
import { CANVAS_STYLE, useCanvasBanner } from './useCanvasBanner';
|
||||
|
||||
interface FirefliesBannerProps {
|
||||
username: string;
|
||||
@@ -17,27 +17,37 @@ interface FirefliesBannerProps {
|
||||
};
|
||||
}
|
||||
|
||||
interface Firefly {
|
||||
x: number;
|
||||
y: number;
|
||||
vx: number;
|
||||
vy: number;
|
||||
size: number;
|
||||
color: string;
|
||||
phase: number;
|
||||
noiseOffset: number;
|
||||
isEvading: boolean;
|
||||
}
|
||||
|
||||
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 FirefliesBanner: React.FC<FirefliesBannerProps> = ({ username, palette, settings }) => {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const theme = useTheme();
|
||||
const gate = useBannerFrameGate();
|
||||
|
||||
const count = settings?.count ?? 150;
|
||||
const baseSpeed = settings?.speed ?? 0.8; // Slower default cruise
|
||||
const baseSpeed = settings?.speed ?? 0.8;
|
||||
const evasionRadius = settings?.evasionRadius ?? 180;
|
||||
const evasionStrength = settings?.evasionStrength ?? 0.6; // High evasion
|
||||
const evasionStrength = settings?.evasionStrength ?? 0.6;
|
||||
const glowSizeMultiplier = settings?.glowSize ?? 4;
|
||||
const flickerSpeed = settings?.flickerSpeed ?? 0.05;
|
||||
|
||||
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 seed = useMemo(() => hashString(username), [username]);
|
||||
|
||||
const brightColors = useMemo(() => {
|
||||
const colors =
|
||||
@@ -54,38 +64,15 @@ const FirefliesBanner: React.FC<FirefliesBannerProps> = ({ username, palette, se
|
||||
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;
|
||||
|
||||
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 x = Math.sin(seed + offset) * 10000;
|
||||
return x - Math.floor(x);
|
||||
};
|
||||
|
||||
const initSwarm = (width: number, height: number) => {
|
||||
const newSwarm = [];
|
||||
const ref = useCanvasBanner<{ swarm: Firefly[]; time: number }>({
|
||||
init: (width, height) => {
|
||||
const pseudoRandom = (offset: number) => {
|
||||
const x = Math.sin(seed + offset) * 10000;
|
||||
return x - Math.floor(x);
|
||||
};
|
||||
const swarm: Firefly[] = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
newSwarm.push({
|
||||
swarm.push({
|
||||
x: pseudoRandom(i) * width,
|
||||
y: pseudoRandom(i + 500) * height,
|
||||
vx: (pseudoRandom(i + 1000) - 0.5) * baseSpeed,
|
||||
@@ -97,60 +84,20 @@ const FirefliesBanner: React.FC<FirefliesBannerProps> = ({ username, palette, se
|
||||
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 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;
|
||||
}
|
||||
const pseudoRandom = (offset: number) => {
|
||||
const x = Math.sin(seed + offset) * 10000;
|
||||
return x - Math.floor(x);
|
||||
};
|
||||
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
|
||||
// Vibrant theme-aware night sky background
|
||||
const isLight = theme.palette.mode === 'light';
|
||||
const bgGrad = ctx.createRadialGradient(
|
||||
width / 2,
|
||||
@@ -161,32 +108,25 @@ const FirefliesBanner: React.FC<FirefliesBannerProps> = ({ username, palette, se
|
||||
width,
|
||||
);
|
||||
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(1, alpha(brightColors[2] || '#16213e', 1.0));
|
||||
} else {
|
||||
bgGrad.addColorStop(0, '#141a2a'); // Noticeable blue-grey tint
|
||||
bgGrad.addColorStop(0, '#141a2a');
|
||||
bgGrad.addColorStop(1, '#05080f');
|
||||
}
|
||||
ctx.fillStyle = bgGrad;
|
||||
ctx.fillRect(0, 0, width, height);
|
||||
|
||||
time += flickerSpeed;
|
||||
|
||||
swarm.forEach((f, i) => {
|
||||
// 1. Autonomous Chaotic Movement (Perlin + Jitter)
|
||||
const driftX = Math.sin(time * 0.5 + 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 jitterY = (pseudoRandom(i + 500 + time * 10) - 0.5) * 0.2;
|
||||
|
||||
f.vx += driftX + jitterX;
|
||||
f.vy += driftY + jitterY;
|
||||
|
||||
// 2. Mouse Evasion (Fast Dash)
|
||||
const dx = mouse.x - f.x;
|
||||
const dy = mouse.y - f.y;
|
||||
const dist = Math.sqrt(dx * dx + dy * dy);
|
||||
@@ -195,12 +135,10 @@ const FirefliesBanner: React.FC<FirefliesBannerProps> = ({ username, palette, se
|
||||
if (f.isEvading) {
|
||||
const force = (evasionRadius - dist) / evasionRadius;
|
||||
const angle = Math.atan2(dy, dx);
|
||||
// Sharp acceleration away
|
||||
f.vx -= Math.cos(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 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;
|
||||
}
|
||||
|
||||
// 4. Smooth Friction
|
||||
f.vx *= 0.97;
|
||||
f.vy *= 0.97;
|
||||
|
||||
f.x += f.vx;
|
||||
f.y += f.vy;
|
||||
|
||||
// Screen Wrap
|
||||
const margin = 20;
|
||||
if (f.x < -margin) f.x = width + margin;
|
||||
if (f.x > width + margin) f.x = -margin;
|
||||
if (f.y < -margin) f.y = height + margin;
|
||||
if (f.y > height + margin) f.y = -margin;
|
||||
|
||||
// Flicker
|
||||
const flicker = 0.4 + Math.sin(time + f.phase) * 0.6;
|
||||
const opacity = flicker > 0 ? flicker : 0;
|
||||
|
||||
// Render
|
||||
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.4, alpha(f.color, opacity * 0.3));
|
||||
@@ -243,44 +177,21 @@ const FirefliesBanner: React.FC<FirefliesBannerProps> = ({ username, palette, se
|
||||
ctx.arc(f.x, f.y, f.size * 0.5, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
});
|
||||
},
|
||||
deps: [
|
||||
seed,
|
||||
brightColors,
|
||||
count,
|
||||
baseSpeed,
|
||||
evasionRadius,
|
||||
evasionStrength,
|
||||
glowSizeMultiplier,
|
||||
flickerSpeed,
|
||||
theme,
|
||||
],
|
||||
});
|
||||
|
||||
animationFrameId = requestAnimationFrame(draw);
|
||||
};
|
||||
|
||||
draw();
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
window.removeEventListener('mousemove', handleMouseMove);
|
||||
cancelAnimationFrame(animationFrameId);
|
||||
};
|
||||
}, [
|
||||
seed,
|
||||
brightColors,
|
||||
count,
|
||||
baseSpeed,
|
||||
evasionRadius,
|
||||
evasionStrength,
|
||||
glowSizeMultiplier,
|
||||
flickerSpeed,
|
||||
theme,
|
||||
gate,
|
||||
]);
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 0,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
return <canvas ref={ref} style={CANVAS_STYLE} />;
|
||||
};
|
||||
|
||||
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;
|
||||
+42
-139
@@ -1,6 +1,6 @@
|
||||
import React, { useRef, useEffect, useMemo } from 'react';
|
||||
import React, { useMemo } from 'react';
|
||||
import { alpha, useTheme } from '@mui/material';
|
||||
import { useBannerFrameGate } from './BannerPerformanceWrapper';
|
||||
import { CANVAS_STYLE, useCanvasBanner } from './useCanvasBanner';
|
||||
|
||||
interface GenerativeFlowProps {
|
||||
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 canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const theme = useTheme();
|
||||
const gate = useBannerFrameGate();
|
||||
|
||||
const blobCount = settings?.blobCount ?? 8;
|
||||
const flowSpeed = settings?.flowSpeed ?? 0.008;
|
||||
@@ -27,15 +46,7 @@ const GenerativeFlowBanner: React.FC<GenerativeFlowProps> = ({ username, palette
|
||||
const blurAmount = settings?.blur ?? 60;
|
||||
const distortionFactor = settings?.distortion ?? 1.2;
|
||||
|
||||
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 seed = useMemo(() => hashString(username), [username]);
|
||||
|
||||
const brightColors = useMemo(() => {
|
||||
const colors =
|
||||
@@ -52,42 +63,17 @@ const GenerativeFlowBanner: React.FC<GenerativeFlowProps> = ({ username, palette
|
||||
return sorted.slice(0, 4);
|
||||
}, [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 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 x = Math.sin(seed + offset) * 10000;
|
||||
return x - Math.floor(x);
|
||||
};
|
||||
|
||||
const initBlobs = (width: number, height: number) => {
|
||||
const newBlobs = [];
|
||||
const ref = useCanvasBanner<{ blobs: Blob[]; time: number }>({
|
||||
init: (width, height) => {
|
||||
const pseudoRandom = (offset: number) => {
|
||||
const x = Math.sin(seed + offset) * 10000;
|
||||
return x - Math.floor(x);
|
||||
};
|
||||
const blobs: Blob[] = [];
|
||||
for (let i = 0; i < blobCount; i++) {
|
||||
const rx = pseudoRandom(i) * width;
|
||||
const ry = pseudoRandom(i + 500) * height;
|
||||
newBlobs.push({
|
||||
blobs.push({
|
||||
x: rx,
|
||||
y: ry,
|
||||
ox: rx,
|
||||
@@ -100,59 +86,15 @@ const GenerativeFlowBanner: React.FC<GenerativeFlowProps> = ({ username, palette
|
||||
speedFactor: 0.5 + pseudoRandom(i + 2000),
|
||||
});
|
||||
}
|
||||
return newBlobs;
|
||||
};
|
||||
|
||||
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`;
|
||||
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;
|
||||
}
|
||||
return { blobs, time: 0 };
|
||||
},
|
||||
draw: ({ ctx, width, height, state, mouse }) => {
|
||||
const { blobs } = state;
|
||||
state.time += flowSpeed;
|
||||
const time = state.time;
|
||||
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
|
||||
// Background Base
|
||||
const isLight = theme.palette.mode === 'light';
|
||||
const bgGrad = ctx.createRadialGradient(
|
||||
width / 2,
|
||||
@@ -163,7 +105,6 @@ const GenerativeFlowBanner: React.FC<GenerativeFlowProps> = ({ username, palette
|
||||
width,
|
||||
);
|
||||
if (isLight) {
|
||||
// For light themes, use a deeper, more saturated version of the theme color
|
||||
bgGrad.addColorStop(
|
||||
0,
|
||||
alpha(brightColors[brightColors.length - 1] || brightColors[0], 0.6),
|
||||
@@ -179,18 +120,13 @@ const GenerativeFlowBanner: React.FC<GenerativeFlowProps> = ({ username, palette
|
||||
ctx.fillStyle = bgGrad;
|
||||
ctx.fillRect(0, 0, width, height);
|
||||
|
||||
time += flowSpeed;
|
||||
|
||||
// Apply filter for "Liquid" look
|
||||
ctx.filter = `blur(${blurAmount}px)`;
|
||||
ctx.globalCompositeOperation = 'screen';
|
||||
|
||||
blobs.forEach((b) => {
|
||||
// Harmonic autonomous drift
|
||||
const driftX = Math.sin(time * b.speedFactor + b.phase) * (width / 5);
|
||||
const driftY = Math.cos(time * 0.8 * b.speedFactor + b.phase) * (height / 5);
|
||||
|
||||
// Mouse distortion
|
||||
const dxMouse = mouse.x - b.ox;
|
||||
const dyMouse = mouse.y - b.oy;
|
||||
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.y = b.oy + driftY + mY;
|
||||
|
||||
// Pulsing size
|
||||
const pulse = 1 + Math.sin(time + b.phase) * 0.1;
|
||||
const currentSize = b.size * pulse;
|
||||
|
||||
@@ -218,43 +153,11 @@ const GenerativeFlowBanner: React.FC<GenerativeFlowProps> = ({ username, palette
|
||||
|
||||
ctx.filter = 'none';
|
||||
ctx.globalCompositeOperation = 'source-over';
|
||||
},
|
||||
deps: [seed, brightColors, blobCount, flowSpeed, vibrancy, blurAmount, distortionFactor, theme],
|
||||
});
|
||||
|
||||
animationFrameId = requestAnimationFrame(draw);
|
||||
};
|
||||
|
||||
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,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
return <canvas ref={ref} style={CANVAS_STYLE} />;
|
||||
};
|
||||
|
||||
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;
|
||||
@@ -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,
|
||||
};
|
||||
@@ -1,26 +1,14 @@
|
||||
import { createContext, useCallback, useEffect, useState, type ReactNode } from 'react';
|
||||
import api from '../lib/api';
|
||||
import { createContext, useCallback, useEffect, type ReactNode } from 'react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
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 {
|
||||
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 type { Notification };
|
||||
|
||||
export interface NotificationContextValue {
|
||||
notifications: Notification[];
|
||||
@@ -33,49 +21,40 @@ export const NotificationContext = createContext<NotificationContextValue | null
|
||||
|
||||
export const NotificationProvider = ({ children }: { children: ReactNode }) => {
|
||||
const { user, setUser, refetchUser } = useAuth();
|
||||
const [notifications, setNotifications] = useState<Notification[]>([]);
|
||||
const qc = useQueryClient();
|
||||
|
||||
const fetchNotifications = useCallback(async () => {
|
||||
try {
|
||||
const res = await api.get('notifications/');
|
||||
const data = Array.isArray(res.data) ? res.data : res.data.results || [];
|
||||
setNotifications(data);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
fetchNotifications();
|
||||
}
|
||||
}, [user, fetchNotifications]);
|
||||
const { data: notifications = [], refetch } = useNotificationsQuery(!!user);
|
||||
const markAllMutation = useMarkAllNotificationsRead();
|
||||
const markOneMutation = useMarkNotificationRead();
|
||||
|
||||
// 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(() => {
|
||||
const interval = setInterval(() => {
|
||||
if (localStorage.getItem('access')) {
|
||||
fetchNotifications();
|
||||
refetchUser();
|
||||
}
|
||||
}, 60000);
|
||||
}, 60_000);
|
||||
return () => clearInterval(interval);
|
||||
}, [fetchNotifications, refetchUser]);
|
||||
}, [refetchUser]);
|
||||
|
||||
const fetchNotifications = useCallback(async () => {
|
||||
await refetch();
|
||||
}, [refetch]);
|
||||
|
||||
const markAllRead = useCallback(async () => {
|
||||
try {
|
||||
await api.post('notifications/mark_all_read/');
|
||||
setNotifications((prev) => prev.map((n) => ({ ...n, is_read: true })));
|
||||
await markAllMutation.mutateAsync();
|
||||
if (user) setUser({ ...user, unread_notifications_count: 0 });
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}, [user, setUser]);
|
||||
}, [markAllMutation, setUser, user]);
|
||||
|
||||
const markRead = useCallback(
|
||||
async (id: number) => {
|
||||
try {
|
||||
await api.post(`notifications/${id}/mark_read/`);
|
||||
setNotifications((prev) => prev.map((n) => (n.id === id ? { ...n, is_read: true } : n)));
|
||||
await markOneMutation.mutateAsync(id);
|
||||
if (user) {
|
||||
setUser({
|
||||
...user,
|
||||
@@ -86,9 +65,15 @@ export const NotificationProvider = ({ children }: { children: ReactNode }) => {
|
||||
/* 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 (
|
||||
<NotificationContext.Provider
|
||||
value={{ notifications, fetchNotifications, markAllRead, markRead }}
|
||||
|
||||
+12
-2
@@ -37,7 +37,13 @@ api.interceptors.request.use((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;
|
||||
|
||||
@@ -76,7 +82,11 @@ api.interceptors.response.use(
|
||||
refreshPromise = null;
|
||||
|
||||
if (!newAccess) {
|
||||
if (typeof window !== 'undefined' && window.location.pathname !== '/login') {
|
||||
if (
|
||||
!original.skipAuthRedirect &&
|
||||
typeof window !== 'undefined' &&
|
||||
window.location.pathname !== '/login'
|
||||
) {
|
||||
window.location.href = '/login';
|
||||
}
|
||||
return Promise.reject(error);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
Typography,
|
||||
Box,
|
||||
@@ -43,7 +44,21 @@ import PlayArrowIcon from '@mui/icons-material/PlayArrow';
|
||||
import InfoIcon from '@mui/icons-material/Info';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
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 { formatDate } from '../utils/date';
|
||||
import BannerSettingsDialog from '../components/BannerSettingsDialog';
|
||||
@@ -125,59 +140,56 @@ const AdminDashboard: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const isMobile = useMediaQuery('(max-width:900px)');
|
||||
useTitle(t('admin.title'));
|
||||
const [stats, setStats] = useState<Stats | null>(null);
|
||||
const [users, setUsers] = useState<AdminUser[]>([]);
|
||||
const [banners, setBanners] = useState<Banner[]>([]);
|
||||
const [jobs, setJobs] = useState<Job[]>([]);
|
||||
const [config, setConfig] = useState<SystemConfig | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const qc = useQueryClient();
|
||||
const { data: stats = null, isLoading: statsLoading } = useAdminStats() as unknown as {
|
||||
data: Stats | null | undefined;
|
||||
isLoading: boolean;
|
||||
};
|
||||
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 [currentTab, setCurrentTab] = useState(0);
|
||||
const [selectedBanner, setSelectedBanner] = useState<Banner | null>(null);
|
||||
const [isSettingsDialogOpen, setIsSettingsDialogOpen] = useState(false);
|
||||
const { notify } = useToast();
|
||||
|
||||
const fetchData = useCallback(async (silent = false) => {
|
||||
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
|
||||
// Poll the jobs query faster while anything is running on the Jobs tab.
|
||||
useEffect(() => {
|
||||
const anyRunning = jobs.some((j) => j.status === 'running' || j.status === 'pending');
|
||||
if (anyRunning && currentTab === 3) {
|
||||
const interval = setInterval(() => fetchData(true), 3000);
|
||||
const interval = setInterval(() => {
|
||||
qc.invalidateQueries({ queryKey: ['adminJobs'] });
|
||||
}, 3000);
|
||||
return () => clearInterval(interval);
|
||||
}
|
||||
}, [jobs, currentTab, fetchData]);
|
||||
}, [jobs, currentTab, qc]);
|
||||
|
||||
const handleUpdateConfig = async (key: string, value: unknown) => {
|
||||
try {
|
||||
const res = await api.patch('admin-custom/config/', { [key]: value });
|
||||
setConfig(res.data);
|
||||
await updateConfigMutation.mutateAsync({ key, value });
|
||||
} catch {
|
||||
notify({ message: 'Failed to update configuration', severity: 'error' });
|
||||
}
|
||||
@@ -185,8 +197,7 @@ const AdminDashboard: React.FC = () => {
|
||||
|
||||
const handleTriggerJob = async (id: number) => {
|
||||
try {
|
||||
await api.post(`admin-custom/${id}/trigger_job/`);
|
||||
fetchData(true);
|
||||
await triggerJobMutation.mutateAsync(id);
|
||||
} catch {
|
||||
notify({ message: 'Failed to trigger job', severity: 'error' });
|
||||
}
|
||||
@@ -196,8 +207,7 @@ const AdminDashboard: React.FC = () => {
|
||||
try {
|
||||
const data: Record<string, unknown> = { interval_hours: interval };
|
||||
if (nextRun) data.next_run = nextRun;
|
||||
await api.patch(`admin-custom/${id}/update_job_schedule/`, data);
|
||||
fetchData(true);
|
||||
await updateJobScheduleMutation.mutateAsync({ id, data });
|
||||
} catch {
|
||||
notify({ message: 'Failed to update schedule', severity: 'error' });
|
||||
}
|
||||
@@ -205,8 +215,7 @@ const AdminDashboard: React.FC = () => {
|
||||
|
||||
const handleActivateBanner = async (id: number) => {
|
||||
try {
|
||||
await api.post(`banners/${id}/activate/`);
|
||||
fetchData();
|
||||
await activateBannerMutation.mutateAsync(id);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
@@ -214,8 +223,7 @@ const AdminDashboard: React.FC = () => {
|
||||
|
||||
const handleToggleEnabled = async (id: number) => {
|
||||
try {
|
||||
await api.post(`banners/${id}/toggle_enabled/`);
|
||||
fetchData();
|
||||
await toggleBannerEnabledMutation.mutateAsync(id);
|
||||
} catch (err) {
|
||||
const error = err as { response?: { data?: { error?: string } } };
|
||||
notify({
|
||||
@@ -225,13 +233,9 @@ const AdminDashboard: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
const handleSendTestEmail = async () => {
|
||||
try {
|
||||
await api.post('admin-custom/send_test_email/');
|
||||
await sendTestEmailMutation.mutateAsync();
|
||||
notify({ message: t('admin.testEmailSuccess'), severity: 'success' });
|
||||
} catch (err) {
|
||||
const error = err as { response?: { data?: { error?: string } } };
|
||||
@@ -616,7 +620,10 @@ const AdminDashboard: React.FC = () => {
|
||||
title="No banner models found"
|
||||
subtitle="No banners registered in the database."
|
||||
action={
|
||||
<Button variant="outlined" onClick={() => fetchData()}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={() => qc.invalidateQueries({ queryKey: ['banners'] })}
|
||||
>
|
||||
Retry Loading
|
||||
</Button>
|
||||
}
|
||||
@@ -857,7 +864,7 @@ const AdminDashboard: React.FC = () => {
|
||||
setSelectedBanner(null);
|
||||
}}
|
||||
banner={selectedBanner}
|
||||
onSave={fetchData}
|
||||
onSave={() => qc.invalidateQueries({ queryKey: ['banners'] })}
|
||||
/>
|
||||
</PageShell>
|
||||
);
|
||||
|
||||
@@ -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 ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
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 MentionTextField from '../components/MentionTextField';
|
||||
import { PageShell, SectionHeader, FormCard } from '../components/ui';
|
||||
import { useToast } from '../hooks/useToast';
|
||||
import { useConfirm } from '../hooks/useConfirm';
|
||||
|
||||
interface AdminRating {
|
||||
id: number;
|
||||
user: string;
|
||||
score: number;
|
||||
comment: string | null;
|
||||
flavor_name: string;
|
||||
}
|
||||
|
||||
const AdminRatingDetail: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
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 [comment, setComment] = useState('');
|
||||
const [rating, setRating] = useState<AdminRating | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const { notify } = useToast();
|
||||
const { confirm } = useConfirm();
|
||||
|
||||
const fetchRating = useCallback(async () => {
|
||||
try {
|
||||
const res = await api.get<AdminRating>(`ratings/${id}/`);
|
||||
setRating(res.data);
|
||||
setScore(res.data.score);
|
||||
setComment(res.data.comment || '');
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
navigate('/admin-panel');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
useEffect(() => {
|
||||
if (rating) {
|
||||
setScore(rating.score);
|
||||
setComment(rating.comment || '');
|
||||
}
|
||||
}, [id, navigate]);
|
||||
}, [rating]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchRating();
|
||||
}, [fetchRating]);
|
||||
if (error) navigate('/admin-panel');
|
||||
}, [error, navigate]);
|
||||
|
||||
useTitle(rating ? `${t('admin.manageRating')}: ${rating.flavor_name}` : 'Rating Management');
|
||||
|
||||
const handleUpdate = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!id) return;
|
||||
try {
|
||||
await api.patch(`ratings/${id}/`, { score, comment });
|
||||
fetchRating();
|
||||
await updateRating.mutateAsync({ id: Number(id), score, comment });
|
||||
notify({ message: 'Rating updated!', severity: 'success' });
|
||||
} catch {
|
||||
notify({ message: 'Update failed', severity: 'error' });
|
||||
@@ -62,9 +50,10 @@ const AdminRatingDetail: React.FC = () => {
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!id) return;
|
||||
if (!(await confirm({ message: 'Delete this rating and all replies?', danger: true }))) return;
|
||||
try {
|
||||
await api.delete(`ratings/${id}/`);
|
||||
await deleteRating.mutateAsync(Number(id));
|
||||
navigate('/admin-panel');
|
||||
} catch {
|
||||
notify({ message: 'Delete failed', severity: 'error' });
|
||||
|
||||
@@ -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 ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
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 MentionTextField from '../components/MentionTextField';
|
||||
import { PageShell, SectionHeader, FormCard } from '../components/ui';
|
||||
import { useToast } from '../hooks/useToast';
|
||||
import { useConfirm } from '../hooks/useConfirm';
|
||||
|
||||
interface AdminReply {
|
||||
id: number;
|
||||
user: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
const AdminReplyDetail: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const { data: reply, isLoading: loading, error } = useAdminReply(id);
|
||||
const updateReply = useUpdateReply();
|
||||
const deleteReply = useDeleteReply();
|
||||
|
||||
const [text, setText] = useState('');
|
||||
const [reply, setReply] = useState<AdminReply | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const { notify } = useToast();
|
||||
const { confirm } = useConfirm();
|
||||
|
||||
const fetchReply = useCallback(async () => {
|
||||
try {
|
||||
const res = await api.get<AdminReply>(`replies/${id}/`);
|
||||
setReply(res.data);
|
||||
setText(res.data.text);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
navigate('/admin-panel');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [id, navigate]);
|
||||
useEffect(() => {
|
||||
if (reply) setText(reply.text);
|
||||
}, [reply]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchReply();
|
||||
}, [fetchReply]);
|
||||
if (error) navigate('/admin-panel');
|
||||
}, [error, navigate]);
|
||||
|
||||
useTitle(reply ? `${t('admin.manageReply')}` : 'Reply Management');
|
||||
|
||||
const handleUpdate = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!id) return;
|
||||
try {
|
||||
await api.patch(`replies/${id}/`, { text });
|
||||
fetchReply();
|
||||
await updateReply.mutateAsync({ replyId: Number(id), text });
|
||||
notify({ message: 'Reply updated!', severity: 'success' });
|
||||
} catch {
|
||||
notify({ message: 'Update failed', severity: 'error' });
|
||||
@@ -58,9 +47,10 @@ const AdminReplyDetail: React.FC = () => {
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!id) return;
|
||||
if (!(await confirm({ message: 'Delete this reply?', danger: true }))) return;
|
||||
try {
|
||||
await api.delete(`replies/${id}/`);
|
||||
await deleteReply.mutateAsync(Number(id));
|
||||
navigate('/admin-panel');
|
||||
} catch {
|
||||
notify({ message: 'Delete failed', severity: 'error' });
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
Typography,
|
||||
Box,
|
||||
@@ -24,7 +24,14 @@ import HistoryIcon from '@mui/icons-material/History';
|
||||
import RateReviewIcon from '@mui/icons-material/RateReview';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
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 { formatDate } from '../utils/date';
|
||||
import MentionTextField from '../components/MentionTextField';
|
||||
@@ -72,10 +79,23 @@ const AdminUserDetail: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const [user, setUser] = useState<AdminUser | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [activeTab, setActiveTab] = useState(0);
|
||||
const {
|
||||
data: user,
|
||||
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 [editScore, setEditScore] = useState(0);
|
||||
const [editComment, setEditComment] = useState('');
|
||||
@@ -86,29 +106,16 @@ const AdminUserDetail: React.FC = () => {
|
||||
const { notify } = useToast();
|
||||
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(() => {
|
||||
fetchUser();
|
||||
}, [fetchUser]);
|
||||
if (error) navigate('/admin-panel');
|
||||
}, [error, navigate]);
|
||||
|
||||
useTitle(user ? `${t('admin.manageUser')}: ${user.username}` : 'User Management');
|
||||
|
||||
const handleToggleActive = async () => {
|
||||
if (!user) return;
|
||||
if (!user || !id) return;
|
||||
try {
|
||||
await api.patch(`admin-custom/${id}/user_detail/`, { is_active: !user.is_active });
|
||||
fetchUser();
|
||||
await toggleActive.mutateAsync({ id, isActive: user.is_active });
|
||||
} catch {
|
||||
notify({ message: 'Action failed', severity: 'error' });
|
||||
}
|
||||
@@ -116,9 +123,12 @@ const AdminUserDetail: React.FC = () => {
|
||||
|
||||
const handleUpdateRating = async (ratingId: number) => {
|
||||
try {
|
||||
await api.patch(`ratings/${ratingId}/`, { score: editScore, comment: editComment });
|
||||
await updateRatingMutation.mutateAsync({
|
||||
id: ratingId,
|
||||
score: editScore,
|
||||
comment: editComment,
|
||||
});
|
||||
setEditingRatingId(null);
|
||||
fetchUser();
|
||||
} catch {
|
||||
notify({ message: 'Update failed', severity: 'error' });
|
||||
}
|
||||
@@ -127,8 +137,7 @@ const AdminUserDetail: React.FC = () => {
|
||||
const handleDeleteRating = async (ratingId: number) => {
|
||||
if (!(await confirm({ message: 'Delete this rating?', danger: true }))) return;
|
||||
try {
|
||||
await api.delete(`ratings/${ratingId}/`);
|
||||
fetchUser();
|
||||
await deleteRatingMutation.mutateAsync(ratingId);
|
||||
} catch {
|
||||
notify({ message: 'Delete failed', severity: 'error' });
|
||||
}
|
||||
@@ -136,9 +145,8 @@ const AdminUserDetail: React.FC = () => {
|
||||
|
||||
const handleUpdateReply = async (replyId: number) => {
|
||||
try {
|
||||
await api.patch(`replies/${replyId}/`, { text: editReplyText });
|
||||
await updateReplyMutation.mutateAsync({ replyId, text: editReplyText });
|
||||
setEditingReplyId(null);
|
||||
fetchUser();
|
||||
} catch {
|
||||
notify({ message: 'Update failed', severity: 'error' });
|
||||
}
|
||||
@@ -147,14 +155,14 @@ const AdminUserDetail: React.FC = () => {
|
||||
const handleDeleteReply = async (replyId: number) => {
|
||||
if (!(await confirm({ message: 'Delete this reply?', danger: true }))) return;
|
||||
try {
|
||||
await api.delete(`replies/${replyId}/`);
|
||||
fetchUser();
|
||||
await deleteReplyMutation.mutateAsync(replyId);
|
||||
} catch {
|
||||
notify({ message: 'Delete failed', severity: 'error' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!id) return;
|
||||
const ok = await confirm({
|
||||
title: 'Delete user',
|
||||
message: 'PERMANENTLY DELETE THIS USER AND ALL DATA? This cannot be undone.',
|
||||
@@ -163,7 +171,7 @@ const AdminUserDetail: React.FC = () => {
|
||||
});
|
||||
if (!ok) return;
|
||||
try {
|
||||
await api.delete(`admin-custom/${id}/user_detail/`);
|
||||
await deleteUserMutation.mutateAsync(id);
|
||||
navigate('/admin-panel');
|
||||
} catch {
|
||||
notify({ message: 'Delete failed', severity: 'error' });
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
Typography,
|
||||
Box,
|
||||
@@ -26,7 +26,14 @@ import NotificationsIcon from '@mui/icons-material/Notifications';
|
||||
import PeopleIcon from '@mui/icons-material/People';
|
||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||
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 RichText from '../components/RichText';
|
||||
import { formatDate } from '../utils/date';
|
||||
@@ -36,61 +43,6 @@ import RatingBadge from '../components/RatingBadge';
|
||||
import { PageShell, HeroBackdrop, SectionHeader, GlassCard, EmptyState } from '../components/ui';
|
||||
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 {
|
||||
adminMode?: boolean;
|
||||
}
|
||||
@@ -125,17 +77,23 @@ const CommunityFeed: React.FC<CommunityFeedProps> = ({ adminMode }) => {
|
||||
useTitle(t('community.title'));
|
||||
const navigate = useNavigate();
|
||||
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 [totalPages, setTotalPages] = useState(1);
|
||||
const [followingSearch, setFollowingSearch] = useState('');
|
||||
const [topFollowed, setTopFollowed] = useState<TopFlavor[]>([]);
|
||||
const [replyInputs, setReplyInputs] = useState<Record<number, string>>({});
|
||||
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) =>
|
||||
user.username.toLowerCase().includes(followingSearch.toLowerCase()),
|
||||
);
|
||||
@@ -145,52 +103,14 @@ const CommunityFeed: React.FC<CommunityFeedProps> = ({ adminMode }) => {
|
||||
else navigate('/');
|
||||
};
|
||||
|
||||
const fetchFeedData = useCallback(
|
||||
async (pageNum: number) => {
|
||||
setLoading(true);
|
||||
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(() => {
|
||||
if (!isAuthed) navigate('/login');
|
||||
}, [isAuthed, navigate]);
|
||||
|
||||
useEffect(() => {
|
||||
const token = localStorage.getItem('access');
|
||||
if (!token) {
|
||||
navigate('/login');
|
||||
return;
|
||||
}
|
||||
fetchFeedData(page);
|
||||
}, [page, navigate, fetchFeedData]);
|
||||
const status = (feedError as { response?: { status?: number } } | null)?.response?.status;
|
||||
if (status === 401) navigate('/login');
|
||||
}, [feedError, navigate]);
|
||||
|
||||
const handlePageChange = (_: React.ChangeEvent<unknown>, value: number) => {
|
||||
setPage(value);
|
||||
@@ -205,10 +125,7 @@ const CommunityFeed: React.FC<CommunityFeedProps> = ({ adminMode }) => {
|
||||
const text = replyInputs[ratingId];
|
||||
if (!text) return;
|
||||
try {
|
||||
const res = await api.post(`ratings/${ratingId}/reply/`, { text });
|
||||
setRatings((prev) =>
|
||||
prev.map((r) => (r.id === ratingId ? { ...r, replies: [...r.replies, res.data] } : r)),
|
||||
);
|
||||
await createReply.mutateAsync({ ratingId, text });
|
||||
setReplyInputs((prev) => ({ ...prev, [ratingId]: '' }));
|
||||
} catch {
|
||||
notify({ message: 'Failed to send reply', severity: 'error' });
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
Typography,
|
||||
CardContent,
|
||||
@@ -32,7 +32,13 @@ import SearchIcon from '@mui/icons-material/Search';
|
||||
import ColorThief from 'colorthief';
|
||||
import { useNavigate, Link } from 'react-router-dom';
|
||||
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 RatingBadge from '../components/RatingBadge';
|
||||
import StatusBadge from '../components/StatusBadge';
|
||||
@@ -43,56 +49,17 @@ import { PageShell, GlassCard, GlassSurface, EmptyState } from '../components/ui
|
||||
import { useToast } from '../hooks/useToast';
|
||||
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 { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const theme = useTheme();
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down('lg'));
|
||||
useTitle(t('dashboard.title'));
|
||||
const [data, setData] = useState<DashboardData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const { data, isLoading: loading } = useDashboard();
|
||||
const updateRating = useUpdateRating();
|
||||
const deleteRating = useDeleteRating();
|
||||
const createReply = useCreateReply();
|
||||
const deleteReply = useDeleteReply();
|
||||
const [activeTab, setActiveTab] = useState(0);
|
||||
const [palette, setPalette] = useState<string[]>([]);
|
||||
|
||||
@@ -125,21 +92,6 @@ const Dashboard: React.FC = () => {
|
||||
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(() => {
|
||||
if (data?.user?.avatar) {
|
||||
const img = new Image();
|
||||
@@ -177,9 +129,8 @@ const Dashboard: React.FC = () => {
|
||||
|
||||
const handleUpdateRating = async (ratingId: number) => {
|
||||
try {
|
||||
await api.patch(`ratings/${ratingId}/`, { score: editScore, comment: editComment });
|
||||
await updateRating.mutateAsync({ id: ratingId, score: editScore, comment: editComment });
|
||||
setEditingRatingId(null);
|
||||
fetchData();
|
||||
} catch {
|
||||
notify({ message: 'Failed to update review', severity: 'error' });
|
||||
}
|
||||
@@ -188,8 +139,7 @@ const Dashboard: React.FC = () => {
|
||||
const handleDeleteRating = async (ratingId: number) => {
|
||||
if (!(await confirm({ message: 'Delete this review?', danger: true }))) return;
|
||||
try {
|
||||
await api.delete(`ratings/${ratingId}/`);
|
||||
fetchData();
|
||||
await deleteRating.mutateAsync(ratingId);
|
||||
} catch {
|
||||
notify({ message: 'Failed to delete review', severity: 'error' });
|
||||
}
|
||||
@@ -199,10 +149,9 @@ const Dashboard: React.FC = () => {
|
||||
const text = replyInputs[ratingId];
|
||||
if (!text) return;
|
||||
try {
|
||||
await api.post(`ratings/${ratingId}/reply/`, { text });
|
||||
await createReply.mutateAsync({ ratingId, text });
|
||||
setReplyInputs({ ...replyInputs, [ratingId]: '' });
|
||||
setExpandedReplies({ ...expandedReplies, [ratingId]: true });
|
||||
fetchData();
|
||||
} catch {
|
||||
notify({ message: 'Failed to send reply', severity: 'error' });
|
||||
}
|
||||
@@ -211,8 +160,7 @@ const Dashboard: React.FC = () => {
|
||||
const handleDeleteReply = async (replyId: number) => {
|
||||
if (!(await confirm({ message: 'Delete this reply?', danger: true }))) return;
|
||||
try {
|
||||
await api.delete(`replies/${replyId}/`);
|
||||
fetchData();
|
||||
await deleteReply.mutateAsync(replyId);
|
||||
} catch {
|
||||
notify({ message: 'Failed to delete reply', severity: 'error' });
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Typography,
|
||||
CardContent,
|
||||
@@ -12,78 +12,36 @@ import {
|
||||
CircularProgress,
|
||||
} from '@mui/material';
|
||||
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 StatusBadge from '../components/StatusBadge';
|
||||
import { PageShell, HeroBackdrop, GlassCard, SectionHeader } from '../components/ui';
|
||||
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 {
|
||||
score: number;
|
||||
comment: string;
|
||||
}
|
||||
|
||||
const FlavorList: React.FC = () => {
|
||||
const [categories, setCategories] = useState<Category[]>([]);
|
||||
const [flavors, setFlavors] = useState<Flavor[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const { data: categories = [], isLoading: catLoading } = useCategories();
|
||||
const { data: flavors = [], isLoading: flavLoading } = useFlavorsList();
|
||||
const createRating = useCreateRating();
|
||||
const [ratingInput, setRatingInput] = useState<Record<number, RatingInput>>({});
|
||||
const { notify } = useToast();
|
||||
|
||||
useEffect(() => {
|
||||
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 loading = catLoading || flavLoading;
|
||||
|
||||
const handleRate = async (flavorId: number) => {
|
||||
const input = ratingInput[flavorId];
|
||||
if (!input) return;
|
||||
try {
|
||||
await api.post('ratings/', {
|
||||
await createRating.mutateAsync({
|
||||
flavor: flavorId,
|
||||
score: input.score,
|
||||
comment: input.comment,
|
||||
});
|
||||
const res = await api.get('flavors/');
|
||||
setFlavors(res.data);
|
||||
notify({ message: 'Rating submitted!', severity: 'success' });
|
||||
} catch {
|
||||
notify({
|
||||
|
||||
+25
-127
@@ -16,7 +16,14 @@ import { useTranslation } from 'react-i18next';
|
||||
import ChevronLeftIcon from '@mui/icons-material/ChevronLeft';
|
||||
import ChevronRightIcon from '@mui/icons-material/ChevronRight';
|
||||
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 RatingBadge from '../components/RatingBadge';
|
||||
import StatusBadge from '../components/StatusBadge';
|
||||
@@ -29,50 +36,6 @@ import {
|
||||
FlavorCard,
|
||||
} 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 {
|
||||
adminMode?: boolean;
|
||||
}
|
||||
@@ -80,99 +43,34 @@ interface MainPageProps {
|
||||
const MainPage: React.FC<MainPageProps> = ({ adminMode }) => {
|
||||
const { t } = useTranslation();
|
||||
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 [loading, setLoading] = useState(true);
|
||||
const [categoryLoading, setCategoryLoading] = useState(false);
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const isFirstRun = useRef(true);
|
||||
const location = useLocation();
|
||||
|
||||
const query = new URLSearchParams(location.search).get('q') || '';
|
||||
const isLoggedIn = !!localStorage.getItem('access');
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
if (query) {
|
||||
const res = await api.get(`flavors/?search=${encodeURIComponent(query)}`);
|
||||
setAllFlavors(Array.isArray(res.data) ? res.data : res.data.results || []);
|
||||
} else {
|
||||
const requests = [
|
||||
api.get('flavors/top/'),
|
||||
api.get('ratings/recent/'),
|
||||
api.get('flavors/newest/'),
|
||||
api.get('categories/'),
|
||||
];
|
||||
const { data: searchResults = [], isLoading: searchLoading } = useFlavorSearch(query);
|
||||
const {
|
||||
data: topFlavors = [],
|
||||
isLoading: topLoading,
|
||||
isFetching: topFetching,
|
||||
} = useTopFlavors(selectedCategorySlug || null);
|
||||
const { data: newestFlavors = [], isLoading: newestLoading } = useNewestFlavors();
|
||||
const { data: recentReviews = [], isLoading: recentLoading } = useRecentReviews();
|
||||
const { data: categories = [], isLoading: catLoading } = useCategories();
|
||||
const { data: feedRatings = [] } = useFollowingFeedTop(isLoggedIn && !query);
|
||||
|
||||
if (isLoggedIn) {
|
||||
requests.push(api.get('ratings/feed/'));
|
||||
}
|
||||
|
||||
const results = await Promise.all(requests);
|
||||
|
||||
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]);
|
||||
const allFlavors = searchResults;
|
||||
const loading = query
|
||||
? searchLoading
|
||||
: topLoading || newestLoading || recentLoading || catLoading;
|
||||
const categoryLoading = topFetching && !topLoading;
|
||||
|
||||
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);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
} finally {
|
||||
setCategoryLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isFirstRun.current) {
|
||||
isFirstRun.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
fetchTopFlavors();
|
||||
}, [selectedCategorySlug, query]);
|
||||
setActiveIndex(0);
|
||||
}, [selectedCategorySlug]);
|
||||
|
||||
const handleTabChange = (_event: React.SyntheticEvent, newValue: string) => {
|
||||
setSelectedCategorySlug(newValue);
|
||||
|
||||
@@ -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 {
|
||||
Typography,
|
||||
@@ -26,7 +26,17 @@ import VerifiedIcon from '@mui/icons-material/Verified';
|
||||
import PersonAddIcon from '@mui/icons-material/PersonAdd';
|
||||
import PersonRemoveIcon from '@mui/icons-material/PersonRemove';
|
||||
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 { useTranslation } from 'react-i18next';
|
||||
import RatingBadge from '../components/RatingBadge';
|
||||
@@ -37,46 +47,6 @@ import { PageShell, GlassCard, GlassSurface, FormCard, EmptyState } from '../com
|
||||
import { useToast } from '../hooks/useToast';
|
||||
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 {
|
||||
adminMode?: boolean;
|
||||
}
|
||||
@@ -86,38 +56,18 @@ const PublicProfile: React.FC<PublicProfileProps> = ({ adminMode }) => {
|
||||
const theme = useTheme();
|
||||
const { username } = useParams<{ username: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [data, setData] = useState<ProfileData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const { data, isLoading: loading } = usePublicProfile(username);
|
||||
const { data: currentUser } = useCurrentUserLite();
|
||||
const followToggle = useFollowToggle();
|
||||
const addComment = useAddProfileComment();
|
||||
const deleteCommentMutation = useDeleteProfileComment();
|
||||
const [activeTab, setActiveTab] = useState(0);
|
||||
const [categoryTab, setCategoryTab] = useState(0);
|
||||
const [currentUser, setCurrentUser] = useState<{
|
||||
username: string;
|
||||
is_superuser: boolean;
|
||||
} | null>(null);
|
||||
const [palette, setPalette] = useState<string[]>([]);
|
||||
const [newComment, setNewComment] = useState('');
|
||||
const { notify } = useToast();
|
||||
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(() => {
|
||||
if (data?.avatar) {
|
||||
const img = new Image();
|
||||
@@ -156,13 +106,10 @@ const PublicProfile: React.FC<PublicProfileProps> = ({ adminMode }) => {
|
||||
const handleFollowToggle = async () => {
|
||||
if (!data) return;
|
||||
try {
|
||||
if (data.is_following) {
|
||||
await api.post(`users/${data.id}/unfollow/`);
|
||||
setData({ ...data, is_following: false, followers_count: data.followers_count - 1 });
|
||||
} else {
|
||||
await api.post(`users/${data.id}/follow/`);
|
||||
setData({ ...data, is_following: true, followers_count: data.followers_count + 1 });
|
||||
}
|
||||
await followToggle.mutateAsync({
|
||||
userId: data.id,
|
||||
currentlyFollowing: data.is_following,
|
||||
});
|
||||
} catch {
|
||||
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) => {
|
||||
try {
|
||||
if (user.is_following) await api.post(`users/${user.id}/unfollow/`);
|
||||
else await api.post(`users/${user.id}/follow/`);
|
||||
fetchProfile();
|
||||
await followToggle.mutateAsync({
|
||||
userId: user.id,
|
||||
currentlyFollowing: !!user.is_following,
|
||||
});
|
||||
} catch {
|
||||
notify({ message: 'Failed', severity: 'error' });
|
||||
}
|
||||
@@ -182,9 +130,8 @@ const PublicProfile: React.FC<PublicProfileProps> = ({ adminMode }) => {
|
||||
e.preventDefault();
|
||||
if (!newComment.trim() || !data) return;
|
||||
try {
|
||||
await api.post(`users/${data.id}/add_comment/`, { text: newComment });
|
||||
await addComment.mutateAsync({ userId: data.id, text: newComment });
|
||||
setNewComment('');
|
||||
fetchProfile();
|
||||
} catch {
|
||||
notify({ message: 'Failed to add comment', severity: 'error' });
|
||||
}
|
||||
@@ -194,8 +141,7 @@ const PublicProfile: React.FC<PublicProfileProps> = ({ adminMode }) => {
|
||||
if (!data) return;
|
||||
if (!(await confirm({ message: 'Delete this comment?', danger: true }))) return;
|
||||
try {
|
||||
await api.delete(`users/${data.id}/delete_comment/${commentId}/`);
|
||||
fetchProfile();
|
||||
await deleteCommentMutation.mutateAsync({ userId: data.id, commentId });
|
||||
} catch {
|
||||
notify({ message: 'Failed to delete comment', severity: 'error' });
|
||||
}
|
||||
@@ -218,15 +164,18 @@ const PublicProfile: React.FC<PublicProfileProps> = ({ adminMode }) => {
|
||||
</PageShell>
|
||||
);
|
||||
|
||||
const categoryGroups = data.ratings.reduce<Record<string, Rating[]>>((acc, rating) => {
|
||||
const cat = rating.category_name || 'Other';
|
||||
if (!acc[cat]) acc[cat] = [];
|
||||
acc[cat].push(rating);
|
||||
return acc;
|
||||
}, {});
|
||||
const categoryGroups = data.ratings.reduce<Record<string, PublicProfileRating[]>>(
|
||||
(acc, rating) => {
|
||||
const cat = rating.category_name || 'Other';
|
||||
if (!acc[cat]) acc[cat] = [];
|
||||
acc[cat].push(rating);
|
||||
return acc;
|
||||
},
|
||||
{},
|
||||
);
|
||||
|
||||
const ratingCategories = ['All', ...Object.keys(categoryGroups)];
|
||||
const currentRatings: Rating[] =
|
||||
const currentRatings: PublicProfileRating[] =
|
||||
categoryTab === 0 ? data.ratings : categoryGroups[ratingCategories[categoryTab]];
|
||||
|
||||
const tiers = [
|
||||
|
||||
@@ -1,4 +1,15 @@
|
||||
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 {
|
||||
Typography,
|
||||
Box,
|
||||
@@ -16,7 +27,6 @@ import {
|
||||
} from '@mui/material';
|
||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import api from '../lib/api';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useTitle } from '../hooks/useTitle';
|
||||
import type { CatppuccinTheme } from '../theme';
|
||||
@@ -28,11 +38,6 @@ interface SettingsProps {
|
||||
onThemeChange: (newTheme: CatppuccinTheme) => void;
|
||||
}
|
||||
|
||||
interface Banner {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
const readErr = (err: unknown): string | undefined =>
|
||||
(err as { response?: { data?: { error?: string } } }).response?.data?.error;
|
||||
|
||||
@@ -41,6 +46,16 @@ const Settings: React.FC<SettingsProps> = ({ themeName, onThemeChange }) => {
|
||||
const navigate = useNavigate();
|
||||
useTitle(t('nav.settings'));
|
||||
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 [email, setEmail] = useState('');
|
||||
const [currentEmail, setCurrentEmail] = useState('');
|
||||
@@ -53,7 +68,6 @@ const Settings: React.FC<SettingsProps> = ({ themeName, onThemeChange }) => {
|
||||
const [deletionCode, setDeletionCode] = useState('');
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||
const [banners, setBanners] = useState<Banner[]>([]);
|
||||
const [selectedBannerId, setSelectedBannerId] = useState<number | string>('');
|
||||
|
||||
const handleGoBack = () => {
|
||||
@@ -62,33 +76,14 @@ const Settings: React.FC<SettingsProps> = ({ themeName, onThemeChange }) => {
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const fetchUser = async () => {
|
||||
try {
|
||||
const [userRes, bannersRes] = await Promise.all([
|
||||
api.get('users/me/'),
|
||||
api.get('banners/'),
|
||||
]);
|
||||
|
||||
setUsername(userRes.data.username);
|
||||
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();
|
||||
}, []);
|
||||
if (!me) return;
|
||||
setUsername(me.username);
|
||||
setEmail(me.email);
|
||||
setCurrentEmail(me.email);
|
||||
setAvatar(me.avatar);
|
||||
setSelectedBannerId(me.selected_banner || '');
|
||||
if (me.language) setLanguage(me.language);
|
||||
}, [me]);
|
||||
|
||||
const handleAvatarUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
@@ -99,13 +94,8 @@ const Settings: React.FC<SettingsProps> = ({ themeName, onThemeChange }) => {
|
||||
return;
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('avatar', file);
|
||||
|
||||
try {
|
||||
const res = await api.post('users/update_avatar/', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
});
|
||||
const res = await updateAvatarMutation.mutateAsync(file);
|
||||
setAvatar(res.data.avatar);
|
||||
setMessage({ type: 'success', text: t('settings.avatarSuccess') });
|
||||
} catch (err) {
|
||||
@@ -117,7 +107,7 @@ const Settings: React.FC<SettingsProps> = ({ themeName, onThemeChange }) => {
|
||||
setLanguage(newLang);
|
||||
i18n.changeLanguage(newLang);
|
||||
try {
|
||||
await api.patch('users/update_preferences/', { language: newLang });
|
||||
await updatePrefs.mutateAsync({ language: newLang });
|
||||
} catch {
|
||||
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) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
const res = await api.patch('users/update_profile/', { username, email });
|
||||
const res = await updateProfileMutation.mutateAsync({ username, email });
|
||||
if (res.data.message) {
|
||||
setPendingConfirmation(true);
|
||||
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) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
const res = await api.post('users/confirm_email/', { code: confirmationCode });
|
||||
const res = await confirmEmailMutation.mutateAsync(confirmationCode);
|
||||
setMessage({ type: 'success', text: t('settings.updateSuccess') });
|
||||
setCurrentEmail(res.data.email);
|
||||
setPendingConfirmation(false);
|
||||
@@ -155,7 +145,7 @@ const Settings: React.FC<SettingsProps> = ({ themeName, onThemeChange }) => {
|
||||
const handleChangePassword = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
await api.post('users/change_password/', {
|
||||
await changePasswordMutation.mutateAsync({
|
||||
old_password: oldPassword,
|
||||
new_password: newPassword,
|
||||
});
|
||||
@@ -169,7 +159,7 @@ const Settings: React.FC<SettingsProps> = ({ themeName, onThemeChange }) => {
|
||||
|
||||
const handleRequestDeletion = async () => {
|
||||
try {
|
||||
await api.post('users/request_account_deletion/');
|
||||
await requestDeletion.mutateAsync();
|
||||
setIsDeleting(true);
|
||||
setMessage({ type: 'success', text: t('settings.codeSent') });
|
||||
} catch {
|
||||
@@ -187,7 +177,7 @@ const Settings: React.FC<SettingsProps> = ({ themeName, onThemeChange }) => {
|
||||
});
|
||||
if (!ok) return;
|
||||
try {
|
||||
await api.post('users/confirm_account_deletion/', { code: deletionCode });
|
||||
await confirmDeletion.mutateAsync(deletionCode);
|
||||
localStorage.removeItem('access');
|
||||
localStorage.removeItem('refresh');
|
||||
window.location.href = '/';
|
||||
@@ -281,7 +271,7 @@ const Settings: React.FC<SettingsProps> = ({ themeName, onThemeChange }) => {
|
||||
const newId = e.target.value;
|
||||
setSelectedBannerId(newId);
|
||||
try {
|
||||
await api.patch('users/update_preferences/', { selected_banner: newId });
|
||||
await updatePrefs.mutateAsync({ selected_banner: newId });
|
||||
} catch {
|
||||
console.error('Failed to update banner preference');
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import {
|
||||
Typography,
|
||||
Box,
|
||||
@@ -21,48 +21,21 @@ import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
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 { formatDate } from '../utils/date';
|
||||
import { PageShell, SectionHeader, GlassCard, FormCard, EmptyState } from '../components/ui';
|
||||
import { useToast } from '../hooks/useToast';
|
||||
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 ButtonStatusColor = 'error' | 'warning' | 'success' | 'primary';
|
||||
|
||||
@@ -71,47 +44,36 @@ const Support: React.FC = () => {
|
||||
const theme = useTheme();
|
||||
const navigate = useNavigate();
|
||||
useTitle(t('support.title'));
|
||||
const [tickets, setTickets] = useState<Ticket[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const { data: tickets = [], isLoading: loading } = useTickets();
|
||||
const { data: currentUser = null } = useMe();
|
||||
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 [description, setDescription] = useState('');
|
||||
|
||||
const [replyText, setReplyText] = useState<Record<number, string>>({});
|
||||
const [expandedId, setExpandedId] = useState<number | null>(null);
|
||||
const [readTicketIds, setReadTicketIds] = useState<Set<number>>(new Set());
|
||||
const { notify } = useToast();
|
||||
const { confirm } = useConfirm();
|
||||
const [currentUser, setCurrentUser] = useState<CurrentUser | null>(null);
|
||||
const [unreadTicketIds, setUnreadTicketIds] = useState<Set<number>>(new Set());
|
||||
|
||||
const fetchTickets = useCallback(async () => {
|
||||
try {
|
||||
const [ticketRes, userRes, notifRes] = await Promise.all([
|
||||
api.get('tickets/'),
|
||||
api.get('users/me/'),
|
||||
api.get('notifications/'),
|
||||
]);
|
||||
setTickets(ticketRes.data.results || ticketRes.data);
|
||||
setCurrentUser(userRes.data);
|
||||
|
||||
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);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchTickets();
|
||||
}, [fetchTickets]);
|
||||
const unreadTicketIds = useMemo(() => {
|
||||
const ids = new Set<number>();
|
||||
notifications.forEach((n) => {
|
||||
// Notification carries .reply or .rating in its current shape; tickets
|
||||
// arrive as ticket_new / ticket_reply with the ticket id encoded in
|
||||
// .reply (legacy field name re-purposed by the serializer).
|
||||
const ticketId = (n as unknown as { ticket?: number | null }).ticket;
|
||||
if (!n.is_read && n.notification_type.startsWith('ticket') && ticketId != null) {
|
||||
if (!readTicketIds.has(ticketId)) ids.add(ticketId);
|
||||
}
|
||||
});
|
||||
return ids;
|
||||
}, [notifications, readTicketIds]);
|
||||
|
||||
const handleExpand = (ticketId: number) => {
|
||||
if (expandedId === ticketId) {
|
||||
@@ -120,20 +82,17 @@ const Support: React.FC = () => {
|
||||
}
|
||||
setExpandedId(ticketId);
|
||||
if (unreadTicketIds.has(ticketId)) {
|
||||
const newUnread = new Set(unreadTicketIds);
|
||||
newUnread.delete(ticketId);
|
||||
setUnreadTicketIds(newUnread);
|
||||
setReadTicketIds((prev) => new Set(prev).add(ticketId));
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreate = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
await api.post('tickets/', { subject, description });
|
||||
await createTicketMutation.mutateAsync({ subject, description });
|
||||
setSubject('');
|
||||
setDescription('');
|
||||
setShowCreate(false);
|
||||
fetchTickets();
|
||||
} catch {
|
||||
notify({ message: 'Failed to create ticket', severity: 'error' });
|
||||
}
|
||||
@@ -143,9 +102,8 @@ const Support: React.FC = () => {
|
||||
const text = replyText[ticketId];
|
||||
if (!text) return;
|
||||
try {
|
||||
await api.post(`tickets/${ticketId}/add_message/`, { text });
|
||||
await addMessage.mutateAsync({ ticketId, text });
|
||||
setReplyText({ ...replyText, [ticketId]: '' });
|
||||
fetchTickets();
|
||||
} catch {
|
||||
notify({ message: 'Failed to send message', severity: 'error' });
|
||||
}
|
||||
@@ -153,8 +111,7 @@ const Support: React.FC = () => {
|
||||
|
||||
const handleUpdateStatus = async (ticketId: number, status: string) => {
|
||||
try {
|
||||
await api.post(`tickets/${ticketId}/update_status/`, { status });
|
||||
fetchTickets();
|
||||
await updateStatusMutation.mutateAsync({ ticketId, status });
|
||||
} catch {
|
||||
notify({ message: 'Failed to update status', severity: 'error' });
|
||||
}
|
||||
@@ -163,8 +120,7 @@ const Support: React.FC = () => {
|
||||
const handleDeleteTicket = async (ticketId: number) => {
|
||||
if (!(await confirm({ message: 'Delete this ticket?', danger: true }))) return;
|
||||
try {
|
||||
await api.delete(`tickets/${ticketId}/`);
|
||||
fetchTickets();
|
||||
await deleteTicketMutation.mutateAsync(ticketId);
|
||||
} catch {
|
||||
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 { Box, CircularProgress, Button } from '@mui/material';
|
||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||
import api from '../../lib/api';
|
||||
import { useTitle } from '../../hooks/useTitle';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useCategoryFlavors } from '../../api/queries/useCategoryFlavors';
|
||||
import {
|
||||
PageShell,
|
||||
HeroBackdrop,
|
||||
@@ -13,35 +13,12 @@ import {
|
||||
EmptyState,
|
||||
} 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 { t } = useTranslation();
|
||||
const { slug } = useParams<{ slug: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [flavors, setFlavors] = useState<Flavor[]>([]);
|
||||
const [categoryName, setCategoryName] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const { data: flavors = [], isLoading: loading } = useCategoryFlavors(slug);
|
||||
const categoryName = flavors[0]?.category_name || '';
|
||||
|
||||
const handleGoBack = () => {
|
||||
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');
|
||||
|
||||
if (loading)
|
||||
|
||||
@@ -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 { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
@@ -20,7 +20,17 @@ import {
|
||||
Switch,
|
||||
Divider,
|
||||
} 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 ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||
import ShoppingCartIcon from '@mui/icons-material/ShoppingCart';
|
||||
@@ -38,38 +48,6 @@ import { PageShell, HeroBackdrop, GlassCard, SectionHeader, EmptyState } from '.
|
||||
import { useToast } from '../../hooks/useToast';
|
||||
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 {
|
||||
adminMode?: boolean;
|
||||
}
|
||||
@@ -81,11 +59,18 @@ const FlavorDetail: React.FC<FlavorDetailProps> = ({ adminMode }) => {
|
||||
const navigate = useNavigate();
|
||||
const { notify } = useToast();
|
||||
const { confirm } = useConfirm();
|
||||
const [flavor, setFlavor] = useState<Flavor | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const { data: flavor, isLoading: loading } = useFlavorDetail(id);
|
||||
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 [expandedReplies, setExpandedReplies] = useState<{ [key: number]: boolean }>({});
|
||||
const [currentUser, setCurrentUser] = useState<string | null>(null);
|
||||
|
||||
// Product Edit state (Admin)
|
||||
const [isAdminEditing, setIsAdminEditing] = useState(false);
|
||||
@@ -111,44 +96,25 @@ const FlavorDetail: React.FC<FlavorDetailProps> = ({ adminMode }) => {
|
||||
const [editingReplyId, setEditingReplyId] = useState<number | null>(null);
|
||||
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(() => {
|
||||
const getUser = async () => {
|
||||
try {
|
||||
const res = await api.get('users/me/');
|
||||
setCurrentUser(res.data.username);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
};
|
||||
getUser();
|
||||
fetchFlavor();
|
||||
}, [fetchFlavor]);
|
||||
if (flavor) {
|
||||
setEditFlavorData({
|
||||
name: flavor.name,
|
||||
description: flavor.description,
|
||||
shop_url: flavor.shop_url || '',
|
||||
is_available: flavor.is_available,
|
||||
is_legacy: flavor.is_legacy,
|
||||
});
|
||||
}
|
||||
}, [flavor]);
|
||||
|
||||
useTitle(flavor?.name || t('common.loading'));
|
||||
|
||||
const handleAdminUpdate = async () => {
|
||||
if (!id) return;
|
||||
try {
|
||||
await api.patch(`flavors/${id}/`, editFlavorData);
|
||||
await updateFlavor.mutateAsync({ id, data: editFlavorData });
|
||||
setIsAdminEditing(false);
|
||||
fetchFlavor();
|
||||
} catch {
|
||||
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 file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
if (!file || !id) return;
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('image', file);
|
||||
|
||||
try {
|
||||
setUploadingImage(true);
|
||||
await api.patch(`flavors/${id}/`, formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
});
|
||||
fetchFlavor();
|
||||
await updateFlavor.mutateAsync({ id, data: formData, isFormData: true });
|
||||
} catch {
|
||||
notify({ message: 'Image upload failed', severity: 'error' });
|
||||
} finally {
|
||||
@@ -180,11 +143,11 @@ const FlavorDetail: React.FC<FlavorDetailProps> = ({ adminMode }) => {
|
||||
notify({ message: 'Please select a score', severity: 'warning' });
|
||||
return;
|
||||
}
|
||||
if (!flavor) return;
|
||||
try {
|
||||
await api.post('ratings/', { flavor: flavor?.id, score: newScore, comment: newComment });
|
||||
await createRating.mutateAsync({ flavor: flavor.id, score: newScore, comment: newComment });
|
||||
setNewScore(null);
|
||||
setNewComment('');
|
||||
fetchFlavor();
|
||||
} catch (err) {
|
||||
const msg = (err as { response?: { data?: { error?: string } } }).response?.data?.error;
|
||||
notify({ message: msg || 'Failed to submit rating', severity: 'error' });
|
||||
@@ -195,10 +158,9 @@ const FlavorDetail: React.FC<FlavorDetailProps> = ({ adminMode }) => {
|
||||
const text = replyInputs[ratingId];
|
||||
if (!text) return;
|
||||
try {
|
||||
await api.post(`ratings/${ratingId}/reply/`, { text });
|
||||
await createReply.mutateAsync({ ratingId, text });
|
||||
setReplyInputs({ ...replyInputs, [ratingId]: '' });
|
||||
setExpandedReplies((prev) => ({ ...prev, [ratingId]: true }));
|
||||
fetchFlavor();
|
||||
} catch {
|
||||
notify({ message: 'Failed to submit reply', severity: 'error' });
|
||||
}
|
||||
@@ -207,9 +169,8 @@ const FlavorDetail: React.FC<FlavorDetailProps> = ({ adminMode }) => {
|
||||
const handleUpdateReply = async (replyId: number) => {
|
||||
if (!editReplyText) return;
|
||||
try {
|
||||
await api.patch(`replies/${replyId}/`, { text: editReplyText });
|
||||
await updateReply.mutateAsync({ replyId, text: editReplyText });
|
||||
setEditingReplyId(null);
|
||||
fetchFlavor();
|
||||
} catch {
|
||||
notify({ message: 'Failed to update reply', severity: 'error' });
|
||||
}
|
||||
@@ -218,8 +179,7 @@ const FlavorDetail: React.FC<FlavorDetailProps> = ({ adminMode }) => {
|
||||
const handleDeleteReply = async (replyId: number) => {
|
||||
if (!(await confirm({ message: 'Delete this reply?', danger: true }))) return;
|
||||
try {
|
||||
await api.delete(`replies/${replyId}/`);
|
||||
fetchFlavor();
|
||||
await deleteReplyMutation.mutateAsync(replyId);
|
||||
} catch {
|
||||
notify({ message: 'Failed to delete reply', severity: 'error' });
|
||||
}
|
||||
@@ -228,14 +188,13 @@ const FlavorDetail: React.FC<FlavorDetailProps> = ({ adminMode }) => {
|
||||
const handleDeleteRating = async (ratingId: number) => {
|
||||
if (!(await confirm({ message: 'Delete this review?', danger: true }))) return;
|
||||
try {
|
||||
await api.delete(`ratings/${ratingId}/`);
|
||||
fetchFlavor();
|
||||
await deleteRatingMutation.mutateAsync(ratingId);
|
||||
} catch {
|
||||
notify({ message: 'Failed to delete review', severity: 'error' });
|
||||
}
|
||||
};
|
||||
|
||||
const startEdit = (rating: Rating) => {
|
||||
const startEdit = (rating: FlavorDetailRating) => {
|
||||
setEditMode(rating.id);
|
||||
setEditScore(rating.score);
|
||||
setEditComment(rating.comment || '');
|
||||
@@ -243,9 +202,8 @@ const FlavorDetail: React.FC<FlavorDetailProps> = ({ adminMode }) => {
|
||||
|
||||
const handleUpdateRating = async (ratingId: number) => {
|
||||
try {
|
||||
await api.patch(`ratings/${ratingId}/`, { score: editScore, comment: editComment });
|
||||
await updateRating.mutateAsync({ id: ratingId, score: editScore, comment: editComment });
|
||||
setEditMode(null);
|
||||
fetchFlavor();
|
||||
} catch {
|
||||
notify({ message: 'Failed to update review', severity: 'error' });
|
||||
}
|
||||
@@ -544,7 +502,7 @@ const FlavorDetail: React.FC<FlavorDetailProps> = ({ adminMode }) => {
|
||||
{flavor.ratings.length === 0 ? (
|
||||
<EmptyState title={t('dashboard.noRatings')} />
|
||||
) : (
|
||||
flavor.ratings.map((rating: Rating) => (
|
||||
flavor.ratings.map((rating: FlavorDetailRating) => (
|
||||
<GlassCard key={rating.id}>
|
||||
<CardContent sx={{ p: 3 }}>
|
||||
{editMode === rating.id ? (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
Reference in new issue
Block a user