Skip to content

Commit a5919fa

Browse files
tag modifications
1 parent 2427516 commit a5919fa

File tree

3 files changed

+297
-0
lines changed

3 files changed

+297
-0
lines changed

frontend/src/components/AdminSidebar.tsx

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,20 @@ const AdminSidebar = () => {
125125
</svg>
126126
),
127127
},
128+
{
129+
name: 'Tags',
130+
path: '/admin/tags',
131+
icon: (
132+
<svg className='w-5 h-5' fill='none' stroke='currentColor' viewBox='0 0 24 24'>
133+
<path
134+
strokeLinecap='round'
135+
strokeLinejoin='round'
136+
strokeWidth={2}
137+
d='M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z'
138+
/>
139+
</svg>
140+
),
141+
},
128142
];
129143

130144
const pageEditItems = [
Lines changed: 274 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,274 @@
1+
import { useState, useEffect } from 'react';
2+
import { useAuth } from '@clerk/clerk-react';
3+
import AdminSidebar from '../../../components/AdminSidebar';
4+
import Toast from '../../../components/Toast';
5+
import ConfirmModal from '../../../components/ConfirmModal';
6+
import { API_BASE_URL } from '../../../config/api';
7+
8+
type Tag = {
9+
id: string;
10+
name: string;
11+
_count?: {
12+
announcements: number;
13+
};
14+
};
15+
16+
const Tags = () => {
17+
const { getToken } = useAuth();
18+
const [tags, setTags] = useState<Tag[]>([]);
19+
const [loading, setLoading] = useState(true);
20+
const [newTagName, setNewTagName] = useState('');
21+
const [toast, setToast] = useState<{ message: string; type: 'success' | 'error' } | null>(null);
22+
const [submitting, setSubmitting] = useState(false);
23+
const [isCollapsed, setIsCollapsed] = useState(false);
24+
const [selectedTagIds, setSelectedTagIds] = useState<string[]>([]);
25+
const [isDeleting, setIsDeleting] = useState(false);
26+
const [showConfirmModal, setShowConfirmModal] = useState(false);
27+
28+
useEffect(() => {
29+
fetchTags();
30+
}, []);
31+
32+
const fetchTags = async () => {
33+
try {
34+
setLoading(true);
35+
const token = await getToken();
36+
const response = await fetch(`${API_BASE_URL}/api/tags`, {
37+
headers: {
38+
Authorization: `Bearer ${token}`,
39+
},
40+
});
41+
42+
if (!response.ok) {
43+
throw new Error('Failed to fetch tags');
44+
}
45+
46+
const data = await response.json();
47+
setTags(data);
48+
} catch (err: any) {
49+
console.error('Error fetching tags:', err);
50+
setToast({ message: err.message || 'Failed to load tags', type: 'error' });
51+
} finally {
52+
setLoading(false);
53+
}
54+
};
55+
56+
const handleAddTag = async (e: React.FormEvent) => {
57+
e.preventDefault();
58+
59+
if (!newTagName.trim()) {
60+
setToast({ message: 'Please enter a tag name', type: 'error' });
61+
return;
62+
}
63+
64+
try {
65+
setSubmitting(true);
66+
const token = await getToken();
67+
const response = await fetch(`${API_BASE_URL}/api/tags`, {
68+
method: 'POST',
69+
headers: {
70+
'Content-Type': 'application/json',
71+
Authorization: `Bearer ${token}`,
72+
},
73+
body: JSON.stringify({ name: newTagName.trim() }),
74+
});
75+
76+
if (!response.ok) {
77+
const errorData = await response.json();
78+
throw new Error(errorData.error || 'Failed to create tag');
79+
}
80+
81+
setToast({ message: 'Tag created successfully!', type: 'success' });
82+
setNewTagName('');
83+
fetchTags();
84+
} catch (err: any) {
85+
console.error('Error creating tag:', err);
86+
setToast({ message: err.message || 'Failed to create tag', type: 'error' });
87+
} finally {
88+
setSubmitting(false);
89+
}
90+
};
91+
92+
const handleToggleTag = (tagId: string) => {
93+
setSelectedTagIds(prev =>
94+
prev.includes(tagId) ? prev.filter(id => id !== tagId) : [...prev, tagId]
95+
);
96+
};
97+
98+
const handleDeleteSelected = async () => {
99+
try {
100+
setIsDeleting(true);
101+
const token = await getToken();
102+
const tagCount = selectedTagIds.length;
103+
104+
await Promise.all(
105+
selectedTagIds.map(tagId =>
106+
fetch(`${API_BASE_URL}/api/tags/${tagId}`, {
107+
method: 'DELETE',
108+
headers: {
109+
Authorization: `Bearer ${token}`,
110+
},
111+
})
112+
)
113+
);
114+
115+
setToast({
116+
message: `${tagCount} tag${tagCount > 1 ? 's' : ''} deleted successfully!`,
117+
type: 'success',
118+
});
119+
setSelectedTagIds([]);
120+
fetchTags();
121+
} catch (err: any) {
122+
console.error('Error deleting tags:', err);
123+
setToast({ message: err.message || 'Failed to delete tags', type: 'error' });
124+
} finally {
125+
setIsDeleting(false);
126+
setShowConfirmModal(false);
127+
}
128+
};
129+
130+
if (loading) {
131+
return (
132+
<div className='flex min-h-screen bg-gray-50'>
133+
<AdminSidebar />
134+
<div className='flex-1 flex items-center justify-center'>
135+
<div className='text-lg'>Loading tags...</div>
136+
</div>
137+
</div>
138+
);
139+
}
140+
141+
return (
142+
<div className='flex min-h-screen bg-gray-50'>
143+
<AdminSidebar />
144+
<div className='flex-1 p-8'>
145+
<h1 className='text-3xl font-bold text-gray-800 mb-6'>Tag Management</h1>
146+
147+
{toast && (
148+
<Toast message={toast.message} type={toast.type} onClose={() => setToast(null)} />
149+
)}
150+
151+
<div className='bg-white rounded-lg shadow-md p-6 mb-6'>
152+
<h2 className='text-xl font-semibold text-gray-800 mb-4'>Add New Tag</h2>
153+
<form onSubmit={handleAddTag} className='flex gap-3'>
154+
<input
155+
type='text'
156+
value={newTagName}
157+
onChange={e => setNewTagName(e.target.value)}
158+
placeholder='Enter tag name...'
159+
className='flex-1 px-4 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-[#194B90]'
160+
disabled={submitting}
161+
/>
162+
<button
163+
type='submit'
164+
disabled={submitting || !newTagName.trim()}
165+
className='px-6 py-2 bg-[#D54242] text-white rounded-md hover:bg-[#b53a3a] disabled:opacity-50 disabled:cursor-not-allowed transition font-medium'
166+
>
167+
{submitting ? 'Adding...' : 'Add Tag'}
168+
</button>
169+
</form>
170+
</div>
171+
172+
<div className='bg-white rounded-lg shadow-md p-6'>
173+
<div className='flex items-center justify-between mb-4'>
174+
<div className='flex items-center gap-3'>
175+
<h2 className='text-xl font-semibold text-gray-800'>All Tags ({tags.length})</h2>
176+
{selectedTagIds.length > 0 && (
177+
<button
178+
onClick={() => setShowConfirmModal(true)}
179+
disabled={isDeleting}
180+
className='px-6 py-2 bg-[#D54242] text-white rounded-md hover:bg-[#b53a3a] disabled:opacity-50 disabled:cursor-not-allowed transition font-medium'
181+
>
182+
Delete ({selectedTagIds.length})
183+
</button>
184+
)}
185+
</div>
186+
{tags.length > 10 && (
187+
<button
188+
onClick={() => setIsCollapsed(!isCollapsed)}
189+
className='px-3 py-1.5 text-sm border border-[#194B90] text-[#194B90] bg-white rounded-md hover:bg-[#EBF3FF] transition font-medium'
190+
>
191+
{isCollapsed ? 'Expand All' : 'Collapse'}
192+
</button>
193+
)}
194+
</div>
195+
196+
{tags.length === 0 ? (
197+
<div className='text-center py-8'>
198+
<svg
199+
className='mx-auto h-12 w-12 text-gray-400'
200+
fill='none'
201+
stroke='currentColor'
202+
viewBox='0 0 24 24'
203+
>
204+
<path
205+
strokeLinecap='round'
206+
strokeLinejoin='round'
207+
strokeWidth={2}
208+
d='M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z'
209+
/>
210+
</svg>
211+
<p className='mt-2 text-gray-500'>No tags created yet</p>
212+
<p className='text-sm text-gray-400'>Add your first tag using the form above</p>
213+
</div>
214+
) : (
215+
<div
216+
className={`grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3 ${
217+
isCollapsed && tags.length > 10 ? 'max-h-48 overflow-hidden' : ''
218+
}`}
219+
>
220+
{tags.map(tag => (
221+
<div
222+
key={tag.id}
223+
className='flex items-center justify-between p-3 bg-gray-50 rounded-lg border border-gray-200 hover:bg-gray-100 transition'
224+
>
225+
<div className='flex items-center gap-2 flex-1 min-w-0'>
226+
<input
227+
type='checkbox'
228+
checked={selectedTagIds.includes(tag.id)}
229+
onChange={() => handleToggleTag(tag.id)}
230+
className='w-4 h-4 text-[#D54242] border-gray-300 rounded focus:ring-[#D54242]'
231+
/>
232+
<span className='w-2 h-2 rounded-full bg-[#D54242] flex-shrink-0'></span>
233+
<span className='text-gray-800 font-medium truncate'>{tag.name}</span>
234+
{tag._count && tag._count.announcements > 0 && (
235+
<span className='text-xs text-gray-500 flex-shrink-0'>
236+
({tag._count.announcements})
237+
</span>
238+
)}
239+
</div>
240+
</div>
241+
))}
242+
</div>
243+
)}
244+
245+
{isCollapsed && tags.length > 10 && (
246+
<div className='mt-4 text-center'>
247+
<button
248+
onClick={() => setIsCollapsed(false)}
249+
className='px-3 py-1.5 text-sm border border-[#194B90] text-[#194B90] bg-white rounded-md hover:bg-[#EBF3FF] transition font-medium'
250+
>
251+
Show all {tags.length} tags
252+
</button>
253+
</div>
254+
)}
255+
</div>
256+
257+
{showConfirmModal && (
258+
<ConfirmModal
259+
title='Delete Tags'
260+
message={`Are you sure you want to delete ${selectedTagIds.length} tag${selectedTagIds.length > 1 ? 's' : ''}? This action cannot be undone.`}
261+
confirmText='Delete'
262+
onConfirm={handleDeleteSelected}
263+
onCancel={() => setShowConfirmModal(false)}
264+
type='danger'
265+
isLoading={isDeleting}
266+
loadingText='Deleting...'
267+
/>
268+
)}
269+
</div>
270+
</div>
271+
);
272+
};
273+
274+
export default Tags;

frontend/src/routes/AppRoutes.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ import BlogsPageEdit from '../pages/Admin/PageEditPages/BlogsPage/BlogsPageEdit'
4040
import CustomEmail from '../pages/Admin/CustomEmailPage/CustomEmail';
4141
import AdminMessages from '../pages/Admin/MessagesPage/Messages';
4242
import OrganizationMessages from '../pages/OrganizationView/MessagesPage/Messages';
43+
import Tags from '../pages/Admin/TagsPage/Tags';
4344

4445
const AppRoutes = () => {
4546
return (
@@ -226,6 +227,14 @@ const AppRoutes = () => {
226227
</AdminRoute>
227228
}
228229
/>
230+
<Route
231+
path='/admin/tags'
232+
element={
233+
<AdminRoute>
234+
<Tags />
235+
</AdminRoute>
236+
}
237+
/>
229238
<Route
230239
path='/admin/messages'
231240
element={

0 commit comments

Comments
 (0)