Skip to content

Commit c678166

Browse files
sumi-0011sumi
andauthored
[실험실] 업보트 기능 추가 (git-goods#348)
Co-authored-by: sumi <sumi@sumiui-MacBookAir.local>
1 parent 7aa6984 commit c678166

19 files changed

Lines changed: 1167 additions & 53 deletions

File tree

.serena/.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
/cache

.vscode/settings.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,5 +11,5 @@
1111
}
1212
],
1313
"typescript.tsdk": "node_modules/typescript/lib",
14-
"cSpell.words": ["hyesungoh"]
14+
"cSpell.words": ["hyesungoh", "supabase"]
1515
}

CLAUDE.md

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,10 +79,65 @@ pnpm --filter @gitanimals/ui-panda storybook # Start Storybook
7979
- Shared utilities from `@gitanimals/util-common`
8080

8181
**State Management:**
82-
- Server state: Tanstack Query with custom hooks in `src/apis/`
82+
- Server state: Tanstack Query v5 with `queryOptions` pattern in `src/apis/`
8383
- Client state: Jotai for atomic state, Zustand for stores
8484
- Auth state managed through NextAuth.js
8585

86+
**Tanstack Query v5 Best Practices:**
87+
- Always use `queryOptions` factory pattern for reusable query definitions
88+
- Group related queryOptions into a single exported object
89+
- Use `useQuery` directly with queryOptions in components (no custom hooks needed)
90+
- Example pattern:
91+
```typescript
92+
// src/apis/user/queries.ts
93+
import { queryOptions } from '@tanstack/react-query';
94+
95+
export const USER_QUERY_KEYS = {
96+
all: ['user'] as const,
97+
detail: (userId: string) => ['user', userId] as const,
98+
list: () => ['user', 'list'] as const,
99+
};
100+
101+
// Define queryOptions factories
102+
const getUserOptions = (userId: string) =>
103+
queryOptions({
104+
queryKey: USER_QUERY_KEYS.detail(userId),
105+
queryFn: () => fetchUser(userId),
106+
enabled: !!userId,
107+
});
108+
109+
const getUserListOptions = () =>
110+
queryOptions({
111+
queryKey: USER_QUERY_KEYS.list(),
112+
queryFn: fetchUserList,
113+
});
114+
115+
// Export grouped queryOptions
116+
export const userQueryOptions = {
117+
getUser: getUserOptions,
118+
getUserList: getUserListOptions,
119+
};
120+
121+
// Usage in component
122+
import { useQuery } from '@tanstack/react-query';
123+
import { userQueryOptions } from '@/apis/user/queries';
124+
125+
function UserProfile({ userId }: { userId: string }) {
126+
const { data: user } = useQuery(userQueryOptions.getUser(userId));
127+
const { data: users } = useQuery(userQueryOptions.getUserList());
128+
// ...
129+
}
130+
131+
// Usage in Server Components (prefetching)
132+
await queryClient.prefetchQuery(userQueryOptions.getUser('123'));
133+
```
134+
- Benefits:
135+
- Type safety with auto-completion
136+
- Reusability in components, prefetching, SSR
137+
- Easier testing and mocking
138+
- Centralized query key management
139+
- No need for custom hooks unless adding extra logic
140+
86141
**Styling Approach:**
87142
- PandaCSS with `styled-system` generation
88143
- Shadow Panda preset for enhanced component styling

