Skip to content

Commit 5e8d105

Browse files
committed
Announcement Feature
1 parent 17cf59a commit 5e8d105

11 files changed

Lines changed: 1015 additions & 1 deletion

File tree

‎apps/codebility/app/home/_components/Navbar.tsx‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
} from "@/public/assets/svgs";
1616
import { useUserStore } from "@/store/codev-store";
1717
import { NotificationContainer } from "@/components/notifications/NotificationContainer";
18+
import { AnnouncementButton } from "@/app/home/announcements/AnnouncementButton";
1819

1920
import {
2021
DropdownMenu,
@@ -84,6 +85,9 @@ const Navbar = () => {
8485
role="navigation"
8586
aria-label="User menu"
8687
>
88+
{/* Announcement Button - NEW */}
89+
<AnnouncementButton />
90+
8791
<NotificationContainer />
8892

8993
<div>
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
"use client";
2+
3+
import React, { useState } from "react";
4+
import { Megaphone } from "lucide-react";
5+
import { AnnouncementModal } from "./AnnouncementModal";
6+
7+
export const AnnouncementButton: React.FC = () => {
8+
const [isModalOpen, setIsModalOpen] = useState(false);
9+
10+
return (
11+
<>
12+
<button
13+
onClick={() => setIsModalOpen(true)}
14+
className="relative rounded-lg p-2 text-gray-600 transition-colors hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-800"
15+
aria-label="View announcements"
16+
title="Announcements"
17+
>
18+
<Megaphone className="h-5 w-5" />
19+
</button>
20+
21+
<AnnouncementModal
22+
isOpen={isModalOpen}
23+
onClose={() => setIsModalOpen(false)}
24+
/>
25+
</>
26+
);
27+
};
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
"use client";
2+
3+
import React from "react";
4+
import Image from "next/image";
5+
import { AnnouncementPage } from "./types";
6+
7+
interface AnnouncementContentProps {
8+
page: AnnouncementPage;
9+
}
10+
11+
export const AnnouncementContent: React.FC<AnnouncementContentProps> = ({
12+
page,
13+
}) => {
14+
return (
15+
<div className="space-y-8">
16+
{/* Banner Image - Fixed for now, will be editable in future */}
17+
<div className="relative aspect-video w-full overflow-hidden rounded-lg bg-gray-200 dark:bg-gray-800">
18+
<Image
19+
src={page.banner_image}
20+
alt={page.title}
21+
fill
22+
className="object-cover"
23+
sizes="(max-width: 768px) 100vw, 800px"
24+
/>
25+
</div>
26+
27+
{/* Title */}
28+
<div>
29+
<h3 className="text-3xl font-semibold text-gray-900 dark:text-gray-100">
30+
{page.title}
31+
</h3>
32+
<p className="mt-2 text-base text-gray-500 dark:text-gray-400">
33+
Last updated: {new Date(page.last_updated).toLocaleDateString("en-US", {
34+
year: "numeric",
35+
month: "long",
36+
day: "numeric",
37+
})}
38+
</p>
39+
</div>
40+
41+
<div className="prose prose-lg dark:prose-invert max-w-none">
42+
<div
43+
className="text-gray-700 dark:text-gray-300 text-lg leading-relaxed"
44+
dangerouslySetInnerHTML={{ __html: page.content }}
45+
/>
46+
</div>
47+
</div>
48+
);
49+
};
Lines changed: 296 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,296 @@
1+
"use client";
2+
3+
import React, { useState, useRef, useEffect } from "react";
4+
import { createPortal } from "react-dom";
5+
import { X, Upload, Save } from "lucide-react";
6+
import { AnnouncementPage } from "./types";
7+
import { createClientClientComponent } from "@/utils/supabase/client";
8+
import AnnouncementRichTextEditor from "./AnnouncementRichTextEditor";
9+
10+
interface AnnouncementEditorProps {
11+
page: AnnouncementPage;
12+
isOpen: boolean;
13+
onClose: () => void;
14+
onSave: (updatedPage: AnnouncementPage) => Promise<void>;
15+
}
16+
17+
export const AnnouncementEditor: React.FC<AnnouncementEditorProps> = ({
18+
page,
19+
isOpen,
20+
onClose,
21+
onSave,
22+
}) => {
23+
const [title, setTitle] = useState(page.title);
24+
const [content, setContent] = useState(page.content);
25+
const [bannerImage, setBannerImage] = useState(page.banner_image);
26+
const [imageFile, setImageFile] = useState<File | null>(null);
27+
const [imagePreview, setImagePreview] = useState<string | null>(null);
28+
const [isSaving, setIsSaving] = useState(false);
29+
const [isUploading, setIsUploading] = useState(false);
30+
const fileInputRef = useRef<HTMLInputElement>(null);
31+
const supabase = createClientClientComponent();
32+
33+
// Reset state when page changes
34+
useEffect(() => {
35+
setTitle(page.title);
36+
setContent(page.content);
37+
setBannerImage(page.banner_image);
38+
setImageFile(null);
39+
setImagePreview(null);
40+
}, [page]);
41+
42+
// Handle image file selection
43+
const handleImageSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
44+
const file = e.target.files?.[0];
45+
if (file) {
46+
// Validate file type
47+
if (!file.type.startsWith("image/")) {
48+
alert("Please select an image file");
49+
return;
50+
}
51+
52+
// Validate file size (max 5MB)
53+
if (file.size > 5 * 1024 * 1024) {
54+
alert("Image size should be less than 5MB");
55+
return;
56+
}
57+
58+
setImageFile(file);
59+
60+
// Create preview
61+
const reader = new FileReader();
62+
reader.onloadend = () => {
63+
setImagePreview(reader.result as string);
64+
};
65+
reader.readAsDataURL(file);
66+
}
67+
};
68+
69+
// Upload image to Supabase storage
70+
const uploadImage = async (): Promise<string | null> => {
71+
if (!imageFile) return null;
72+
if (!supabase) throw new Error('Supabase client not initialized');
73+
74+
try {
75+
setIsUploading(true);
76+
77+
// Create a unique filename
78+
const fileExt = imageFile.name.split(".").pop();
79+
const fileName = `${page.category}-${Date.now()}.${fileExt}`;
80+
const filePath = `announcementBanner/${fileName}`;
81+
82+
// Upload to Supabase storage
83+
const { error: uploadError } = await supabase.storage
84+
.from("codebility")
85+
.upload(filePath, imageFile, {
86+
cacheControl: "3600",
87+
upsert: true,
88+
});
89+
90+
if (uploadError) throw uploadError;
91+
92+
// Return the public URL
93+
const publicUrl = `${process.env.NEXT_PUBLIC_SUPABASE_URL}/storage/v1/object/public/codebility/${filePath}`;
94+
95+
return publicUrl;
96+
} catch (error) {
97+
console.error("Error uploading image:", error);
98+
throw error;
99+
} finally {
100+
setIsUploading(false);
101+
}
102+
};
103+
104+
// Handle save
105+
const handleSave = async () => {
106+
try {
107+
setIsSaving(true);
108+
109+
let finalBannerImage = bannerImage;
110+
111+
// Upload new image if selected
112+
if (imageFile) {
113+
const uploadedUrl = await uploadImage();
114+
if (uploadedUrl) {
115+
finalBannerImage = uploadedUrl;
116+
} else {
117+
throw new Error("Failed to upload image");
118+
}
119+
}
120+
121+
// Create updated page object
122+
const updatedPage: AnnouncementPage = {
123+
...page,
124+
title,
125+
content,
126+
banner_image: finalBannerImage,
127+
last_updated: new Date().toISOString(),
128+
};
129+
130+
// Call the onSave prop
131+
await onSave(updatedPage);
132+
133+
// Close the editor
134+
onClose();
135+
} catch (error) {
136+
console.error("Error saving announcement:", error);
137+
throw error;
138+
} finally {
139+
setIsSaving(false);
140+
}
141+
};
142+
143+
// Handle cancel
144+
const handleCancel = () => {
145+
setTitle(page.title);
146+
setContent(page.content);
147+
setBannerImage(page.banner_image);
148+
setImageFile(null);
149+
setImagePreview(null);
150+
onClose();
151+
};
152+
153+
if (!isOpen) return null;
154+
155+
return createPortal(
156+
<>
157+
{/* Backdrop */}
158+
<div
159+
className="fixed inset-0 z-[150] bg-black/50 backdrop-blur-sm"
160+
onClick={handleCancel}
161+
aria-hidden="true"
162+
/>
163+
164+
{/* Editor Modal */}
165+
<div
166+
className="fixed inset-0 z-[150] flex items-center justify-center p-4 pointer-events-none"
167+
role="dialog"
168+
aria-modal="true"
169+
aria-labelledby="editor-modal-title"
170+
>
171+
<div className="flex h-[90vh] w-full max-w-5xl flex-col overflow-hidden rounded-xl bg-white shadow-2xl dark:bg-gray-900 pointer-events-auto">
172+
{/* Header */}
173+
<div className="flex items-center justify-between border-b border-gray-200 p-6 dark:border-gray-800">
174+
<h2
175+
id="editor-modal-title"
176+
className="text-2xl font-bold text-gray-900 dark:text-gray-100"
177+
>
178+
Edit Announcement
179+
</h2>
180+
<button
181+
onClick={handleCancel}
182+
className="rounded-lg p-2 text-gray-400 transition-colors hover:bg-gray-100 hover:text-gray-600 dark:hover:bg-gray-800 dark:hover:text-gray-300"
183+
aria-label="Close editor"
184+
>
185+
<X className="h-6 w-6" />
186+
</button>
187+
</div>
188+
189+
{/* Editor Content */}
190+
<div className="flex-1 overflow-y-auto p-6 space-y-6">
191+
{/* Title Input */}
192+
<div>
193+
<label
194+
htmlFor="announcement-title"
195+
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"
196+
>
197+
Title
198+
</label>
199+
<input
200+
id="announcement-title"
201+
type="text"
202+
value={title}
203+
onChange={(e) => setTitle(e.target.value)}
204+
className="w-full rounded-lg border border-gray-300 px-4 py-2 text-gray-900 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100"
205+
placeholder="Enter announcement title"
206+
/>
207+
</div>
208+
209+
{/* Banner Image Upload */}
210+
<div>
211+
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
212+
Banner Image
213+
</label>
214+
215+
{/* Current/Preview Image */}
216+
<div className="mb-3">
217+
<img
218+
src={imagePreview || bannerImage}
219+
alt="Banner preview"
220+
className="w-full h-48 object-cover rounded-lg border border-gray-200 dark:border-gray-700"
221+
/>
222+
</div>
223+
224+
{/* Upload Button */}
225+
<div className="flex items-center gap-3">
226+
<input
227+
ref={fileInputRef}
228+
type="file"
229+
accept="image/*"
230+
onChange={handleImageSelect}
231+
className="hidden"
232+
/>
233+
<button
234+
onClick={() => fileInputRef.current?.click()}
235+
disabled={isUploading}
236+
className="flex items-center gap-2 rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-50 dark:border-gray-700 dark:text-gray-300 dark:hover:bg-gray-800 disabled:opacity-50 disabled:cursor-not-allowed"
237+
>
238+
<Upload className="h-4 w-4" />
239+
{isUploading ? "Uploading..." : "Change Image"}
240+
</button>
241+
{imageFile && (
242+
<span className="text-sm text-gray-600 dark:text-gray-400">
243+
{imageFile.name}
244+
</span>
245+
)}
246+
</div>
247+
<p className="mt-2 text-xs text-gray-500 dark:text-gray-400">
248+
Recommended: 1200x400px, Max size: 5MB
249+
</p>
250+
</div>
251+
252+
{/* Rich Text Content Editor with Tiptap */}
253+
<div>
254+
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
255+
Content
256+
</label>
257+
<AnnouncementRichTextEditor
258+
value={content}
259+
onChange={setContent}
260+
/>
261+
</div>
262+
</div>
263+
264+
{/* Footer */}
265+
<div className="flex items-center justify-end gap-3 border-t border-gray-200 p-6 dark:border-gray-800">
266+
<button
267+
onClick={handleCancel}
268+
disabled={isSaving || isUploading}
269+
className="rounded-lg px-4 py-2 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800 disabled:opacity-50 disabled:cursor-not-allowed"
270+
>
271+
Cancel
272+
</button>
273+
<button
274+
onClick={handleSave}
275+
disabled={isSaving || isUploading || !title.trim() || !content.trim()}
276+
className="flex items-center gap-2 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
277+
>
278+
{isSaving ? (
279+
<>
280+
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
281+
Saving...
282+
</>
283+
) : (
284+
<>
285+
<Save className="h-4 w-4" />
286+
Save Changes
287+
</>
288+
)}
289+
</button>
290+
</div>
291+
</div>
292+
</div>
293+
</>,
294+
document.body
295+
);
296+
};

0 commit comments

Comments
 (0)