diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 36b503e..984c44c 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -13,6 +13,8 @@ "@emotion/styled": "^11.14.1", "@mui/icons-material": "^7.3.8", "@mui/material": "^7.3.8", + "@tanstack/react-query": "^5.100.3", + "@tanstack/react-query-devtools": "^5.100.3", "axios": "^1.13.5", "colorthief": "^2.6.0", "i18next": "^24.2.2", @@ -2400,6 +2402,59 @@ "dev": true, "license": "MIT" }, + "node_modules/@tanstack/query-core": { + "version": "5.100.3", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.100.3.tgz", + "integrity": "sha512-oMO1imV4qStH+GqddafkI7Q7r2ktPL7/0Mu74W1XEhfHHd3oTIrwP3OOIsbtpnnbe8y/IU+8Lm7Bi2LlMhVdNA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/query-devtools": { + "version": "5.100.3", + "resolved": "https://registry.npmjs.org/@tanstack/query-devtools/-/query-devtools-5.100.3.tgz", + "integrity": "sha512-RfLRX0R9yXYNbtt1OIIN4mObzTT8/jteljgioTMwSKRw6C84xrLjnDRIyIzMhUZxbCJHrgkXiD9IA8owiYFA9A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.100.3", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.100.3.tgz", + "integrity": "sha512-8Fgb4vKmBHllRHUjz3ZOwgV0v9b7cxCdN5T0iFQvvWJJVs6xvaxHERO1BclTL03bbK8vZAuXVKN3IeVS1sUdeQ==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.100.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, + "node_modules/@tanstack/react-query-devtools": { + "version": "5.100.3", + "resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-5.100.3.tgz", + "integrity": "sha512-0h2rTSS8mwLDL5m7d2seKs4Gg3XShGiLXBfSDDYNrbJcxX6AHuiqbg9xBi2bWCNyhq0dGRIMay3sE6xn1SSCOQ==", + "license": "MIT", + "dependencies": { + "@tanstack/query-devtools": "5.100.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "@tanstack/react-query": "^5.100.3", + "react": "^18 || ^19" + } + }, "node_modules/@testing-library/dom": { "version": "10.4.1", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index d566e52..256261c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -20,6 +20,8 @@ "@emotion/styled": "^11.14.1", "@mui/icons-material": "^7.3.8", "@mui/material": "^7.3.8", + "@tanstack/react-query": "^5.100.3", + "@tanstack/react-query-devtools": "^5.100.3", "axios": "^1.13.5", "colorthief": "^2.6.0", "i18next": "^24.2.2", diff --git a/frontend/src/api/keys.ts b/frontend/src/api/keys.ts new file mode 100644 index 0000000..c4b48af --- /dev/null +++ b/frontend/src/api/keys.ts @@ -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) => ['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) => ['communityFeed', params] as const, +}; diff --git a/frontend/src/api/mutations/useAdminMutations.ts b/frontend/src/api/mutations/useAdminMutations.ts new file mode 100644 index 0000000..3f7ad43 --- /dev/null +++ b/frontend/src/api/mutations/useAdminMutations.ts @@ -0,0 +1,91 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import api from '../../lib/api'; + +const invalidateAdmin = (qc: ReturnType) => { + 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 }) => + 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); + }, + }); +}; diff --git a/frontend/src/api/mutations/useFlavorMutations.ts b/frontend/src/api/mutations/useFlavorMutations.ts new file mode 100644 index 0000000..08a55f6 --- /dev/null +++ b/frontend/src/api/mutations/useFlavorMutations.ts @@ -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 | 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() }); + }, + }); +}; diff --git a/frontend/src/api/mutations/useMarkNotifications.ts b/frontend/src/api/mutations/useMarkNotifications.ts new file mode 100644 index 0000000..4cb455f --- /dev/null +++ b/frontend/src/api/mutations/useMarkNotifications.ts @@ -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(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(queryKeys.notifications(), (old) => + old?.map((n) => (n.id === id ? { ...n, is_read: true } : n)), + ); + }, + }); +}; diff --git a/frontend/src/api/mutations/useRatingMutations.ts b/frontend/src/api/mutations/useRatingMutations.ts new file mode 100644 index 0000000..3531ed1 --- /dev/null +++ b/frontend/src/api/mutations/useRatingMutations.ts @@ -0,0 +1,70 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import api from '../../lib/api'; +import { queryKeys } from '../keys'; + +const invalidateRatingViews = (qc: ReturnType) => { + 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), + }); +}; diff --git a/frontend/src/api/mutations/useReplyMutations.ts b/frontend/src/api/mutations/useReplyMutations.ts new file mode 100644 index 0000000..3eeb138 --- /dev/null +++ b/frontend/src/api/mutations/useReplyMutations.ts @@ -0,0 +1,20 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import api from '../../lib/api'; +import { queryKeys } from '../keys'; + +const invalidateRatingViews = (qc: ReturnType) => { + 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), + }); +}; diff --git a/frontend/src/api/mutations/useSettingsMutations.ts b/frontend/src/api/mutations/useSettingsMutations.ts new file mode 100644 index 0000000..d9ce20b --- /dev/null +++ b/frontend/src/api/mutations/useSettingsMutations.ts @@ -0,0 +1,62 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import api from '../../lib/api'; + +const invalidateMe = (qc: ReturnType) => { + 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) => 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 }), + }); diff --git a/frontend/src/api/mutations/useSocialMutations.ts b/frontend/src/api/mutations/useSocialMutations.ts new file mode 100644 index 0000000..d5dd8e5 --- /dev/null +++ b/frontend/src/api/mutations/useSocialMutations.ts @@ -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() }); + }, + }); +}; diff --git a/frontend/src/api/mutations/useTicketMutations.ts b/frontend/src/api/mutations/useTicketMutations.ts new file mode 100644 index 0000000..27a0453 --- /dev/null +++ b/frontend/src/api/mutations/useTicketMutations.ts @@ -0,0 +1,40 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import api from '../../lib/api'; + +const invalidateTickets = (qc: ReturnType) => { + 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), + }); +}; diff --git a/frontend/src/api/queries/useActiveBanner.ts b/frontend/src/api/queries/useActiveBanner.ts new file mode 100644 index 0000000..cb62cc0 --- /dev/null +++ b/frontend/src/api/queries/useActiveBanner.ts @@ -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('banners/active/', { params: { username } }); + return res.data; + }, + enabled: !!username, + staleTime: 5 * 60_000, + }); diff --git a/frontend/src/api/queries/useAdminQueries.ts b/frontend/src/api/queries/useAdminQueries.ts new file mode 100644 index 0000000..6a609a1 --- /dev/null +++ b/frontend/src/api/queries/useAdminQueries.ts @@ -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(`ratings/${id}/`); + return res.data; + }, + enabled: !!id, + }); + +export const useAdminReply = (id: string | undefined) => + useQuery({ + queryKey: ['adminReply', id], + queryFn: async () => { + const res = await api.get(`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('admin-custom/stats/')).data, + }); + +export const useAdminUsers = () => + useQuery({ + queryKey: ['adminUsers'], + queryFn: async () => (await api.get('admin-custom/users/')).data, + }); + +export const useAdminJobs = () => + useQuery({ + queryKey: ['adminJobs'], + queryFn: async () => (await api.get('admin-custom/jobs/')).data, + refetchInterval: 30_000, + }); + +export const useAdminConfig = () => + useQuery({ + queryKey: ['adminConfig'], + queryFn: async () => (await api.get('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(`admin-custom/${id}/user_detail/`)).data, + enabled: !!id, + }); diff --git a/frontend/src/api/queries/useBanners.ts b/frontend/src/api/queries/useBanners.ts new file mode 100644 index 0000000..03c29d8 --- /dev/null +++ b/frontend/src/api/queries/useBanners.ts @@ -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('banners/'); + return Array.isArray(res.data) ? res.data : (res.data.results ?? []); + }, + staleTime: 5 * 60_000, + }); diff --git a/frontend/src/api/queries/useCategoryFlavors.ts b/frontend/src/api/queries/useCategoryFlavors.ts new file mode 100644 index 0000000..913419e --- /dev/null +++ b/frontend/src/api/queries/useCategoryFlavors.ts @@ -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( + `flavors/?category__slug=${slug}`, + ); + const data = res.data; + return Array.isArray(data) ? data : (data.results ?? []); + }, + enabled: !!slug, + }); diff --git a/frontend/src/api/queries/useCommunityFeed.ts b/frontend/src/api/queries/useCommunityFeed.ts new file mode 100644 index 0000000..19bb798 --- /dev/null +++ b/frontend/src/api/queries/useCommunityFeed.ts @@ -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 => { + 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( + '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('flavors/followed_top/'); + return Array.isArray(res.data) ? res.data.slice(0, 5) : []; + }, + }); diff --git a/frontend/src/api/queries/useCurrentUserLite.ts b/frontend/src/api/queries/useCurrentUserLite.ts new file mode 100644 index 0000000..d94412e --- /dev/null +++ b/frontend/src/api/queries/useCurrentUserLite.ts @@ -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('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, + }); diff --git a/frontend/src/api/queries/useDashboard.ts b/frontend/src/api/queries/useDashboard.ts new file mode 100644 index 0000000..16a7229 --- /dev/null +++ b/frontend/src/api/queries/useDashboard.ts @@ -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('users/dashboard/'); + return res.data; + }, + }); diff --git a/frontend/src/api/queries/useFlavorDetail.ts b/frontend/src/api/queries/useFlavorDetail.ts new file mode 100644 index 0000000..e1ce6b5 --- /dev/null +++ b/frontend/src/api/queries/useFlavorDetail.ts @@ -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(`flavors/${id}/`); + return res.data; + }, + enabled: !!id, + }); diff --git a/frontend/src/api/queries/useFlavorsList.ts b/frontend/src/api/queries/useFlavorsList.ts new file mode 100644 index 0000000..3640205 --- /dev/null +++ b/frontend/src/api/queries/useFlavorsList.ts @@ -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('flavors/'); + return res.data; + }, + }); + +export const useCategories = () => + useQuery({ + queryKey: ['categories'], + queryFn: async () => { + const res = await api.get('categories/'); + return res.data; + }, + staleTime: 5 * 60_000, + }); diff --git a/frontend/src/api/queries/useMainPageQueries.ts b/frontend/src/api/queries/useMainPageQueries.ts new file mode 100644 index 0000000..d6f7bc1 --- /dev/null +++ b/frontend/src/api/queries/useMainPageQueries.ts @@ -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 = (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(url); + return unwrap(res.data); + }, + }); + +export const useNewestFlavors = () => + useQuery({ + queryKey: queryKeys.flavors({ scope: 'newest' }), + queryFn: async () => { + const res = await api.get( + 'flavors/newest/', + ); + return unwrap(res.data); + }, + }); + +export const useRecentReviews = () => + useQuery({ + queryKey: ['recentReviews'], + queryFn: async () => { + const res = await api.get( + 'ratings/recent/', + ); + return unwrap(res.data); + }, + }); + +export const useFollowingFeedTop = (enabled: boolean) => + useQuery({ + queryKey: ['followingFeedTop'], + queryFn: async () => { + const res = await api.get('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( + `flavors/?search=${encodeURIComponent(query)}`, + ); + return unwrap(res.data); + }, + enabled: !!query, + }); diff --git a/frontend/src/api/queries/useMe.ts b/frontend/src/api/queries/useMe.ts new file mode 100644 index 0000000..9f4acf2 --- /dev/null +++ b/frontend/src/api/queries/useMe.ts @@ -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('users/me/', { + ...({ skipAuthRedirect: true } as object), + }); + return res.data; + }, + enabled: !!localStorage.getItem('access'), + }); diff --git a/frontend/src/api/queries/useNotifications.ts b/frontend/src/api/queries/useNotifications.ts new file mode 100644 index 0000000..2271f56 --- /dev/null +++ b/frontend/src/api/queries/useNotifications.ts @@ -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, + }); diff --git a/frontend/src/api/queries/usePublicProfile.ts b/frontend/src/api/queries/usePublicProfile.ts new file mode 100644 index 0000000..6c7c4ac --- /dev/null +++ b/frontend/src/api/queries/usePublicProfile.ts @@ -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(`users/profile/${username}/`); + return res.data; + }, + enabled: !!username, + }); diff --git a/frontend/src/api/queries/useTickets.ts b/frontend/src/api/queries/useTickets.ts new file mode 100644 index 0000000..9c61f76 --- /dev/null +++ b/frontend/src/api/queries/useTickets.ts @@ -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('tickets/'); + return Array.isArray(res.data) ? res.data : (res.data.results ?? []); + }, + }); diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts new file mode 100644 index 0000000..7e02316 --- /dev/null +++ b/frontend/src/api/types.ts @@ -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 | null; +} + +export interface NotificationItem { + id: number; + verb: string; + actor: string; + target: string | null; + url: string | null; + is_read: boolean; + created_at: string; +} diff --git a/frontend/src/app/providers.tsx b/frontend/src/app/providers.tsx index 8fb003d..d777edf 100644 --- a/frontend/src/app/providers.tsx +++ b/frontend/src/app/providers.tsx @@ -1,19 +1,25 @@ import type { ReactNode } from 'react'; +import { QueryClientProvider } from '@tanstack/react-query'; +import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; import { ThemeProvider } from '../contexts/ThemeContext'; import { AuthProvider } from '../contexts/AuthContext'; import { NotificationProvider } from '../contexts/NotificationContext'; import { ToastProvider } from '../contexts/ToastContext'; import { ConfirmProvider } from '../contexts/ConfirmContext'; +import { queryClient } from './queryClient'; import '../i18n'; export const AppProviders = ({ children }: { children: ReactNode }) => ( - - - - - {children} - - - - + + + + + + {children} + + + + + {import.meta.env.DEV && } + ); diff --git a/frontend/src/app/queryClient.ts b/frontend/src/app/queryClient.ts new file mode 100644 index 0000000..e46cd2b --- /dev/null +++ b/frontend/src/app/queryClient.ts @@ -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 }, + }, +}); diff --git a/frontend/src/components/DynamicBanner.tsx b/frontend/src/components/DynamicBanner.tsx index 3c7d9ed..4a5717e 100644 --- a/frontend/src/components/DynamicBanner.tsx +++ b/frontend/src/components/DynamicBanner.tsx @@ -1,21 +1,16 @@ -import React, { useEffect, useState } from 'react'; -import api from '../lib/api'; -import GenerativeBanner from './GenerativeBanner'; -import NebulaBanner from './NebulaBanner'; -import FirefliesBanner from './FirefliesBanner'; -import HextechBanner from './HextechBanner'; -import HextechCorruptionBanner from './HextechCorruptionBanner'; -import GenerativeFlowBanner from './GenerativeFlowBanner'; -import { BannerPerformanceWrapper } from './BannerPerformanceWrapper'; - -interface Banner { - id: number; - name: string; - slug: string; - description: string; - is_active: boolean; - settings: any; -} +import React from 'react'; +import { useActiveBanner } from '../api/queries/useActiveBanner'; +import type { BannerConfig } from '../api/types'; +import { + BannerPerformanceWrapper, + FirefliesBanner, + GenerativeBanner, + GenerativeFlowBanner, + HextechBanner, + HextechCorruptionBanner, + MatrixBanner, + NebulaBanner, +} from './banners'; interface DynamicBannerProps { username: string; @@ -24,90 +19,29 @@ interface DynamicBannerProps { followersCount: number; } -const MatrixBanner: React.FC = ({ - username, - settings, -}) => { - const [durations] = useState(() => Array.from({ length: 20 }, () => 2 + Math.random() * 3)); - return ( -
-
- {durations.map((dur, i) => ( -
- {username.split('').map((char, j) => ( - - {char} - - ))} -
- ))} -
- -
- ); -}; - -const pickBanner = (banner: Banner | null, props: DynamicBannerProps): React.ReactElement => { +const pickBanner = ( + banner: BannerConfig | null | undefined, + props: DynamicBannerProps, +): React.ReactElement => { + const settings = banner?.settings ?? undefined; if (!banner || banner.slug === 'generative-web') { - return ; + return ; } - if (banner.slug === 'matrix') return ; - if (banner.slug === 'nebula') return ; - if (banner.slug === 'fireflies') return ; - if (banner.slug === 'hextech') return ; + if (banner.slug === 'matrix') return ; + if (banner.slug === 'nebula') return ; + if (banner.slug === 'fireflies') return ; + if (banner.slug === 'hextech') return ; if (banner.slug === 'hextech-corruption') - return ; + return ; if (banner.slug === 'generative-flow') - return ; + return ; return ; }; const DynamicBanner: React.FC = (props) => { - const [banner, setBanner] = useState(null); - const [loading, setLoading] = useState(true); + const { data: banner, isLoading } = useActiveBanner(props.username); - useEffect(() => { - const fetchActiveBanner = async () => { - try { - const res = await api.get('banners/active/', { - params: { username: props.username }, - }); - setBanner(res.data); - } catch (err) { - console.error('Failed to fetch active banner:', err); - } finally { - setLoading(false); - } - }; - - fetchActiveBanner(); - }, [props.username]); - - if (loading) return null; + if (isLoading) return null; return {pickBanner(banner, props)}; }; diff --git a/frontend/src/components/GenerativeBanner.tsx b/frontend/src/components/GenerativeBanner.tsx deleted file mode 100644 index 133bc28..0000000 --- a/frontend/src/components/GenerativeBanner.tsx +++ /dev/null @@ -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 = ({ - username, - palette, - ratingsCount, - followersCount, - settings, -}) => { - const canvasRef = useRef(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 ( - - ); -}; - -export default GenerativeBanner; diff --git a/frontend/src/components/HextechBanner.tsx b/frontend/src/components/HextechBanner.tsx deleted file mode 100644 index ebcfe64..0000000 --- a/frontend/src/components/HextechBanner.tsx +++ /dev/null @@ -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 = ({ username, palette, settings }) => { - const canvasRef = useRef(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 ( - - ); -}; - -export default HextechBanner; diff --git a/frontend/src/components/HextechCorruptionBanner.tsx b/frontend/src/components/HextechCorruptionBanner.tsx deleted file mode 100644 index 9ff39ad..0000000 --- a/frontend/src/components/HextechCorruptionBanner.tsx +++ /dev/null @@ -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 = ({ - username, - palette, - settings, -}) => { - const canvasRef = useRef(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 ( - - ); -}; - -export default HextechCorruptionBanner; diff --git a/frontend/src/components/NebulaBanner.tsx b/frontend/src/components/NebulaBanner.tsx deleted file mode 100644 index b6cc25b..0000000 --- a/frontend/src/components/NebulaBanner.tsx +++ /dev/null @@ -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 = ({ - username, - palette, - ratingsCount, - followersCount, - settings, -}) => { - const canvasRef = useRef(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 ( - - ); -}; - -export default NebulaBanner; diff --git a/frontend/src/components/BannerPerformanceWrapper.test.tsx b/frontend/src/components/banners/BannerPerformanceWrapper.test.tsx similarity index 100% rename from frontend/src/components/BannerPerformanceWrapper.test.tsx rename to frontend/src/components/banners/BannerPerformanceWrapper.test.tsx diff --git a/frontend/src/components/BannerPerformanceWrapper.tsx b/frontend/src/components/banners/BannerPerformanceWrapper.tsx similarity index 100% rename from frontend/src/components/BannerPerformanceWrapper.tsx rename to frontend/src/components/banners/BannerPerformanceWrapper.tsx diff --git a/frontend/src/components/FirefliesBanner.tsx b/frontend/src/components/banners/FirefliesBanner.tsx similarity index 51% rename from frontend/src/components/FirefliesBanner.tsx rename to frontend/src/components/banners/FirefliesBanner.tsx index d6bde73..71b66a8 100644 --- a/frontend/src/components/FirefliesBanner.tsx +++ b/frontend/src/components/banners/FirefliesBanner.tsx @@ -1,6 +1,6 @@ -import React, { useRef, useEffect, useMemo } from 'react'; +import React, { useMemo } from 'react'; import { alpha, useTheme } from '@mui/material'; -import { useBannerFrameGate } from './BannerPerformanceWrapper'; +import { CANVAS_STYLE, useCanvasBanner } from './useCanvasBanner'; interface FirefliesBannerProps { username: string; @@ -17,27 +17,37 @@ interface FirefliesBannerProps { }; } +interface Firefly { + x: number; + y: number; + vx: number; + vy: number; + size: number; + color: string; + phase: number; + noiseOffset: number; + isEvading: boolean; +} + +const hashString = (str: string): number => { + let hash = 0; + for (let i = 0; i < str.length; i++) { + hash = str.charCodeAt(i) + ((hash << 5) - hash); + } + return hash; +}; + const FirefliesBanner: React.FC = ({ username, palette, settings }) => { - const canvasRef = useRef(null); const theme = useTheme(); - const gate = useBannerFrameGate(); const count = settings?.count ?? 150; - const baseSpeed = settings?.speed ?? 0.8; // Slower default cruise + const baseSpeed = settings?.speed ?? 0.8; const evasionRadius = settings?.evasionRadius ?? 180; - const evasionStrength = settings?.evasionStrength ?? 0.6; // High evasion + const evasionStrength = settings?.evasionStrength ?? 0.6; const glowSizeMultiplier = settings?.glowSize ?? 4; const flickerSpeed = settings?.flickerSpeed ?? 0.05; - const getSeed = (str: string) => { - let hash = 0; - for (let i = 0; i < str.length; i++) { - hash = str.charCodeAt(i) + ((hash << 5) - hash); - } - return hash; - }; - - const seed = useMemo(() => getSeed(username), [username]); + const seed = useMemo(() => hashString(username), [username]); const brightColors = useMemo(() => { const colors = @@ -54,38 +64,15 @@ const FirefliesBanner: React.FC = ({ username, palette, se return sorted.slice(0, 3); }, [palette, theme]); - useEffect(() => { - const canvas = canvasRef.current; - if (!canvas) return; - - const ctx = canvas.getContext('2d', { alpha: true }); - if (!ctx) return; - - let animationFrameId: number; - - interface Firefly { - x: number; - y: number; - vx: number; - vy: number; - size: number; - color: string; - phase: number; - noiseOffset: number; - isEvading: boolean; - } - - let swarm: Firefly[] = []; - - const pseudoRandom = (offset: number) => { - const x = Math.sin(seed + offset) * 10000; - return x - Math.floor(x); - }; - - const initSwarm = (width: number, height: number) => { - const newSwarm = []; + const ref = useCanvasBanner<{ swarm: Firefly[]; time: number }>({ + init: (width, height) => { + const pseudoRandom = (offset: number) => { + const x = Math.sin(seed + offset) * 10000; + return x - Math.floor(x); + }; + const swarm: Firefly[] = []; for (let i = 0; i < count; i++) { - newSwarm.push({ + swarm.push({ x: pseudoRandom(i) * width, y: pseudoRandom(i + 500) * height, vx: (pseudoRandom(i + 1000) - 0.5) * baseSpeed, @@ -97,60 +84,20 @@ const FirefliesBanner: React.FC = ({ username, palette, se isEvading: false, }); } - return newSwarm; - }; + return { swarm, time: 0 }; + }, + draw: ({ ctx, width, height, state, mouse }) => { + const { swarm } = state; + state.time += flickerSpeed; + const time = state.time; - const resize = () => { - const parent = canvas.parentElement; - if (parent) { - const rect = parent.getBoundingClientRect(); - const dpr = window.devicePixelRatio || 1; - canvas.width = rect.width * dpr; - canvas.height = rect.height * dpr; - ctx.scale(dpr, dpr); - canvas.style.width = `${rect.width}px`; - canvas.style.height = `${rect.height}px`; - swarm = initSwarm(rect.width, rect.height); - } - }; - - const resizeObserver = new ResizeObserver(() => resize()); - if (canvas.parentElement) resizeObserver.observe(canvas.parentElement); - resize(); - - const mouse = { x: -1000, y: -1000 }; - const handleMouseMove = (e: MouseEvent) => { - const rect = canvas.getBoundingClientRect(); - const x = e.clientX - rect.left; - const y = e.clientY - rect.top; - if (x < 0 || y < 0 || x > rect.width || y > rect.height) { - mouse.x = -1000; - mouse.y = -1000; - } else { - mouse.x = x; - mouse.y = y; - } - }; - window.addEventListener('mousemove', handleMouseMove); - - let time = 0; - const draw = (now = 0) => { - const decision = gate(now); - if (decision === 'halt') return; - if (decision === 'skip') { - animationFrameId = requestAnimationFrame(draw); - return; - } - const width = canvas.width / (window.devicePixelRatio || 1); - const height = canvas.height / (window.devicePixelRatio || 1); - if (!width || !height) { - animationFrameId = requestAnimationFrame(draw); - return; - } + const pseudoRandom = (offset: number) => { + const x = Math.sin(seed + offset) * 10000; + return x - Math.floor(x); + }; ctx.clearRect(0, 0, width, height); - // Vibrant theme-aware night sky background const isLight = theme.palette.mode === 'light'; const bgGrad = ctx.createRadialGradient( width / 2, @@ -161,32 +108,25 @@ const FirefliesBanner: React.FC = ({ username, palette, se width, ); if (isLight) { - // For light themes, we still need a "dusk/night" feel for fireflies to make sense. - // We use a deeper, more saturated version of the theme's colors. bgGrad.addColorStop(0, alpha(brightColors[2] || '#1a1a2e', 0.85)); bgGrad.addColorStop(1, alpha(brightColors[2] || '#16213e', 1.0)); } else { - bgGrad.addColorStop(0, '#141a2a'); // Noticeable blue-grey tint + bgGrad.addColorStop(0, '#141a2a'); bgGrad.addColorStop(1, '#05080f'); } ctx.fillStyle = bgGrad; ctx.fillRect(0, 0, width, height); - time += flickerSpeed; - swarm.forEach((f, i) => { - // 1. Autonomous Chaotic Movement (Perlin + Jitter) const driftX = Math.sin(time * 0.5 + f.noiseOffset) * 0.05; const driftY = Math.cos(time * 0.4 + f.noiseOffset) * 0.05; - // High-frequency jitter for insect feel const jitterX = (pseudoRandom(i + time * 10) - 0.5) * 0.2; const jitterY = (pseudoRandom(i + 500 + time * 10) - 0.5) * 0.2; f.vx += driftX + jitterX; f.vy += driftY + jitterY; - // 2. Mouse Evasion (Fast Dash) const dx = mouse.x - f.x; const dy = mouse.y - f.y; const dist = Math.sqrt(dx * dx + dy * dy); @@ -195,12 +135,10 @@ const FirefliesBanner: React.FC = ({ username, palette, se if (f.isEvading) { const force = (evasionRadius - dist) / evasionRadius; const angle = Math.atan2(dy, dx); - // Sharp acceleration away f.vx -= Math.cos(angle) * force * evasionStrength * 1.5; f.vy -= Math.sin(angle) * force * evasionStrength * 1.5; } - // 3. Dynamic Speed Limits const currentSpeed = Math.sqrt(f.vx * f.vx + f.vy * f.vy); const maxSpeed = f.isEvading ? baseSpeed * 10 : baseSpeed * 1.5; @@ -209,25 +147,21 @@ const FirefliesBanner: React.FC = ({ username, palette, se f.vy = (f.vy / currentSpeed) * maxSpeed; } - // 4. Smooth Friction f.vx *= 0.97; f.vy *= 0.97; f.x += f.vx; f.y += f.vy; - // Screen Wrap const margin = 20; if (f.x < -margin) f.x = width + margin; if (f.x > width + margin) f.x = -margin; if (f.y < -margin) f.y = height + margin; if (f.y > height + margin) f.y = -margin; - // Flicker const flicker = 0.4 + Math.sin(time + f.phase) * 0.6; const opacity = flicker > 0 ? flicker : 0; - // Render const grad = ctx.createRadialGradient(f.x, f.y, 0, f.x, f.y, f.size * glowSizeMultiplier); grad.addColorStop(0, alpha(f.color, opacity)); grad.addColorStop(0.4, alpha(f.color, opacity * 0.3)); @@ -243,44 +177,21 @@ const FirefliesBanner: React.FC = ({ username, palette, se ctx.arc(f.x, f.y, f.size * 0.5, 0, Math.PI * 2); ctx.fill(); }); + }, + deps: [ + seed, + brightColors, + count, + baseSpeed, + evasionRadius, + evasionStrength, + glowSizeMultiplier, + flickerSpeed, + theme, + ], + }); - animationFrameId = requestAnimationFrame(draw); - }; - - draw(); - - return () => { - resizeObserver.disconnect(); - window.removeEventListener('mousemove', handleMouseMove); - cancelAnimationFrame(animationFrameId); - }; - }, [ - seed, - brightColors, - count, - baseSpeed, - evasionRadius, - evasionStrength, - glowSizeMultiplier, - flickerSpeed, - theme, - gate, - ]); - - return ( - - ); + return ; }; export default FirefliesBanner; diff --git a/frontend/src/components/banners/GenerativeBanner.tsx b/frontend/src/components/banners/GenerativeBanner.tsx new file mode 100644 index 0000000..440ff0a --- /dev/null +++ b/frontend/src/components/banners/GenerativeBanner.tsx @@ -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 = ({ + 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 ( + + ); +}; + +export default GenerativeBanner; diff --git a/frontend/src/components/GenerativeFlowBanner.tsx b/frontend/src/components/banners/GenerativeFlowBanner.tsx similarity index 50% rename from frontend/src/components/GenerativeFlowBanner.tsx rename to frontend/src/components/banners/GenerativeFlowBanner.tsx index 98476e3..3ae60be 100644 --- a/frontend/src/components/GenerativeFlowBanner.tsx +++ b/frontend/src/components/banners/GenerativeFlowBanner.tsx @@ -1,6 +1,6 @@ -import React, { useRef, useEffect, useMemo } from 'react'; +import React, { useMemo } from 'react'; import { alpha, useTheme } from '@mui/material'; -import { useBannerFrameGate } from './BannerPerformanceWrapper'; +import { CANVAS_STYLE, useCanvasBanner } from './useCanvasBanner'; interface GenerativeFlowProps { username: string; @@ -16,10 +16,29 @@ interface GenerativeFlowProps { }; } +interface Blob { + x: number; + y: number; + ox: number; + oy: number; + vx: number; + vy: number; + size: number; + color: string; + phase: number; + speedFactor: number; +} + +const hashString = (str: string): number => { + let hash = 0; + for (let i = 0; i < str.length; i++) { + hash = str.charCodeAt(i) + ((hash << 5) - hash); + } + return hash; +}; + const GenerativeFlowBanner: React.FC = ({ username, palette, settings }) => { - const canvasRef = useRef(null); const theme = useTheme(); - const gate = useBannerFrameGate(); const blobCount = settings?.blobCount ?? 8; const flowSpeed = settings?.flowSpeed ?? 0.008; @@ -27,15 +46,7 @@ const GenerativeFlowBanner: React.FC = ({ username, palette const blurAmount = settings?.blur ?? 60; const distortionFactor = settings?.distortion ?? 1.2; - const getSeed = (str: string) => { - let hash = 0; - for (let i = 0; i < str.length; i++) { - hash = str.charCodeAt(i) + ((hash << 5) - hash); - } - return hash; - }; - - const seed = useMemo(() => getSeed(username), [username]); + const seed = useMemo(() => hashString(username), [username]); const brightColors = useMemo(() => { const colors = @@ -52,42 +63,17 @@ const GenerativeFlowBanner: React.FC = ({ username, palette return sorted.slice(0, 4); }, [palette, theme]); - useEffect(() => { - const canvas = canvasRef.current; - if (!canvas) return; - - const ctx = canvas.getContext('2d', { alpha: true }); - if (!ctx) return; - - let animationFrameId: number; - let time = 0; - - interface Blob { - x: number; - y: number; - ox: number; - oy: number; - vx: number; - vy: number; - size: number; - color: string; - phase: number; - speedFactor: number; - } - - let blobs: Blob[] = []; - - const pseudoRandom = (offset: number) => { - const x = Math.sin(seed + offset) * 10000; - return x - Math.floor(x); - }; - - const initBlobs = (width: number, height: number) => { - const newBlobs = []; + const ref = useCanvasBanner<{ blobs: Blob[]; time: number }>({ + init: (width, height) => { + const pseudoRandom = (offset: number) => { + const x = Math.sin(seed + offset) * 10000; + return x - Math.floor(x); + }; + const blobs: Blob[] = []; for (let i = 0; i < blobCount; i++) { const rx = pseudoRandom(i) * width; const ry = pseudoRandom(i + 500) * height; - newBlobs.push({ + blobs.push({ x: rx, y: ry, ox: rx, @@ -100,59 +86,15 @@ const GenerativeFlowBanner: React.FC = ({ username, palette speedFactor: 0.5 + pseudoRandom(i + 2000), }); } - return newBlobs; - }; - - const resize = () => { - const parent = canvas.parentElement; - if (parent) { - const rect = parent.getBoundingClientRect(); - const dpr = window.devicePixelRatio || 1; - canvas.width = rect.width * dpr; - canvas.height = rect.height * dpr; - ctx.scale(dpr, dpr); - canvas.style.width = `${rect.width}px`; - canvas.style.height = `${rect.height}px`; - blobs = initBlobs(rect.width, rect.height); - } - }; - - const resizeObserver = new ResizeObserver(() => resize()); - if (canvas.parentElement) resizeObserver.observe(canvas.parentElement); - resize(); - - const mouse = { x: -1000, y: -1000 }; - const handleMouseMove = (e: MouseEvent) => { - const rect = canvas.getBoundingClientRect(); - const x = e.clientX - rect.left; - const y = e.clientY - rect.top; - if (x < 0 || y < 0 || x > rect.width || y > rect.height) { - mouse.x = -1000; - mouse.y = -1000; - } else { - mouse.x = x; - mouse.y = y; - } - }; - window.addEventListener('mousemove', handleMouseMove); - - const draw = (now = 0) => { - const decision = gate(now); - if (decision === 'halt') return; - if (decision === 'skip') { - animationFrameId = requestAnimationFrame(draw); - return; - } - const width = canvas.width / (window.devicePixelRatio || 1); - const height = canvas.height / (window.devicePixelRatio || 1); - if (!width || !height) { - animationFrameId = requestAnimationFrame(draw); - return; - } + return { blobs, time: 0 }; + }, + draw: ({ ctx, width, height, state, mouse }) => { + const { blobs } = state; + state.time += flowSpeed; + const time = state.time; ctx.clearRect(0, 0, width, height); - // Background Base const isLight = theme.palette.mode === 'light'; const bgGrad = ctx.createRadialGradient( width / 2, @@ -163,7 +105,6 @@ const GenerativeFlowBanner: React.FC = ({ username, palette width, ); if (isLight) { - // For light themes, use a deeper, more saturated version of the theme color bgGrad.addColorStop( 0, alpha(brightColors[brightColors.length - 1] || brightColors[0], 0.6), @@ -179,18 +120,13 @@ const GenerativeFlowBanner: React.FC = ({ username, palette ctx.fillStyle = bgGrad; ctx.fillRect(0, 0, width, height); - time += flowSpeed; - - // Apply filter for "Liquid" look ctx.filter = `blur(${blurAmount}px)`; ctx.globalCompositeOperation = 'screen'; blobs.forEach((b) => { - // Harmonic autonomous drift const driftX = Math.sin(time * b.speedFactor + b.phase) * (width / 5); const driftY = Math.cos(time * 0.8 * b.speedFactor + b.phase) * (height / 5); - // Mouse distortion const dxMouse = mouse.x - b.ox; const dyMouse = mouse.y - b.oy; const distMouse = Math.sqrt(dxMouse * dxMouse + dyMouse * dyMouse); @@ -202,7 +138,6 @@ const GenerativeFlowBanner: React.FC = ({ username, palette b.x = b.ox + driftX + mX; b.y = b.oy + driftY + mY; - // Pulsing size const pulse = 1 + Math.sin(time + b.phase) * 0.1; const currentSize = b.size * pulse; @@ -218,43 +153,11 @@ const GenerativeFlowBanner: React.FC = ({ username, palette ctx.filter = 'none'; ctx.globalCompositeOperation = 'source-over'; + }, + deps: [seed, brightColors, blobCount, flowSpeed, vibrancy, blurAmount, distortionFactor, theme], + }); - animationFrameId = requestAnimationFrame(draw); - }; - - draw(); - - return () => { - resizeObserver.disconnect(); - window.removeEventListener('mousemove', handleMouseMove); - cancelAnimationFrame(animationFrameId); - }; - }, [ - seed, - brightColors, - blobCount, - flowSpeed, - vibrancy, - blurAmount, - distortionFactor, - theme, - gate, - ]); - - return ( - - ); + return ; }; export default GenerativeFlowBanner; diff --git a/frontend/src/components/banners/HextechBanner.tsx b/frontend/src/components/banners/HextechBanner.tsx new file mode 100644 index 0000000..603fd20 --- /dev/null +++ b/frontend/src/components/banners/HextechBanner.tsx @@ -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 = ({ 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 ; +}; + +export default HextechBanner; diff --git a/frontend/src/components/banners/HextechCorruptionBanner.tsx b/frontend/src/components/banners/HextechCorruptionBanner.tsx new file mode 100644 index 0000000..68df07c --- /dev/null +++ b/frontend/src/components/banners/HextechCorruptionBanner.tsx @@ -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 = ({ + 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 ; +}; + +export default HextechCorruptionBanner; diff --git a/frontend/src/components/banners/MatrixBanner.tsx b/frontend/src/components/banners/MatrixBanner.tsx new file mode 100644 index 0000000..c567867 --- /dev/null +++ b/frontend/src/components/banners/MatrixBanner.tsx @@ -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 = ({ username, settings }) => { + const [durations] = useState(() => Array.from({ length: 20 }, () => 2 + Math.random() * 3)); + return ( +
+
+ {durations.map((dur, i) => ( +
+ {username.split('').map((char, j) => ( + + {char} + + ))} +
+ ))} +
+ +
+ ); +}; + +export default MatrixBanner; diff --git a/frontend/src/components/banners/NebulaBanner.tsx b/frontend/src/components/banners/NebulaBanner.tsx new file mode 100644 index 0000000..e69074a --- /dev/null +++ b/frontend/src/components/banners/NebulaBanner.tsx @@ -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 = ({ + 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 ; +}; + +export default NebulaBanner; diff --git a/frontend/src/components/banners/index.ts b/frontend/src/components/banners/index.ts new file mode 100644 index 0000000..af56e3a --- /dev/null +++ b/frontend/src/components/banners/index.ts @@ -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'; diff --git a/frontend/src/components/banners/useCanvasBanner.test.tsx b/frontend/src/components/banners/useCanvasBanner.test.tsx new file mode 100644 index 0000000..9027439 --- /dev/null +++ b/frontend/src/components/banners/useCanvasBanner.test.tsx @@ -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 ( + + + + ); +}; + +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(); + 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(); + 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(); + 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(); + unmount(); + expect(roDisconnectMock).toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/components/banners/useCanvasBanner.ts b/frontend/src/components/banners/useCanvasBanner.ts new file mode 100644 index 0000000..2485b7d --- /dev/null +++ b/frontend/src/components/banners/useCanvasBanner.ts @@ -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 { + ctx: CanvasRenderingContext2D; + width: number; + height: number; + state: S; + mouse: CanvasMouse; + now: number; +} + +export interface UseCanvasBannerOptions { + init: (width: number, height: number) => S; + draw: (args: DrawArgs) => 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({ + init, + draw, + observeTouch = false, + trackPrev = false, + deps, +}: UseCanvasBannerOptions): RefObject { + const canvasRef = useRef(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, +}; diff --git a/frontend/src/contexts/NotificationContext.tsx b/frontend/src/contexts/NotificationContext.tsx index e97f97a..0e272a7 100644 --- a/frontend/src/contexts/NotificationContext.tsx +++ b/frontend/src/contexts/NotificationContext.tsx @@ -1,26 +1,14 @@ -import { createContext, useCallback, useEffect, useState, type ReactNode } from 'react'; -import api from '../lib/api'; +import { createContext, useCallback, useEffect, type ReactNode } from 'react'; +import { useQueryClient } from '@tanstack/react-query'; import { useAuth } from '../hooks/useAuth'; +import { queryKeys } from '../api/keys'; +import { useNotificationsQuery, type Notification } from '../api/queries/useNotifications'; +import { + useMarkAllNotificationsRead, + useMarkNotificationRead, +} from '../api/mutations/useMarkNotifications'; -export interface Notification { - id: number; - actor_username: string; - actor_avatar: string | null; - notification_type: - | 'reply' - | 'mention' - | 'follow' - | 'ticket_new' - | 'ticket_reply' - | 'profile_comment'; - rating: number | null; - reply: number | null; - is_read: boolean; - created_at: string; - flavor_name: string | null; - flavor_id: number | null; - profile_owner_username: string | null; -} +export type { Notification }; export interface NotificationContextValue { notifications: Notification[]; @@ -33,49 +21,40 @@ export const NotificationContext = createContext { const { user, setUser, refetchUser } = useAuth(); - const [notifications, setNotifications] = useState([]); + const qc = useQueryClient(); - const fetchNotifications = useCallback(async () => { - try { - const res = await api.get('notifications/'); - const data = Array.isArray(res.data) ? res.data : res.data.results || []; - setNotifications(data); - } catch { - /* ignore */ - } - }, []); - - useEffect(() => { - if (user) { - fetchNotifications(); - } - }, [user, fetchNotifications]); + const { data: notifications = [], refetch } = useNotificationsQuery(!!user); + const markAllMutation = useMarkAllNotificationsRead(); + const markOneMutation = useMarkNotificationRead(); + // Refetch the auth user on the same 60s cadence so the unread count badge + // stays in sync with anything that mutated server-side outside this client. useEffect(() => { const interval = setInterval(() => { if (localStorage.getItem('access')) { - fetchNotifications(); refetchUser(); } - }, 60000); + }, 60_000); return () => clearInterval(interval); - }, [fetchNotifications, refetchUser]); + }, [refetchUser]); + + const fetchNotifications = useCallback(async () => { + await refetch(); + }, [refetch]); const markAllRead = useCallback(async () => { try { - await api.post('notifications/mark_all_read/'); - setNotifications((prev) => prev.map((n) => ({ ...n, is_read: true }))); + await markAllMutation.mutateAsync(); if (user) setUser({ ...user, unread_notifications_count: 0 }); } catch { /* ignore */ } - }, [user, setUser]); + }, [markAllMutation, setUser, user]); const markRead = useCallback( async (id: number) => { try { - await api.post(`notifications/${id}/mark_read/`); - setNotifications((prev) => prev.map((n) => (n.id === id ? { ...n, is_read: true } : n))); + await markOneMutation.mutateAsync(id); if (user) { setUser({ ...user, @@ -86,9 +65,15 @@ export const NotificationProvider = ({ children }: { children: ReactNode }) => { /* ignore */ } }, - [user, setUser], + [markOneMutation, setUser, user], ); + // Clear cached notifications on logout so the next user doesn't briefly + // see the previous user's data. + useEffect(() => { + if (!user) qc.removeQueries({ queryKey: queryKeys.notifications() }); + }, [user, qc]); + return ( { 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 | null = null; @@ -76,7 +82,11 @@ api.interceptors.response.use( refreshPromise = null; if (!newAccess) { - if (typeof window !== 'undefined' && window.location.pathname !== '/login') { + if ( + !original.skipAuthRedirect && + typeof window !== 'undefined' && + window.location.pathname !== '/login' + ) { window.location.href = '/login'; } return Promise.reject(error); diff --git a/frontend/src/pages/AdminDashboard.tsx b/frontend/src/pages/AdminDashboard.tsx index 720a626..e605c2e 100644 --- a/frontend/src/pages/AdminDashboard.tsx +++ b/frontend/src/pages/AdminDashboard.tsx @@ -1,4 +1,5 @@ -import React, { useCallback, useEffect, useState } from 'react'; +import React, { useEffect, useState } from 'react'; +import { useQueryClient } from '@tanstack/react-query'; import { Typography, Box, @@ -43,7 +44,21 @@ import PlayArrowIcon from '@mui/icons-material/PlayArrow'; import InfoIcon from '@mui/icons-material/Info'; import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router-dom'; -import api from '../lib/api'; +import { + useAdminConfig, + useAdminJobs, + useAdminStats, + useAdminUsers, +} from '../api/queries/useAdminQueries'; +import { useBannersList } from '../api/queries/useBanners'; +import { + useActivateBanner, + useSendTestEmail, + useToggleBannerEnabled, + useTriggerJob, + useUpdateAdminConfig, + useUpdateJobSchedule, +} from '../api/mutations/useAdminMutations'; import { useTitle } from '../hooks/useTitle'; import { formatDate } from '../utils/date'; import BannerSettingsDialog from '../components/BannerSettingsDialog'; @@ -125,59 +140,56 @@ const AdminDashboard: React.FC = () => { const navigate = useNavigate(); const isMobile = useMediaQuery('(max-width:900px)'); useTitle(t('admin.title')); - const [stats, setStats] = useState(null); - const [users, setUsers] = useState([]); - const [banners, setBanners] = useState([]); - const [jobs, setJobs] = useState([]); - const [config, setConfig] = useState(null); - const [loading, setLoading] = useState(true); + const qc = useQueryClient(); + const { data: stats = null, isLoading: statsLoading } = useAdminStats() as unknown as { + data: Stats | null | undefined; + isLoading: boolean; + }; + const { data: users = [], isLoading: usersLoading } = useAdminUsers() as unknown as { + data: AdminUser[]; + isLoading: boolean; + }; + const { data: banners = [], isLoading: bannersLoading } = useBannersList() as unknown as { + data: Banner[]; + isLoading: boolean; + }; + const { data: jobs = [], isLoading: jobsLoading } = useAdminJobs() as unknown as { + data: Job[]; + isLoading: boolean; + }; + const { data: config = null, isLoading: configLoading } = useAdminConfig() as unknown as { + data: SystemConfig | null | undefined; + isLoading: boolean; + }; + const updateConfigMutation = useUpdateAdminConfig(); + const triggerJobMutation = useTriggerJob(); + const updateJobScheduleMutation = useUpdateJobSchedule(); + const activateBannerMutation = useActivateBanner(); + const toggleBannerEnabledMutation = useToggleBannerEnabled(); + const sendTestEmailMutation = useSendTestEmail(); + + const loading = statsLoading || usersLoading || bannersLoading || jobsLoading || configLoading; + const [search, setSearch] = useState(''); const [currentTab, setCurrentTab] = useState(0); const [selectedBanner, setSelectedBanner] = useState(null); const [isSettingsDialogOpen, setIsSettingsDialogOpen] = useState(false); const { notify } = useToast(); - const fetchData = useCallback(async (silent = false) => { - if (!silent) setLoading(true); - try { - const [statsRes, usersRes, bannersRes, jobsRes, configRes] = await Promise.all([ - api.get('admin-custom/stats/'), - api.get('admin-custom/users/'), - api.get('banners/'), - api.get('admin-custom/jobs/'), - api.get('admin-custom/config/'), - ]); - - setStats(statsRes.data); - setUsers(usersRes.data); - - const bannersData = Array.isArray(bannersRes.data) - ? bannersRes.data - : bannersRes.data.results || []; - setBanners(bannersData); - - setJobs(jobsRes.data); - setConfig(configRes.data); - } catch (err) { - console.error('Data fetch error:', err); - } - - if (!silent) setLoading(false); - }, []); - - // Poll for job updates if any are running or pending + // Poll the jobs query faster while anything is running on the Jobs tab. useEffect(() => { const anyRunning = jobs.some((j) => j.status === 'running' || j.status === 'pending'); if (anyRunning && currentTab === 3) { - const interval = setInterval(() => fetchData(true), 3000); + const interval = setInterval(() => { + qc.invalidateQueries({ queryKey: ['adminJobs'] }); + }, 3000); return () => clearInterval(interval); } - }, [jobs, currentTab, fetchData]); + }, [jobs, currentTab, qc]); const handleUpdateConfig = async (key: string, value: unknown) => { try { - const res = await api.patch('admin-custom/config/', { [key]: value }); - setConfig(res.data); + await updateConfigMutation.mutateAsync({ key, value }); } catch { notify({ message: 'Failed to update configuration', severity: 'error' }); } @@ -185,8 +197,7 @@ const AdminDashboard: React.FC = () => { const handleTriggerJob = async (id: number) => { try { - await api.post(`admin-custom/${id}/trigger_job/`); - fetchData(true); + await triggerJobMutation.mutateAsync(id); } catch { notify({ message: 'Failed to trigger job', severity: 'error' }); } @@ -196,8 +207,7 @@ const AdminDashboard: React.FC = () => { try { const data: Record = { interval_hours: interval }; if (nextRun) data.next_run = nextRun; - await api.patch(`admin-custom/${id}/update_job_schedule/`, data); - fetchData(true); + await updateJobScheduleMutation.mutateAsync({ id, data }); } catch { notify({ message: 'Failed to update schedule', severity: 'error' }); } @@ -205,8 +215,7 @@ const AdminDashboard: React.FC = () => { const handleActivateBanner = async (id: number) => { try { - await api.post(`banners/${id}/activate/`); - fetchData(); + await activateBannerMutation.mutateAsync(id); } catch (err) { console.error(err); } @@ -214,8 +223,7 @@ const AdminDashboard: React.FC = () => { const handleToggleEnabled = async (id: number) => { try { - await api.post(`banners/${id}/toggle_enabled/`); - fetchData(); + await toggleBannerEnabledMutation.mutateAsync(id); } catch (err) { const error = err as { response?: { data?: { error?: string } } }; notify({ @@ -225,13 +233,9 @@ const AdminDashboard: React.FC = () => { } }; - useEffect(() => { - fetchData(); - }, [fetchData]); - const handleSendTestEmail = async () => { try { - await api.post('admin-custom/send_test_email/'); + await sendTestEmailMutation.mutateAsync(); notify({ message: t('admin.testEmailSuccess'), severity: 'success' }); } catch (err) { const error = err as { response?: { data?: { error?: string } } }; @@ -616,7 +620,10 @@ const AdminDashboard: React.FC = () => { title="No banner models found" subtitle="No banners registered in the database." action={ - } @@ -857,7 +864,7 @@ const AdminDashboard: React.FC = () => { setSelectedBanner(null); }} banner={selectedBanner} - onSave={fetchData} + onSave={() => qc.invalidateQueries({ queryKey: ['banners'] })} /> ); diff --git a/frontend/src/pages/AdminRatingDetail.tsx b/frontend/src/pages/AdminRatingDetail.tsx index bf80581..9ecedf9 100644 --- a/frontend/src/pages/AdminRatingDetail.tsx +++ b/frontend/src/pages/AdminRatingDetail.tsx @@ -1,60 +1,48 @@ -import React, { useState, useEffect, useCallback } from 'react'; +import React, { useEffect, useState } from 'react'; import { Box, Button, Stack, CircularProgress, TextField } from '@mui/material'; import ArrowBackIcon from '@mui/icons-material/ArrowBack'; import DeleteIcon from '@mui/icons-material/Delete'; import { useTranslation } from 'react-i18next'; import { useNavigate, useParams } from 'react-router-dom'; -import api from '../lib/api'; +import { useAdminRating } from '../api/queries/useAdminQueries'; +import { useDeleteRating, useUpdateRating } from '../api/mutations/useRatingMutations'; import { useTitle } from '../hooks/useTitle'; import MentionTextField from '../components/MentionTextField'; import { PageShell, SectionHeader, FormCard } from '../components/ui'; import { useToast } from '../hooks/useToast'; import { useConfirm } from '../hooks/useConfirm'; -interface AdminRating { - id: number; - user: string; - score: number; - comment: string | null; - flavor_name: string; -} - const AdminRatingDetail: React.FC = () => { const { t } = useTranslation(); const navigate = useNavigate(); const { id } = useParams<{ id: string }>(); + const { data: rating, isLoading: loading, error } = useAdminRating(id); + const updateRating = useUpdateRating(); + const deleteRating = useDeleteRating(); + const [score, setScore] = useState(0); const [comment, setComment] = useState(''); - const [rating, setRating] = useState(null); - const [loading, setLoading] = useState(true); const { notify } = useToast(); const { confirm } = useConfirm(); - const fetchRating = useCallback(async () => { - try { - const res = await api.get(`ratings/${id}/`); - setRating(res.data); - setScore(res.data.score); - setComment(res.data.comment || ''); - } catch (err) { - console.error(err); - navigate('/admin-panel'); - } finally { - setLoading(false); + useEffect(() => { + if (rating) { + setScore(rating.score); + setComment(rating.comment || ''); } - }, [id, navigate]); + }, [rating]); useEffect(() => { - fetchRating(); - }, [fetchRating]); + if (error) navigate('/admin-panel'); + }, [error, navigate]); useTitle(rating ? `${t('admin.manageRating')}: ${rating.flavor_name}` : 'Rating Management'); const handleUpdate = async (e: React.FormEvent) => { e.preventDefault(); + if (!id) return; try { - await api.patch(`ratings/${id}/`, { score, comment }); - fetchRating(); + await updateRating.mutateAsync({ id: Number(id), score, comment }); notify({ message: 'Rating updated!', severity: 'success' }); } catch { notify({ message: 'Update failed', severity: 'error' }); @@ -62,9 +50,10 @@ const AdminRatingDetail: React.FC = () => { }; const handleDelete = async () => { + if (!id) return; if (!(await confirm({ message: 'Delete this rating and all replies?', danger: true }))) return; try { - await api.delete(`ratings/${id}/`); + await deleteRating.mutateAsync(Number(id)); navigate('/admin-panel'); } catch { notify({ message: 'Delete failed', severity: 'error' }); diff --git a/frontend/src/pages/AdminReplyDetail.tsx b/frontend/src/pages/AdminReplyDetail.tsx index 3554356..90eba67 100644 --- a/frontend/src/pages/AdminReplyDetail.tsx +++ b/frontend/src/pages/AdminReplyDetail.tsx @@ -1,56 +1,45 @@ -import React, { useState, useEffect, useCallback } from 'react'; +import React, { useEffect, useState } from 'react'; import { Box, Button, Stack, CircularProgress } from '@mui/material'; import ArrowBackIcon from '@mui/icons-material/ArrowBack'; import DeleteIcon from '@mui/icons-material/Delete'; import { useTranslation } from 'react-i18next'; import { useNavigate, useParams } from 'react-router-dom'; -import api from '../lib/api'; +import { useAdminReply } from '../api/queries/useAdminQueries'; +import { useDeleteReply } from '../api/mutations/useRatingMutations'; +import { useUpdateReply } from '../api/mutations/useReplyMutations'; import { useTitle } from '../hooks/useTitle'; import MentionTextField from '../components/MentionTextField'; import { PageShell, SectionHeader, FormCard } from '../components/ui'; import { useToast } from '../hooks/useToast'; import { useConfirm } from '../hooks/useConfirm'; -interface AdminReply { - id: number; - user: string; - text: string; -} - const AdminReplyDetail: React.FC = () => { const { t } = useTranslation(); const navigate = useNavigate(); const { id } = useParams<{ id: string }>(); + const { data: reply, isLoading: loading, error } = useAdminReply(id); + const updateReply = useUpdateReply(); + const deleteReply = useDeleteReply(); + const [text, setText] = useState(''); - const [reply, setReply] = useState(null); - const [loading, setLoading] = useState(true); const { notify } = useToast(); const { confirm } = useConfirm(); - const fetchReply = useCallback(async () => { - try { - const res = await api.get(`replies/${id}/`); - setReply(res.data); - setText(res.data.text); - } catch (err) { - console.error(err); - navigate('/admin-panel'); - } finally { - setLoading(false); - } - }, [id, navigate]); + useEffect(() => { + if (reply) setText(reply.text); + }, [reply]); useEffect(() => { - fetchReply(); - }, [fetchReply]); + if (error) navigate('/admin-panel'); + }, [error, navigate]); useTitle(reply ? `${t('admin.manageReply')}` : 'Reply Management'); const handleUpdate = async (e: React.FormEvent) => { e.preventDefault(); + if (!id) return; try { - await api.patch(`replies/${id}/`, { text }); - fetchReply(); + await updateReply.mutateAsync({ replyId: Number(id), text }); notify({ message: 'Reply updated!', severity: 'success' }); } catch { notify({ message: 'Update failed', severity: 'error' }); @@ -58,9 +47,10 @@ const AdminReplyDetail: React.FC = () => { }; const handleDelete = async () => { + if (!id) return; if (!(await confirm({ message: 'Delete this reply?', danger: true }))) return; try { - await api.delete(`replies/${id}/`); + await deleteReply.mutateAsync(Number(id)); navigate('/admin-panel'); } catch { notify({ message: 'Delete failed', severity: 'error' }); diff --git a/frontend/src/pages/AdminUserDetail.tsx b/frontend/src/pages/AdminUserDetail.tsx index 2cca614..ce20e8b 100644 --- a/frontend/src/pages/AdminUserDetail.tsx +++ b/frontend/src/pages/AdminUserDetail.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useState } from 'react'; +import React, { useEffect, useState } from 'react'; import { Typography, Box, @@ -24,7 +24,14 @@ import HistoryIcon from '@mui/icons-material/History'; import RateReviewIcon from '@mui/icons-material/RateReview'; import { useTranslation } from 'react-i18next'; import { useNavigate, useParams } from 'react-router-dom'; -import api from '../lib/api'; +import { useAdminUserDetail } from '../api/queries/useAdminQueries'; +import { useDeleteUser, useToggleUserActive } from '../api/mutations/useAdminMutations'; +import { + useDeleteRating, + useDeleteReply, + useUpdateRating, +} from '../api/mutations/useRatingMutations'; +import { useUpdateReply } from '../api/mutations/useReplyMutations'; import { useTitle } from '../hooks/useTitle'; import { formatDate } from '../utils/date'; import MentionTextField from '../components/MentionTextField'; @@ -72,10 +79,23 @@ const AdminUserDetail: React.FC = () => { const { t } = useTranslation(); const navigate = useNavigate(); const { id } = useParams<{ id: string }>(); - const [user, setUser] = useState(null); - const [loading, setLoading] = useState(true); - const [activeTab, setActiveTab] = useState(0); + const { + data: user, + isLoading: loading, + error, + } = useAdminUserDetail(id) as { + data: AdminUser | undefined; + isLoading: boolean; + error: unknown; + }; + const toggleActive = useToggleUserActive(); + const deleteUserMutation = useDeleteUser(); + const updateRatingMutation = useUpdateRating(); + const deleteRatingMutation = useDeleteRating(); + const updateReplyMutation = useUpdateReply(); + const deleteReplyMutation = useDeleteReply(); + const [activeTab, setActiveTab] = useState(0); const [editingRatingId, setEditingRatingId] = useState(null); const [editScore, setEditScore] = useState(0); const [editComment, setEditComment] = useState(''); @@ -86,29 +106,16 @@ const AdminUserDetail: React.FC = () => { const { notify } = useToast(); const { confirm } = useConfirm(); - const fetchUser = useCallback(async () => { - try { - const res = await api.get(`admin-custom/${id}/user_detail/`); - setUser(res.data); - } catch (err) { - console.error(err); - navigate('/admin-panel'); - } finally { - setLoading(false); - } - }, [id, navigate]); - useEffect(() => { - fetchUser(); - }, [fetchUser]); + if (error) navigate('/admin-panel'); + }, [error, navigate]); useTitle(user ? `${t('admin.manageUser')}: ${user.username}` : 'User Management'); const handleToggleActive = async () => { - if (!user) return; + if (!user || !id) return; try { - await api.patch(`admin-custom/${id}/user_detail/`, { is_active: !user.is_active }); - fetchUser(); + await toggleActive.mutateAsync({ id, isActive: user.is_active }); } catch { notify({ message: 'Action failed', severity: 'error' }); } @@ -116,9 +123,12 @@ const AdminUserDetail: React.FC = () => { const handleUpdateRating = async (ratingId: number) => { try { - await api.patch(`ratings/${ratingId}/`, { score: editScore, comment: editComment }); + await updateRatingMutation.mutateAsync({ + id: ratingId, + score: editScore, + comment: editComment, + }); setEditingRatingId(null); - fetchUser(); } catch { notify({ message: 'Update failed', severity: 'error' }); } @@ -127,8 +137,7 @@ const AdminUserDetail: React.FC = () => { const handleDeleteRating = async (ratingId: number) => { if (!(await confirm({ message: 'Delete this rating?', danger: true }))) return; try { - await api.delete(`ratings/${ratingId}/`); - fetchUser(); + await deleteRatingMutation.mutateAsync(ratingId); } catch { notify({ message: 'Delete failed', severity: 'error' }); } @@ -136,9 +145,8 @@ const AdminUserDetail: React.FC = () => { const handleUpdateReply = async (replyId: number) => { try { - await api.patch(`replies/${replyId}/`, { text: editReplyText }); + await updateReplyMutation.mutateAsync({ replyId, text: editReplyText }); setEditingReplyId(null); - fetchUser(); } catch { notify({ message: 'Update failed', severity: 'error' }); } @@ -147,14 +155,14 @@ const AdminUserDetail: React.FC = () => { const handleDeleteReply = async (replyId: number) => { if (!(await confirm({ message: 'Delete this reply?', danger: true }))) return; try { - await api.delete(`replies/${replyId}/`); - fetchUser(); + await deleteReplyMutation.mutateAsync(replyId); } catch { notify({ message: 'Delete failed', severity: 'error' }); } }; const handleDelete = async () => { + if (!id) return; const ok = await confirm({ title: 'Delete user', message: 'PERMANENTLY DELETE THIS USER AND ALL DATA? This cannot be undone.', @@ -163,7 +171,7 @@ const AdminUserDetail: React.FC = () => { }); if (!ok) return; try { - await api.delete(`admin-custom/${id}/user_detail/`); + await deleteUserMutation.mutateAsync(id); navigate('/admin-panel'); } catch { notify({ message: 'Delete failed', severity: 'error' }); diff --git a/frontend/src/pages/CommunityFeed.tsx b/frontend/src/pages/CommunityFeed.tsx index 4c543d3..11e8642 100644 --- a/frontend/src/pages/CommunityFeed.tsx +++ b/frontend/src/pages/CommunityFeed.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useState } from 'react'; +import React, { useEffect, useState } from 'react'; import { Typography, Box, @@ -26,7 +26,14 @@ import NotificationsIcon from '@mui/icons-material/Notifications'; import PeopleIcon from '@mui/icons-material/People'; import ArrowBackIcon from '@mui/icons-material/ArrowBack'; import { Link, useNavigate } from 'react-router-dom'; -import api from '../lib/api'; +import { + useCommunityFeed, + useFollowedTopFlavors, + useFollowingList, + type FeedRating, +} from '../api/queries/useCommunityFeed'; +import { useNotificationsQuery } from '../api/queries/useNotifications'; +import { useCreateReply } from '../api/mutations/useRatingMutations'; import { useTitle } from '../hooks/useTitle'; import RichText from '../components/RichText'; import { formatDate } from '../utils/date'; @@ -36,61 +43,6 @@ import RatingBadge from '../components/RatingBadge'; import { PageShell, HeroBackdrop, SectionHeader, GlassCard, EmptyState } from '../components/ui'; import { useToast } from '../hooks/useToast'; -interface Reply { - id: number; - user: string; - text: string; - created_at: string; -} - -interface FeedRating { - id: number; - user: string; - user_id: number; - user_avatar: string | null; - flavor: number; - flavor_name: string; - flavor_image: string | null; - category_name: string; - category_slug: string; - score: number; - comment: string; - created_at: string; - replies: Reply[]; -} - -interface FollowedUser { - id: number; - username: string; - avatar: string | null; -} - -interface Notification { - id: number; - actor_username: string; - actor_avatar: string | null; - notification_type: - | 'reply' - | 'mention' - | 'follow' - | 'ticket_new' - | 'ticket_reply' - | 'profile_comment'; - rating: number | null; - reply: number | null; - is_read: boolean; - created_at: string; - flavor_name: string | null; - flavor_id: number | null; -} - -interface TopFlavor { - id: number; - name: string; - image_url: string | null; - average_rating: number; -} - interface CommunityFeedProps { adminMode?: boolean; } @@ -125,17 +77,23 @@ const CommunityFeed: React.FC = ({ adminMode }) => { useTitle(t('community.title')); const navigate = useNavigate(); const { notify } = useToast(); - const [ratings, setRatings] = useState([]); - const [following, setFollowing] = useState([]); - const [notifications, setNotifications] = useState([]); - const [loading, setLoading] = useState(true); const [page, setPage] = useState(1); - const [totalPages, setTotalPages] = useState(1); const [followingSearch, setFollowingSearch] = useState(''); - const [topFollowed, setTopFollowed] = useState([]); const [replyInputs, setReplyInputs] = useState>({}); const [expandedReplies, setExpandedReplies] = useState>({}); + const isAuthed = !!localStorage.getItem('access'); + const { data: feed, isLoading: feedLoading, error: feedError } = useCommunityFeed(page); + const { data: following = [] } = useFollowingList(); + const { data: notificationsAll = [] } = useNotificationsQuery(isAuthed); + const { data: topFollowed = [] } = useFollowedTopFlavors(); + const createReply = useCreateReply(); + + const ratings: FeedRating[] = feed?.ratings ?? []; + const totalPages = feed?.totalPages ?? 1; + const notifications = notificationsAll.slice(0, 5); + const loading = feedLoading; + const filteredFollowing = following.filter((user) => user.username.toLowerCase().includes(followingSearch.toLowerCase()), ); @@ -145,52 +103,14 @@ const CommunityFeed: React.FC = ({ adminMode }) => { else navigate('/'); }; - const fetchFeedData = useCallback( - async (pageNum: number) => { - setLoading(true); - try { - const [feedRes, followingRes, topFollowedRes, notifRes] = await Promise.all([ - api.get(`ratings/feed/?page=${pageNum}`), - api.get('users/following_list/'), - api.get('flavors/followed_top/'), - api.get('notifications/'), - ]); - - const feedData = feedRes.data.results || (Array.isArray(feedRes.data) ? feedRes.data : []); - setRatings(feedData); - const count = feedRes.data.count || 0; - setTotalPages(Math.ceil(count / 10)); - - const followData = Array.isArray(followingRes.data) - ? followingRes.data - : followingRes.data.results || []; - setFollowing(followData); - - setTopFollowed(Array.isArray(topFollowedRes.data) ? topFollowedRes.data.slice(0, 5) : []); - - const notifData = Array.isArray(notifRes.data) - ? notifRes.data - : notifRes.data.results || []; - setNotifications(notifData.slice(0, 5)); - } catch (err) { - const status = (err as { response?: { status?: number } }).response?.status; - if (status === 401) navigate('/login'); - console.error('Failed to fetch feed data', err); - } finally { - setLoading(false); - } - }, - [navigate], - ); + useEffect(() => { + if (!isAuthed) navigate('/login'); + }, [isAuthed, navigate]); useEffect(() => { - const token = localStorage.getItem('access'); - if (!token) { - navigate('/login'); - return; - } - fetchFeedData(page); - }, [page, navigate, fetchFeedData]); + const status = (feedError as { response?: { status?: number } } | null)?.response?.status; + if (status === 401) navigate('/login'); + }, [feedError, navigate]); const handlePageChange = (_: React.ChangeEvent, value: number) => { setPage(value); @@ -205,10 +125,7 @@ const CommunityFeed: React.FC = ({ adminMode }) => { const text = replyInputs[ratingId]; if (!text) return; try { - const res = await api.post(`ratings/${ratingId}/reply/`, { text }); - setRatings((prev) => - prev.map((r) => (r.id === ratingId ? { ...r, replies: [...r.replies, res.data] } : r)), - ); + await createReply.mutateAsync({ ratingId, text }); setReplyInputs((prev) => ({ ...prev, [ratingId]: '' })); } catch { notify({ message: 'Failed to send reply', severity: 'error' }); diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index 2aade23..9fbe1ad 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; import { Typography, CardContent, @@ -32,7 +32,13 @@ import SearchIcon from '@mui/icons-material/Search'; import ColorThief from 'colorthief'; import { useNavigate, Link } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; -import api from '../lib/api'; +import { useDashboard } from '../api/queries/useDashboard'; +import { + useDeleteRating, + useDeleteReply, + useCreateReply, + useUpdateRating, +} from '../api/mutations/useRatingMutations'; import { useTitle } from '../hooks/useTitle'; import RatingBadge from '../components/RatingBadge'; import StatusBadge from '../components/StatusBadge'; @@ -43,56 +49,17 @@ import { PageShell, GlassCard, GlassSurface, EmptyState } from '../components/ui import { useToast } from '../hooks/useToast'; import { useConfirm } from '../hooks/useConfirm'; -interface Reply { - id: number; - user: string; - text: string; - created_at: string; -} - -interface RatedItem { - id: number; - flavor: number; - flavor_name: string; - flavor_image: string | null; - category_name: string; - category_slug: string; - score: number; - comment: string | null; - created_at: string; - replies: Reply[]; - is_legacy?: boolean; - is_available?: boolean; -} - -interface MissingFlavor { - id: number; - name: string; - image_url: string | null; - category_name: string; - category_slug: string; - average_rating: number; - followed_average_rating?: number; - is_legacy?: boolean; - is_available?: boolean; -} - -interface DashboardData { - user: { username: string; avatar: string | null }; - rated_count: number; - missing_count: number; - missing_flavors: MissingFlavor[]; - my_ratings: RatedItem[]; -} - const Dashboard: React.FC = () => { const { t } = useTranslation(); const navigate = useNavigate(); const theme = useTheme(); const isMobile = useMediaQuery(theme.breakpoints.down('lg')); useTitle(t('dashboard.title')); - const [data, setData] = useState(null); - const [loading, setLoading] = useState(true); + const { data, isLoading: loading } = useDashboard(); + const updateRating = useUpdateRating(); + const deleteRating = useDeleteRating(); + const createReply = useCreateReply(); + const deleteReply = useDeleteReply(); const [activeTab, setActiveTab] = useState(0); const [palette, setPalette] = useState([]); @@ -125,21 +92,6 @@ const Dashboard: React.FC = () => { else navigate('/'); }; - const fetchData = useCallback(async () => { - try { - const res = await api.get('users/dashboard/'); - setData(res.data); - } catch (err) { - console.error(err); - } finally { - setLoading(false); - } - }, []); - - useEffect(() => { - fetchData(); - }, [fetchData]); - useEffect(() => { if (data?.user?.avatar) { const img = new Image(); @@ -177,9 +129,8 @@ const Dashboard: React.FC = () => { const handleUpdateRating = async (ratingId: number) => { try { - await api.patch(`ratings/${ratingId}/`, { score: editScore, comment: editComment }); + await updateRating.mutateAsync({ id: ratingId, score: editScore, comment: editComment }); setEditingRatingId(null); - fetchData(); } catch { notify({ message: 'Failed to update review', severity: 'error' }); } @@ -188,8 +139,7 @@ const Dashboard: React.FC = () => { const handleDeleteRating = async (ratingId: number) => { if (!(await confirm({ message: 'Delete this review?', danger: true }))) return; try { - await api.delete(`ratings/${ratingId}/`); - fetchData(); + await deleteRating.mutateAsync(ratingId); } catch { notify({ message: 'Failed to delete review', severity: 'error' }); } @@ -199,10 +149,9 @@ const Dashboard: React.FC = () => { const text = replyInputs[ratingId]; if (!text) return; try { - await api.post(`ratings/${ratingId}/reply/`, { text }); + await createReply.mutateAsync({ ratingId, text }); setReplyInputs({ ...replyInputs, [ratingId]: '' }); setExpandedReplies({ ...expandedReplies, [ratingId]: true }); - fetchData(); } catch { notify({ message: 'Failed to send reply', severity: 'error' }); } @@ -211,8 +160,7 @@ const Dashboard: React.FC = () => { const handleDeleteReply = async (replyId: number) => { if (!(await confirm({ message: 'Delete this reply?', danger: true }))) return; try { - await api.delete(`replies/${replyId}/`); - fetchData(); + await deleteReply.mutateAsync(replyId); } catch { notify({ message: 'Failed to delete reply', severity: 'error' }); } diff --git a/frontend/src/pages/FlavorList.tsx b/frontend/src/pages/FlavorList.tsx index 70b3881..11d3b5f 100644 --- a/frontend/src/pages/FlavorList.tsx +++ b/frontend/src/pages/FlavorList.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from 'react'; +import React, { useState } from 'react'; import { Typography, CardContent, @@ -12,78 +12,36 @@ import { CircularProgress, } from '@mui/material'; import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; -import api from '../lib/api'; +import { useCategories, useFlavorsList } from '../api/queries/useFlavorsList'; +import { useCreateRating } from '../api/mutations/useRatingMutations'; import RatingBadge from '../components/RatingBadge'; import StatusBadge from '../components/StatusBadge'; import { PageShell, HeroBackdrop, GlassCard, SectionHeader } from '../components/ui'; import { useToast } from '../hooks/useToast'; -interface Rating { - id: number; - user: string; - score: number; - comment: string; - created_at: string; -} - -interface Flavor { - id: number; - name: string; - category_name: string; - description: string; - average_rating: number; - user_rating: number | null; - ratings: Rating[]; - image_url: string | null; - is_available: boolean; - is_legacy?: boolean; - shop_url: string | null; -} - -interface Category { - id: number; - name: string; - slug: string; -} - interface RatingInput { score: number; comment: string; } const FlavorList: React.FC = () => { - const [categories, setCategories] = useState([]); - const [flavors, setFlavors] = useState([]); - const [loading, setLoading] = useState(true); + const { data: categories = [], isLoading: catLoading } = useCategories(); + const { data: flavors = [], isLoading: flavLoading } = useFlavorsList(); + const createRating = useCreateRating(); const [ratingInput, setRatingInput] = useState>({}); const { notify } = useToast(); - useEffect(() => { - const fetchData = async () => { - try { - const [catRes, flavRes] = await Promise.all([api.get('categories/'), api.get('flavors/')]); - setCategories(catRes.data); - setFlavors(flavRes.data); - } catch (err) { - console.error(err); - } finally { - setLoading(false); - } - }; - fetchData(); - }, []); + const loading = catLoading || flavLoading; const handleRate = async (flavorId: number) => { const input = ratingInput[flavorId]; if (!input) return; try { - await api.post('ratings/', { + await createRating.mutateAsync({ flavor: flavorId, score: input.score, comment: input.comment, }); - const res = await api.get('flavors/'); - setFlavors(res.data); notify({ message: 'Rating submitted!', severity: 'success' }); } catch { notify({ diff --git a/frontend/src/pages/MainPage.tsx b/frontend/src/pages/MainPage.tsx index 2076acd..19ffbdf 100644 --- a/frontend/src/pages/MainPage.tsx +++ b/frontend/src/pages/MainPage.tsx @@ -16,7 +16,14 @@ import { useTranslation } from 'react-i18next'; import ChevronLeftIcon from '@mui/icons-material/ChevronLeft'; import ChevronRightIcon from '@mui/icons-material/ChevronRight'; import EmojiEventsIcon from '@mui/icons-material/EmojiEvents'; -import api from '../lib/api'; +import { useCategories } from '../api/queries/useFlavorsList'; +import { + useFlavorSearch, + useFollowingFeedTop, + useNewestFlavors, + useRecentReviews, + useTopFlavors, +} from '../api/queries/useMainPageQueries'; import { useTitle } from '../hooks/useTitle'; import RatingBadge from '../components/RatingBadge'; import StatusBadge from '../components/StatusBadge'; @@ -29,50 +36,6 @@ import { FlavorCard, } from '../components/ui'; -interface Category { - id: number; - name: string; - slug: string; -} - -interface Flavor { - id: number; - name: string; - category_name: string; - description: string; - average_rating: number; - image_url: string | null; - is_available: boolean; - is_legacy: boolean; - ratings: FeedRating[]; -} - -interface FeedRating { - id: number; - user: string; - user_avatar: string | null; - flavor: number; - flavor_name: string; - flavor_image: string | null; - flavor_category?: string; - score: number; - comment: string; - created_at: string; - replies?: unknown[]; -} - -interface Review { - id: number; - user: string; - user_avatar: string | null; - flavor_name: string; - flavor_image: string | null; - score: number; - comment: string; - created_at: string; - flavor: number; -} - interface MainPageProps { adminMode?: boolean; } @@ -80,99 +43,34 @@ interface MainPageProps { const MainPage: React.FC = ({ adminMode }) => { const { t } = useTranslation(); const navigate = useNavigate(); - const [topFlavors, setTopFlavors] = useState([]); - const [newestFlavors, setNewestFlavors] = useState([]); - const [recentReviews, setRecentReviews] = useState([]); - const [allFlavors, setAllFlavors] = useState([]); - const [feedRatings, setFeedRatings] = useState([]); - const [categories, setCategories] = useState([]); const [selectedCategorySlug, setSelectedCategorySlug] = useState(''); - const [loading, setLoading] = useState(true); - const [categoryLoading, setCategoryLoading] = useState(false); const [activeIndex, setActiveIndex] = useState(0); const timerRef = useRef | null>(null); - const isFirstRun = useRef(true); const location = useLocation(); const query = new URLSearchParams(location.search).get('q') || ''; const isLoggedIn = !!localStorage.getItem('access'); - useEffect(() => { - const fetchData = async () => { - setLoading(true); - try { - if (query) { - const res = await api.get(`flavors/?search=${encodeURIComponent(query)}`); - setAllFlavors(Array.isArray(res.data) ? res.data : res.data.results || []); - } else { - const requests = [ - api.get('flavors/top/'), - api.get('ratings/recent/'), - api.get('flavors/newest/'), - api.get('categories/'), - ]; + const { data: searchResults = [], isLoading: searchLoading } = useFlavorSearch(query); + const { + data: topFlavors = [], + isLoading: topLoading, + isFetching: topFetching, + } = useTopFlavors(selectedCategorySlug || null); + const { data: newestFlavors = [], isLoading: newestLoading } = useNewestFlavors(); + const { data: recentReviews = [], isLoading: recentLoading } = useRecentReviews(); + const { data: categories = [], isLoading: catLoading } = useCategories(); + const { data: feedRatings = [] } = useFollowingFeedTop(isLoggedIn && !query); - if (isLoggedIn) { - requests.push(api.get('ratings/feed/')); - } - - const results = await Promise.all(requests); - - setTopFlavors( - Array.isArray(results[0].data) ? results[0].data : results[0].data.results || [], - ); - setRecentReviews( - Array.isArray(results[1].data) ? results[1].data : results[1].data.results || [], - ); - setNewestFlavors( - Array.isArray(results[2].data) ? results[2].data : results[2].data.results || [], - ); - setCategories( - Array.isArray(results[3].data) ? results[3].data : results[3].data.results || [], - ); - - if (isLoggedIn && results[4]) { - const feedData = Array.isArray(results[4].data) - ? results[4].data - : results[4].data.results || []; - setFeedRatings(feedData.slice(0, 6)); - } - } - } catch (err) { - console.error(err); - } finally { - setLoading(false); - } - }; - fetchData(); - }, [query, isLoggedIn]); + const allFlavors = searchResults; + const loading = query + ? searchLoading + : topLoading || newestLoading || recentLoading || catLoading; + const categoryLoading = topFetching && !topLoading; useEffect(() => { - if (query) return; - - const fetchTopFlavors = async () => { - setCategoryLoading(true); - try { - const url = selectedCategorySlug - ? `flavors/top/?category=${selectedCategorySlug}` - : 'flavors/top/'; - const res = await api.get(url); - setTopFlavors(Array.isArray(res.data) ? res.data : res.data.results || []); - setActiveIndex(0); - } catch (err) { - console.error(err); - } finally { - setCategoryLoading(false); - } - }; - - if (isFirstRun.current) { - isFirstRun.current = false; - return; - } - - fetchTopFlavors(); - }, [selectedCategorySlug, query]); + setActiveIndex(0); + }, [selectedCategorySlug]); const handleTabChange = (_event: React.SyntheticEvent, newValue: string) => { setSelectedCategorySlug(newValue); diff --git a/frontend/src/pages/PublicProfile.tsx b/frontend/src/pages/PublicProfile.tsx index c870085..2138e18 100644 --- a/frontend/src/pages/PublicProfile.tsx +++ b/frontend/src/pages/PublicProfile.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useState } from 'react'; +import React, { useEffect, useState } from 'react'; import { useParams, useNavigate, Link } from 'react-router-dom'; import { Typography, @@ -26,7 +26,17 @@ import VerifiedIcon from '@mui/icons-material/Verified'; import PersonAddIcon from '@mui/icons-material/PersonAdd'; import PersonRemoveIcon from '@mui/icons-material/PersonRemove'; import ColorThief from 'colorthief'; -import api from '../lib/api'; +import { + usePublicProfile, + type MiniUser, + type PublicProfileRating, +} from '../api/queries/usePublicProfile'; +import { useCurrentUserLite } from '../api/queries/useCurrentUserLite'; +import { + useAddProfileComment, + useDeleteProfileComment, + useFollowToggle, +} from '../api/mutations/useSocialMutations'; import { useTitle } from '../hooks/useTitle'; import { useTranslation } from 'react-i18next'; import RatingBadge from '../components/RatingBadge'; @@ -37,46 +47,6 @@ import { PageShell, GlassCard, GlassSurface, FormCard, EmptyState } from '../com import { useToast } from '../hooks/useToast'; import { useConfirm } from '../hooks/useConfirm'; -interface Rating { - id: number; - flavor: number; - flavor_name: string; - flavor_image: string | null; - category_name: string; - score: number; - comment: string; - created_at: string; -} - -interface ProfileComment { - id: number; - author_username: string; - author_avatar: string | null; - text: string; - created_at: string; -} - -interface MiniUser { - id: number; - username: string; - avatar: string | null; - is_following?: boolean; -} - -interface ProfileData { - id: number; - username: string; - theme: string; - avatar: string | null; - following_count: number; - followers_count: number; - is_following: boolean; - ratings: Rating[]; - comments: ProfileComment[]; - followers: MiniUser[]; - following: MiniUser[]; -} - interface PublicProfileProps { adminMode?: boolean; } @@ -86,38 +56,18 @@ const PublicProfile: React.FC = ({ adminMode }) => { const theme = useTheme(); const { username } = useParams<{ username: string }>(); const navigate = useNavigate(); - const [data, setData] = useState(null); - const [loading, setLoading] = useState(true); + const { data, isLoading: loading } = usePublicProfile(username); + const { data: currentUser } = useCurrentUserLite(); + const followToggle = useFollowToggle(); + const addComment = useAddProfileComment(); + const deleteCommentMutation = useDeleteProfileComment(); const [activeTab, setActiveTab] = useState(0); const [categoryTab, setCategoryTab] = useState(0); - const [currentUser, setCurrentUser] = useState<{ - username: string; - is_superuser: boolean; - } | null>(null); const [palette, setPalette] = useState([]); const [newComment, setNewComment] = useState(''); const { notify } = useToast(); const { confirm } = useConfirm(); - const fetchProfile = useCallback(async () => { - try { - const [profileRes, meRes] = await Promise.all([ - api.get(`users/profile/${username}/`), - localStorage.getItem('access') ? api.get('users/me/') : Promise.resolve({ data: null }), - ]); - setData(profileRes.data); - if (meRes.data) setCurrentUser(meRes.data); - } catch (err) { - console.error(err); - } finally { - setLoading(false); - } - }, [username]); - - useEffect(() => { - fetchProfile(); - }, [fetchProfile]); - useEffect(() => { if (data?.avatar) { const img = new Image(); @@ -156,13 +106,10 @@ const PublicProfile: React.FC = ({ adminMode }) => { const handleFollowToggle = async () => { if (!data) return; try { - if (data.is_following) { - await api.post(`users/${data.id}/unfollow/`); - setData({ ...data, is_following: false, followers_count: data.followers_count - 1 }); - } else { - await api.post(`users/${data.id}/follow/`); - setData({ ...data, is_following: true, followers_count: data.followers_count + 1 }); - } + await followToggle.mutateAsync({ + userId: data.id, + currentlyFollowing: data.is_following, + }); } catch { notify({ message: 'Failed to update follow status. Please login.', severity: 'error' }); } @@ -170,9 +117,10 @@ const PublicProfile: React.FC = ({ adminMode }) => { const handleMiniFollowToggle = async (user: MiniUser) => { try { - if (user.is_following) await api.post(`users/${user.id}/unfollow/`); - else await api.post(`users/${user.id}/follow/`); - fetchProfile(); + await followToggle.mutateAsync({ + userId: user.id, + currentlyFollowing: !!user.is_following, + }); } catch { notify({ message: 'Failed', severity: 'error' }); } @@ -182,9 +130,8 @@ const PublicProfile: React.FC = ({ adminMode }) => { e.preventDefault(); if (!newComment.trim() || !data) return; try { - await api.post(`users/${data.id}/add_comment/`, { text: newComment }); + await addComment.mutateAsync({ userId: data.id, text: newComment }); setNewComment(''); - fetchProfile(); } catch { notify({ message: 'Failed to add comment', severity: 'error' }); } @@ -194,8 +141,7 @@ const PublicProfile: React.FC = ({ adminMode }) => { if (!data) return; if (!(await confirm({ message: 'Delete this comment?', danger: true }))) return; try { - await api.delete(`users/${data.id}/delete_comment/${commentId}/`); - fetchProfile(); + await deleteCommentMutation.mutateAsync({ userId: data.id, commentId }); } catch { notify({ message: 'Failed to delete comment', severity: 'error' }); } @@ -218,15 +164,18 @@ const PublicProfile: React.FC = ({ adminMode }) => { ); - const categoryGroups = data.ratings.reduce>((acc, rating) => { - const cat = rating.category_name || 'Other'; - if (!acc[cat]) acc[cat] = []; - acc[cat].push(rating); - return acc; - }, {}); + const categoryGroups = data.ratings.reduce>( + (acc, rating) => { + const cat = rating.category_name || 'Other'; + if (!acc[cat]) acc[cat] = []; + acc[cat].push(rating); + return acc; + }, + {}, + ); const ratingCategories = ['All', ...Object.keys(categoryGroups)]; - const currentRatings: Rating[] = + const currentRatings: PublicProfileRating[] = categoryTab === 0 ? data.ratings : categoryGroups[ratingCategories[categoryTab]]; const tiers = [ diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index 79dec84..f675a80 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -1,4 +1,15 @@ import React, { useState, useEffect } from 'react'; +import { useMe } from '../api/queries/useMe'; +import { useBannersList } from '../api/queries/useBanners'; +import { + useChangePassword, + useConfirmAccountDeletion, + useConfirmEmail, + useRequestAccountDeletion, + useUpdateAvatar, + useUpdatePreferences, + useUpdateProfile, +} from '../api/mutations/useSettingsMutations'; import { Typography, Box, @@ -16,7 +27,6 @@ import { } from '@mui/material'; import ArrowBackIcon from '@mui/icons-material/ArrowBack'; import { useNavigate } from 'react-router-dom'; -import api from '../lib/api'; import { useTranslation } from 'react-i18next'; import { useTitle } from '../hooks/useTitle'; import type { CatppuccinTheme } from '../theme'; @@ -28,11 +38,6 @@ interface SettingsProps { onThemeChange: (newTheme: CatppuccinTheme) => void; } -interface Banner { - id: number; - name: string; -} - const readErr = (err: unknown): string | undefined => (err as { response?: { data?: { error?: string } } }).response?.data?.error; @@ -41,6 +46,16 @@ const Settings: React.FC = ({ themeName, onThemeChange }) => { const navigate = useNavigate(); useTitle(t('nav.settings')); const { confirm } = useConfirm(); + const { data: me } = useMe(); + const { data: banners = [] } = useBannersList(); + const updateAvatarMutation = useUpdateAvatar(); + const updatePrefs = useUpdatePreferences(); + const updateProfileMutation = useUpdateProfile(); + const confirmEmailMutation = useConfirmEmail(); + const changePasswordMutation = useChangePassword(); + const requestDeletion = useRequestAccountDeletion(); + const confirmDeletion = useConfirmAccountDeletion(); + const [username, setUsername] = useState(''); const [email, setEmail] = useState(''); const [currentEmail, setCurrentEmail] = useState(''); @@ -53,7 +68,6 @@ const Settings: React.FC = ({ themeName, onThemeChange }) => { const [deletionCode, setDeletionCode] = useState(''); const [isDeleting, setIsDeleting] = useState(false); const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null); - const [banners, setBanners] = useState([]); const [selectedBannerId, setSelectedBannerId] = useState(''); const handleGoBack = () => { @@ -62,33 +76,14 @@ const Settings: React.FC = ({ themeName, onThemeChange }) => { }; useEffect(() => { - const fetchUser = async () => { - try { - const [userRes, bannersRes] = await Promise.all([ - api.get('users/me/'), - api.get('banners/'), - ]); - - setUsername(userRes.data.username); - setEmail(userRes.data.email); - setCurrentEmail(userRes.data.email); - setAvatar(userRes.data.avatar); - setSelectedBannerId(userRes.data.selected_banner || ''); - - if (userRes.data.language) { - setLanguage(userRes.data.language); - } - - const bannersData: Banner[] = Array.isArray(bannersRes.data) - ? bannersRes.data - : bannersRes.data.results || []; - setBanners(bannersData); - } catch (err) { - console.error(err); - } - }; - fetchUser(); - }, []); + if (!me) return; + setUsername(me.username); + setEmail(me.email); + setCurrentEmail(me.email); + setAvatar(me.avatar); + setSelectedBannerId(me.selected_banner || ''); + if (me.language) setLanguage(me.language); + }, [me]); const handleAvatarUpload = async (e: React.ChangeEvent) => { const file = e.target.files?.[0]; @@ -99,13 +94,8 @@ const Settings: React.FC = ({ themeName, onThemeChange }) => { return; } - const formData = new FormData(); - formData.append('avatar', file); - try { - const res = await api.post('users/update_avatar/', formData, { - headers: { 'Content-Type': 'multipart/form-data' }, - }); + const res = await updateAvatarMutation.mutateAsync(file); setAvatar(res.data.avatar); setMessage({ type: 'success', text: t('settings.avatarSuccess') }); } catch (err) { @@ -117,7 +107,7 @@ const Settings: React.FC = ({ themeName, onThemeChange }) => { setLanguage(newLang); i18n.changeLanguage(newLang); try { - await api.patch('users/update_preferences/', { language: newLang }); + await updatePrefs.mutateAsync({ language: newLang }); } catch { console.error('Failed to update language on server'); } @@ -126,7 +116,7 @@ const Settings: React.FC = ({ themeName, onThemeChange }) => { const handleUpdateProfile = async (e: React.FormEvent) => { e.preventDefault(); try { - const res = await api.patch('users/update_profile/', { username, email }); + const res = await updateProfileMutation.mutateAsync({ username, email }); if (res.data.message) { setPendingConfirmation(true); setMessage({ type: 'success', text: `${t('settings.updateSuccess')} ${res.data.message}` }); @@ -142,7 +132,7 @@ const Settings: React.FC = ({ themeName, onThemeChange }) => { const handleConfirmEmail = async (e: React.FormEvent) => { e.preventDefault(); try { - const res = await api.post('users/confirm_email/', { code: confirmationCode }); + const res = await confirmEmailMutation.mutateAsync(confirmationCode); setMessage({ type: 'success', text: t('settings.updateSuccess') }); setCurrentEmail(res.data.email); setPendingConfirmation(false); @@ -155,7 +145,7 @@ const Settings: React.FC = ({ themeName, onThemeChange }) => { const handleChangePassword = async (e: React.FormEvent) => { e.preventDefault(); try { - await api.post('users/change_password/', { + await changePasswordMutation.mutateAsync({ old_password: oldPassword, new_password: newPassword, }); @@ -169,7 +159,7 @@ const Settings: React.FC = ({ themeName, onThemeChange }) => { const handleRequestDeletion = async () => { try { - await api.post('users/request_account_deletion/'); + await requestDeletion.mutateAsync(); setIsDeleting(true); setMessage({ type: 'success', text: t('settings.codeSent') }); } catch { @@ -187,7 +177,7 @@ const Settings: React.FC = ({ themeName, onThemeChange }) => { }); if (!ok) return; try { - await api.post('users/confirm_account_deletion/', { code: deletionCode }); + await confirmDeletion.mutateAsync(deletionCode); localStorage.removeItem('access'); localStorage.removeItem('refresh'); window.location.href = '/'; @@ -281,7 +271,7 @@ const Settings: React.FC = ({ themeName, onThemeChange }) => { const newId = e.target.value; setSelectedBannerId(newId); try { - await api.patch('users/update_preferences/', { selected_banner: newId }); + await updatePrefs.mutateAsync({ selected_banner: newId }); } catch { console.error('Failed to update banner preference'); } diff --git a/frontend/src/pages/Support.tsx b/frontend/src/pages/Support.tsx index 7bb53f0..9a2d420 100644 --- a/frontend/src/pages/Support.tsx +++ b/frontend/src/pages/Support.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useCallback } from 'react'; +import React, { useState, useMemo } from 'react'; import { Typography, Box, @@ -21,48 +21,21 @@ import DeleteIcon from '@mui/icons-material/Delete'; import ArrowBackIcon from '@mui/icons-material/ArrowBack'; import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router-dom'; -import api from '../lib/api'; +import { useTickets } from '../api/queries/useTickets'; +import { useMe } from '../api/queries/useMe'; +import { useNotificationsQuery } from '../api/queries/useNotifications'; +import { + useAddTicketMessage, + useCreateTicket, + useDeleteTicket, + useUpdateTicketStatus, +} from '../api/mutations/useTicketMutations'; import { useTitle } from '../hooks/useTitle'; import { formatDate } from '../utils/date'; import { PageShell, SectionHeader, GlassCard, FormCard, EmptyState } from '../components/ui'; import { useToast } from '../hooks/useToast'; import { useConfirm } from '../hooks/useConfirm'; -interface Message { - id: number; - username: string; - text: string; - created_at: string; - is_admin: boolean; -} - -type TicketStatus = 'open' | 'in_progress' | 'resolved' | 'closed'; - -interface Ticket { - id: number; - user: number; - username: string; - user_email: string; - user_avatar: string | null; - subject: string; - description: string; - status: TicketStatus; - created_at: string; - messages: Message[]; -} - -interface CurrentUser { - id: number; - username: string; - is_superuser: boolean; -} - -interface NotificationDTO { - is_read: boolean; - notification_type: string; - ticket: number | null; -} - type ChipStatusColor = 'error' | 'warning' | 'success' | 'default'; type ButtonStatusColor = 'error' | 'warning' | 'success' | 'primary'; @@ -71,47 +44,36 @@ const Support: React.FC = () => { const theme = useTheme(); const navigate = useNavigate(); useTitle(t('support.title')); - const [tickets, setTickets] = useState([]); - const [loading, setLoading] = useState(true); - const [showCreate, setShowCreate] = useState(false); + const { data: tickets = [], isLoading: loading } = useTickets(); + const { data: currentUser = null } = useMe(); + const { data: notifications = [] } = useNotificationsQuery(true); + const createTicketMutation = useCreateTicket(); + const addMessage = useAddTicketMessage(); + const updateStatusMutation = useUpdateTicketStatus(); + const deleteTicketMutation = useDeleteTicket(); + const [showCreate, setShowCreate] = useState(false); const [subject, setSubject] = useState(''); const [description, setDescription] = useState(''); - const [replyText, setReplyText] = useState>({}); const [expandedId, setExpandedId] = useState(null); + const [readTicketIds, setReadTicketIds] = useState>(new Set()); const { notify } = useToast(); const { confirm } = useConfirm(); - const [currentUser, setCurrentUser] = useState(null); - const [unreadTicketIds, setUnreadTicketIds] = useState>(new Set()); - const fetchTickets = useCallback(async () => { - try { - const [ticketRes, userRes, notifRes] = await Promise.all([ - api.get('tickets/'), - api.get('users/me/'), - api.get('notifications/'), - ]); - setTickets(ticketRes.data.results || ticketRes.data); - setCurrentUser(userRes.data); - - const notifs: NotificationDTO[] = notifRes.data.results || notifRes.data; - const unreadIds = new Set( - notifs - .filter((n) => !n.is_read && n.notification_type.startsWith('ticket') && n.ticket != null) - .map((n) => n.ticket as number), - ); - setUnreadTicketIds(unreadIds); - } catch (err) { - console.error(err); - } finally { - setLoading(false); - } - }, []); - - useEffect(() => { - fetchTickets(); - }, [fetchTickets]); + const unreadTicketIds = useMemo(() => { + const ids = new Set(); + notifications.forEach((n) => { + // Notification carries .reply or .rating in its current shape; tickets + // arrive as ticket_new / ticket_reply with the ticket id encoded in + // .reply (legacy field name re-purposed by the serializer). + const ticketId = (n as unknown as { ticket?: number | null }).ticket; + if (!n.is_read && n.notification_type.startsWith('ticket') && ticketId != null) { + if (!readTicketIds.has(ticketId)) ids.add(ticketId); + } + }); + return ids; + }, [notifications, readTicketIds]); const handleExpand = (ticketId: number) => { if (expandedId === ticketId) { @@ -120,20 +82,17 @@ const Support: React.FC = () => { } setExpandedId(ticketId); if (unreadTicketIds.has(ticketId)) { - const newUnread = new Set(unreadTicketIds); - newUnread.delete(ticketId); - setUnreadTicketIds(newUnread); + setReadTicketIds((prev) => new Set(prev).add(ticketId)); } }; const handleCreate = async (e: React.FormEvent) => { e.preventDefault(); try { - await api.post('tickets/', { subject, description }); + await createTicketMutation.mutateAsync({ subject, description }); setSubject(''); setDescription(''); setShowCreate(false); - fetchTickets(); } catch { notify({ message: 'Failed to create ticket', severity: 'error' }); } @@ -143,9 +102,8 @@ const Support: React.FC = () => { const text = replyText[ticketId]; if (!text) return; try { - await api.post(`tickets/${ticketId}/add_message/`, { text }); + await addMessage.mutateAsync({ ticketId, text }); setReplyText({ ...replyText, [ticketId]: '' }); - fetchTickets(); } catch { notify({ message: 'Failed to send message', severity: 'error' }); } @@ -153,8 +111,7 @@ const Support: React.FC = () => { const handleUpdateStatus = async (ticketId: number, status: string) => { try { - await api.post(`tickets/${ticketId}/update_status/`, { status }); - fetchTickets(); + await updateStatusMutation.mutateAsync({ ticketId, status }); } catch { notify({ message: 'Failed to update status', severity: 'error' }); } @@ -163,8 +120,7 @@ const Support: React.FC = () => { const handleDeleteTicket = async (ticketId: number) => { if (!(await confirm({ message: 'Delete this ticket?', danger: true }))) return; try { - await api.delete(`tickets/${ticketId}/`); - fetchTickets(); + await deleteTicketMutation.mutateAsync(ticketId); } catch { notify({ message: 'Failed to delete ticket', severity: 'error' }); } diff --git a/frontend/src/pages/categories/CategoryFlavors.tsx b/frontend/src/pages/categories/CategoryFlavors.tsx index 8340e77..c468442 100644 --- a/frontend/src/pages/categories/CategoryFlavors.tsx +++ b/frontend/src/pages/categories/CategoryFlavors.tsx @@ -1,10 +1,10 @@ -import React, { useEffect, useState } from 'react'; +import React from 'react'; import { useParams, useNavigate } from 'react-router-dom'; import { Box, CircularProgress, Button } from '@mui/material'; import ArrowBackIcon from '@mui/icons-material/ArrowBack'; -import api from '../../lib/api'; import { useTitle } from '../../hooks/useTitle'; import { useTranslation } from 'react-i18next'; +import { useCategoryFlavors } from '../../api/queries/useCategoryFlavors'; import { PageShell, HeroBackdrop, @@ -13,35 +13,12 @@ import { EmptyState, } from '../../components/ui'; -interface Rating { - id: number; - user: string; - score: number; - comment: string; - created_at: string; -} - -interface Flavor { - id: number; - name: string; - category_name: string; - description: string; - average_rating: number; - user_rating: number | null; - ratings: Rating[]; - image_url: string | null; - is_available: boolean; - is_legacy: boolean; - shop_url: string | null; -} - const CategoryFlavors: React.FC = () => { const { t } = useTranslation(); const { slug } = useParams<{ slug: string }>(); const navigate = useNavigate(); - const [flavors, setFlavors] = useState([]); - const [categoryName, setCategoryName] = useState(''); - const [loading, setLoading] = useState(true); + const { data: flavors = [], isLoading: loading } = useCategoryFlavors(slug); + const categoryName = flavors[0]?.category_name || ''; const handleGoBack = () => { if (window.history.length > 1) { @@ -51,25 +28,6 @@ const CategoryFlavors: React.FC = () => { } }; - useEffect(() => { - const fetchFlavors = async () => { - setLoading(true); - try { - const res = await api.get(`flavors/?category__slug=${slug}`); - const data: Flavor[] = Array.isArray(res.data) ? res.data : res.data.results || []; - setFlavors(data); - if (data.length > 0) { - setCategoryName(data[0].category_name); - } - } catch (err) { - console.error(err); - } finally { - setLoading(false); - } - }; - fetchFlavors(); - }, [slug]); - useTitle(categoryName || 'Flavors'); if (loading) diff --git a/frontend/src/pages/flavors/FlavorDetail.tsx b/frontend/src/pages/flavors/FlavorDetail.tsx index 52d7845..e0ee52f 100644 --- a/frontend/src/pages/flavors/FlavorDetail.tsx +++ b/frontend/src/pages/flavors/FlavorDetail.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useState } from 'react'; +import React, { useEffect, useState } from 'react'; import { useParams, Link, useNavigate } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { @@ -20,7 +20,17 @@ import { Switch, Divider, } from '@mui/material'; -import api from '../../lib/api'; +import { useFlavorDetail, type FlavorDetailRating } from '../../api/queries/useFlavorDetail'; +import { useCurrentUserLite } from '../../api/queries/useCurrentUserLite'; +import { useUpdateFlavor } from '../../api/mutations/useFlavorMutations'; +import { + useCreateRating, + useDeleteRating, + useCreateReply, + useDeleteReply, + useUpdateRating, +} from '../../api/mutations/useRatingMutations'; +import { useUpdateReply } from '../../api/mutations/useReplyMutations'; import { useTitle } from '../../hooks/useTitle'; import ArrowBackIcon from '@mui/icons-material/ArrowBack'; import ShoppingCartIcon from '@mui/icons-material/ShoppingCart'; @@ -38,38 +48,6 @@ import { PageShell, HeroBackdrop, GlassCard, SectionHeader, EmptyState } from '. import { useToast } from '../../hooks/useToast'; import { useConfirm } from '../../hooks/useConfirm'; -interface Reply { - id: number; - user: string; - text: string; - created_at: string; -} - -interface Rating { - id: number; - user: string; - user_avatar: string | null; - score: number; - comment: string; - created_at: string; - replies: Reply[]; -} - -interface Flavor { - id: number; - name: string; - category_name: string; - category_slug: string; - description: string; - average_rating: number; - user_rating: number | null; - ratings: Rating[]; - image_url: string | null; - is_available: boolean; - is_legacy: boolean; - shop_url: string | null; -} - interface FlavorDetailProps { adminMode?: boolean; } @@ -81,11 +59,18 @@ const FlavorDetail: React.FC = ({ adminMode }) => { const navigate = useNavigate(); const { notify } = useToast(); const { confirm } = useConfirm(); - const [flavor, setFlavor] = useState(null); - const [loading, setLoading] = useState(true); + const { data: flavor, isLoading: loading } = useFlavorDetail(id); + const { data: meData } = useCurrentUserLite(); + const currentUser = meData?.username ?? null; + const updateFlavor = useUpdateFlavor(); + const createRating = useCreateRating(); + const deleteRatingMutation = useDeleteRating(); + const updateRating = useUpdateRating(); + const createReply = useCreateReply(); + const updateReply = useUpdateReply(); + const deleteReplyMutation = useDeleteReply(); const [replyInputs, setReplyInputs] = useState<{ [key: number]: string }>({}); const [expandedReplies, setExpandedReplies] = useState<{ [key: number]: boolean }>({}); - const [currentUser, setCurrentUser] = useState(null); // Product Edit state (Admin) const [isAdminEditing, setIsAdminEditing] = useState(false); @@ -111,44 +96,25 @@ const FlavorDetail: React.FC = ({ adminMode }) => { const [editingReplyId, setEditingReplyId] = useState(null); const [editReplyText, setEditReplyText] = useState(''); - const fetchFlavor = useCallback(async () => { - try { - const res = await api.get(`flavors/${id}/`); - setFlavor(res.data); - setEditFlavorData({ - name: res.data.name, - description: res.data.description, - shop_url: res.data.shop_url || '', - is_available: res.data.is_available, - is_legacy: res.data.is_legacy, - }); - } catch (err) { - console.error(err); - } finally { - setLoading(false); - } - }, [id]); - useEffect(() => { - const getUser = async () => { - try { - const res = await api.get('users/me/'); - setCurrentUser(res.data.username); - } catch { - /* ignore */ - } - }; - getUser(); - fetchFlavor(); - }, [fetchFlavor]); + if (flavor) { + setEditFlavorData({ + name: flavor.name, + description: flavor.description, + shop_url: flavor.shop_url || '', + is_available: flavor.is_available, + is_legacy: flavor.is_legacy, + }); + } + }, [flavor]); useTitle(flavor?.name || t('common.loading')); const handleAdminUpdate = async () => { + if (!id) return; try { - await api.patch(`flavors/${id}/`, editFlavorData); + await updateFlavor.mutateAsync({ id, data: editFlavorData }); setIsAdminEditing(false); - fetchFlavor(); } catch { notify({ message: 'Admin update failed', severity: 'error' }); } @@ -156,17 +122,14 @@ const FlavorDetail: React.FC = ({ adminMode }) => { const handleImageUpload = async (e: React.ChangeEvent) => { const file = e.target.files?.[0]; - if (!file) return; + if (!file || !id) return; const formData = new FormData(); formData.append('image', file); try { setUploadingImage(true); - await api.patch(`flavors/${id}/`, formData, { - headers: { 'Content-Type': 'multipart/form-data' }, - }); - fetchFlavor(); + await updateFlavor.mutateAsync({ id, data: formData, isFormData: true }); } catch { notify({ message: 'Image upload failed', severity: 'error' }); } finally { @@ -180,11 +143,11 @@ const FlavorDetail: React.FC = ({ adminMode }) => { notify({ message: 'Please select a score', severity: 'warning' }); return; } + if (!flavor) return; try { - await api.post('ratings/', { flavor: flavor?.id, score: newScore, comment: newComment }); + await createRating.mutateAsync({ flavor: flavor.id, score: newScore, comment: newComment }); setNewScore(null); setNewComment(''); - fetchFlavor(); } catch (err) { const msg = (err as { response?: { data?: { error?: string } } }).response?.data?.error; notify({ message: msg || 'Failed to submit rating', severity: 'error' }); @@ -195,10 +158,9 @@ const FlavorDetail: React.FC = ({ adminMode }) => { const text = replyInputs[ratingId]; if (!text) return; try { - await api.post(`ratings/${ratingId}/reply/`, { text }); + await createReply.mutateAsync({ ratingId, text }); setReplyInputs({ ...replyInputs, [ratingId]: '' }); setExpandedReplies((prev) => ({ ...prev, [ratingId]: true })); - fetchFlavor(); } catch { notify({ message: 'Failed to submit reply', severity: 'error' }); } @@ -207,9 +169,8 @@ const FlavorDetail: React.FC = ({ adminMode }) => { const handleUpdateReply = async (replyId: number) => { if (!editReplyText) return; try { - await api.patch(`replies/${replyId}/`, { text: editReplyText }); + await updateReply.mutateAsync({ replyId, text: editReplyText }); setEditingReplyId(null); - fetchFlavor(); } catch { notify({ message: 'Failed to update reply', severity: 'error' }); } @@ -218,8 +179,7 @@ const FlavorDetail: React.FC = ({ adminMode }) => { const handleDeleteReply = async (replyId: number) => { if (!(await confirm({ message: 'Delete this reply?', danger: true }))) return; try { - await api.delete(`replies/${replyId}/`); - fetchFlavor(); + await deleteReplyMutation.mutateAsync(replyId); } catch { notify({ message: 'Failed to delete reply', severity: 'error' }); } @@ -228,14 +188,13 @@ const FlavorDetail: React.FC = ({ adminMode }) => { const handleDeleteRating = async (ratingId: number) => { if (!(await confirm({ message: 'Delete this review?', danger: true }))) return; try { - await api.delete(`ratings/${ratingId}/`); - fetchFlavor(); + await deleteRatingMutation.mutateAsync(ratingId); } catch { notify({ message: 'Failed to delete review', severity: 'error' }); } }; - const startEdit = (rating: Rating) => { + const startEdit = (rating: FlavorDetailRating) => { setEditMode(rating.id); setEditScore(rating.score); setEditComment(rating.comment || ''); @@ -243,9 +202,8 @@ const FlavorDetail: React.FC = ({ adminMode }) => { const handleUpdateRating = async (ratingId: number) => { try { - await api.patch(`ratings/${ratingId}/`, { score: editScore, comment: editComment }); + await updateRating.mutateAsync({ id: ratingId, score: editScore, comment: editComment }); setEditMode(null); - fetchFlavor(); } catch { notify({ message: 'Failed to update review', severity: 'error' }); } @@ -544,7 +502,7 @@ const FlavorDetail: React.FC = ({ adminMode }) => { {flavor.ratings.length === 0 ? ( ) : ( - flavor.ratings.map((rating: Rating) => ( + flavor.ratings.map((rating: FlavorDetailRating) => ( {editMode === rating.id ? ( diff --git a/frontend/src/test/queryWrapper.tsx b/frontend/src/test/queryWrapper.tsx new file mode 100644 index 0000000..2ef238b --- /dev/null +++ b/frontend/src/test/queryWrapper.tsx @@ -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()) => ( + {children} +);