apps/web/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
"@radix-ui/react-slot": "^1.1.0",
3535
"@radix-ui/react-tooltip": "^1.1.6",
3636
"@shadow-panda/style-context": "^0.7.1",
37+
"@supabase/supabase-js": "^2.75.0",
3738
"@suspensive/react": "^2.17.1",
3839
"@tanstack/react-query": "*",
3940
"@vercel/analytics": "^1.5.0",
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
import { supabase } from '@/lib/supabase/client';
2+
3+
export interface LaboratoryUpvote {
4+
id: string;
5+
user_id: string;
6+
username: string;
7+
laboratory_id: string;
8+
description: string | null;
9+
created_at: string;
10+
updated_at: string;
11+
}
12+
13+
export interface CreateUpvoteRequest {
14+
user_id: string;
15+
username: string;
16+
laboratory_id: string;
17+
}
18+
19+
export interface CheckUpvoteResponse {
20+
hasUpvoted: boolean;
21+
upvote?: LaboratoryUpvote;
22+
}
23+
24+
/**
25+
* Create or update laboratory upvote
26+
*/
27+
export async function createOrUpdateUpvote(request: CreateUpvoteRequest): Promise<LaboratoryUpvote> {
28+
// Check if upvote already exists for this user + laboratory combination
29+
const { data: existingUpvote, error: checkError } = await supabase
30+
.from('laboratory_feedback')
31+
.select('*')
32+
.eq('user_id', request.user_id)
33+
.eq('laboratory_id', request.laboratory_id)
34+
.maybeSingle();
35+
36+
if (checkError) {
37+
throw new Error(`Failed to check existing upvote: ${checkError.message}`);
38+
}
39+
40+
// If upvote exists, update timestamp
41+
if (existingUpvote) {
42+
const typedUpvote = existingUpvote as LaboratoryUpvote;
43+
const { data, error } = await supabase
44+
.from('laboratory_feedback')
45+
.update({
46+
updated_at: new Date().toISOString(),
47+
})
48+
.eq('id', typedUpvote.id)
49+
.select()
50+
.single();
51+
52+
if (error || !data) {
53+
throw new Error(`Failed to update upvote: ${error?.message ?? 'No data returned'}`);
54+
}
55+
56+
return data as LaboratoryUpvote;
57+
}
58+
59+
// Otherwise, create new upvote
60+
const { data, error } = await supabase
61+
.from('laboratory_feedback')
62+
.insert({
63+
user_id: request.user_id,
64+
username: request.username,
65+
laboratory_id: request.laboratory_id,
66+
description: null,
67+
})
68+
.select()
69+
.single();
70+
71+
if (error || !data) {
72+
throw new Error(`Failed to create upvote: ${error?.message ?? 'No data returned'}`);
73+
}
74+
75+
return data as LaboratoryUpvote;
76+
}
77+
78+
/**
79+
* Check if user has already upvoted for a specific laboratory
80+
*/
81+
export async function checkUserUpvote(userId: string, laboratoryId: string): Promise<CheckUpvoteResponse> {
82+
const { data, error } = await supabase
83+
.from('laboratory_feedback')
84+
.select('*')
85+
.eq('user_id', userId)
86+
.eq('laboratory_id', laboratoryId)
87+
.maybeSingle();
88+
89+
if (error) {
90+
throw new Error(`Failed to check user upvote: ${error.message}`);
91+
}
92+
93+
return {
94+
hasUpvoted: !!data,
95+
upvote: data ? (data as LaboratoryUpvote) : undefined,
96+
};
97+
}
98+
99+
/**
100+
* Get all laboratory upvotes for a specific laboratory
101+
*/
102+
export async function getAllUpvotes(laboratoryId: string): Promise<LaboratoryUpvote[]> {
103+
const { data, error } = await supabase
104+
.from('laboratory_feedback')
105+
.select('*')
106+
.eq('laboratory_id', laboratoryId)
107+
.order('created_at', { ascending: false });
108+
109+
if (error || !data) {
110+
throw new Error(`Failed to get upvotes: ${error?.message ?? 'No data returned'}`);
111+
}
112+
113+
return data as LaboratoryUpvote[];
114+
}
115+
116+
/**
117+
* Get upvote count for a specific laboratory
118+
*/
119+
export async function getUpvoteCount(laboratoryId: string): Promise<number> {
120+
const { count, error } = await supabase
121+
.from('laboratory_feedback')
122+
.select('*', { count: 'exact', head: true })
123+
.eq('laboratory_id', laboratoryId);
124+
125+
if (error) {
126+
throw new Error(`Failed to get upvote count: ${error.message}`);
127+
}
128+
129+
return count ?? 0;
130+
}
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { queryOptions } from '@tanstack/react-query';
2+
3+
import { checkUserUpvote, getAllUpvotes, getUpvoteCount } from './feedback';
4+
5+
export const LABORATORY_FEEDBACK_QUERY_KEYS = {
6+
all: ['laboratory-upvote'] as const,
7+
byLab: (laboratoryId: string) => ['laboratory-upvote', 'lab', laboratoryId] as const,
8+
count: (laboratoryId: string) => ['laboratory-upvote', 'count', laboratoryId] as const,
9+
userCheck: (userId: string, laboratoryId: string) => ['laboratory-upvote', 'user', userId, 'lab', laboratoryId] as const,
10+
};
11+
12+
/**
13+
* Query options for checking if user has upvoted for a specific laboratory
14+
*/
15+
const checkUserUpvoteOptions = (userId: string, laboratoryId: string) =>
16+
queryOptions({
17+
queryKey: LABORATORY_FEEDBACK_QUERY_KEYS.userCheck(userId, laboratoryId),
18+
queryFn: () => checkUserUpvote(userId, laboratoryId),
19+
enabled: !!userId && !!laboratoryId,
20+
});
21+
22+
/**
23+
* Query options for getting all laboratory upvotes for a specific laboratory
24+
*/
25+
const getAllUpvotesOptions = (laboratoryId: string) =>
26+
queryOptions({
27+
queryKey: LABORATORY_FEEDBACK_QUERY_KEYS.byLab(laboratoryId),
28+
queryFn: () => getAllUpvotes(laboratoryId),
29+
enabled: !!laboratoryId,
30+
});
31+
32+
/**
33+
* Query options for getting upvote count for a specific laboratory
34+
*/
35+
const getUpvoteCountOptions = (laboratoryId: string) =>
36+
queryOptions({
37+
queryKey: LABORATORY_FEEDBACK_QUERY_KEYS.count(laboratoryId),
38+
queryFn: () => getUpvoteCount(laboratoryId),
39+
enabled: !!laboratoryId,
40+
});
41+
42+
export const upvoteQueryOptions = {
43+
checkUserUpvote: checkUserUpvoteOptions,
44+
getAllUpvotes: getAllUpvotesOptions,
45+
getUpvoteCount: getUpvoteCountOptions,
46+
};

0 commit comments

Comments
 (0)