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