Skip to content

Commit 5372928

Browse files
committed
allow image uploading to journals
1 parent eb8a992 commit 5372928

5 files changed

Lines changed: 325 additions & 5 deletions

File tree

client/src/components/AdminReviewPage.css

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -556,3 +556,22 @@
556556
opacity: 0.5;
557557
cursor: wait;
558558
}
559+
560+
.admin-review-journal-description {
561+
display: block;
562+
margin: 0.3rem 0;
563+
color: #c8d4f5;
564+
font-size: 0.97rem;
565+
white-space: pre-wrap;
566+
}
567+
568+
.admin-review-media-item {
569+
display: block;
570+
max-width: 100%;
571+
max-height: 24rem;
572+
border-radius: 8px;
573+
border: 2px solid #2e3f7a;
574+
object-fit: contain;
575+
background: #1a2240;
576+
margin: 0.4rem 0;
577+
}

client/src/components/AdminReviewPage.jsx

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,38 @@
11
import { useAuth } from "../auth/AuthContext.jsx";
22
import { useEffect, useMemo, useState } from "react";
3+
4+
function isVideoUrl(url) {
5+
const ext = url.split("?")[0].split(".").pop()?.toLowerCase();
6+
return ["mp4", "webm", "ogg", "mov"].includes(ext);
7+
}
8+
9+
function JournalDescriptionRenderer({ text }) {
10+
if (!text) return null;
11+
const parts = [];
12+
const imgRe = /!\[([^\]]*)\]\((https:\/\/cdn\.hackclub\.com\/[^\s)]+)\)/g;
13+
let last = 0;
14+
let match;
15+
while ((match = imgRe.exec(text)) !== null) {
16+
if (match.index > last) parts.push({ type: "text", value: text.slice(last, match.index) });
17+
parts.push({ type: "media", alt: match[1], url: match[2] });
18+
last = match.index + match[0].length;
19+
}
20+
if (last < text.length) parts.push({ type: "text", value: text.slice(last) });
21+
22+
return (
23+
<div className="admin-review-journal-description">
24+
{parts.map((part, i) =>
25+
part.type === "text" ? (
26+
<span key={i} style={{ whiteSpace: "pre-wrap" }}>{part.value}</span>
27+
) : isVideoUrl(part.url) ? (
28+
<video key={i} className="admin-review-media-item" src={part.url} controls preload="metadata" />
29+
) : (
30+
<img key={i} className="admin-review-media-item" src={part.url} alt={part.alt} />
31+
)
32+
)}
33+
</div>
34+
);
35+
}
336
import "./AdminReviewPage.css";
437

538
function formatHours(value) {
@@ -444,7 +477,7 @@ function AdminReviewDetail({ projectId }) {
444477
journalEntries.map((entry) => (
445478
<article className="admin-review-journal-entry" key={entry.id}>
446479
<strong>{entry.timeDone ? new Date(entry.timeDone).toLocaleString() : "N/A"} · {entry.hoursWorked}h</strong>
447-
<p>{entry.description}</p>
480+
<JournalDescriptionRenderer text={entry.description} />
448481
{entry.toolsUsed?.length ? <small>Tools: {entry.toolsUsed.join(", ")}</small> : null}
449482
</article>
450483
))

client/src/components/ProjectsPage.css

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -485,3 +485,78 @@
485485
color: #304389;
486486
font-weight: 700;
487487
}
488+
489+
.journal-textarea-wrap {
490+
position: relative;
491+
}
492+
493+
.journal-textarea-wrap textarea {
494+
width: 100%;
495+
box-sizing: border-box;
496+
transition: border-color 0.15s, box-shadow 0.15s;
497+
}
498+
499+
.journal-textarea-wrap--drag textarea {
500+
border-color: #3b5fc0;
501+
box-shadow: 0 0 0 3px rgba(59, 95, 192, 0.25);
502+
}
503+
504+
.journal-textarea-drop-overlay {
505+
position: absolute;
506+
inset: 0;
507+
display: flex;
508+
align-items: center;
509+
justify-content: center;
510+
border-radius: 10px;
511+
background: rgba(59, 95, 192, 0.12);
512+
border: 2px dashed #3b5fc0;
513+
font-size: 1rem;
514+
font-weight: 700;
515+
color: #1b4cc0;
516+
pointer-events: none;
517+
}
518+
519+
.journal-textarea-uploading {
520+
position: absolute;
521+
bottom: 0.4rem;
522+
right: 0.6rem;
523+
font-size: 0.78rem;
524+
color: #3b5fc0;
525+
font-weight: 700;
526+
pointer-events: none;
527+
background: rgba(255,255,255,0.85);
528+
border-radius: 4px;
529+
padding: 0 4px;
530+
}
531+
532+
.journal-upload-error {
533+
margin: 0.2rem 0 0;
534+
font-size: 0.85rem;
535+
color: #c0392b;
536+
font-weight: 700;
537+
}
538+
539+
.journal-upload-hint-text {
540+
color: #6b7ea8;
541+
font-size: 0.78rem;
542+
font-weight: 400;
543+
margin-top: 0.1rem;
544+
}
545+
546+
.journal-description {
547+
display: block;
548+
margin: 0.35rem 0;
549+
color: #304389;
550+
font-size: 0.97rem;
551+
}
552+
553+
.journal-media-item {
554+
display: block;
555+
max-width: 100%;
556+
max-height: 20rem;
557+
border-radius: 8px;
558+
border: 2px solid #c7d6ff;
559+
object-fit: contain;
560+
background: #eef2ff;
561+
margin: 0.4rem 0;
562+
}

client/src/components/ProjectsPage.jsx

Lines changed: 134 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
const platformBackground = "https://cdn.hackclub.com/019e3e5a-908f-707d-9790-91f9ec414045/bkg.png";
2-
import { useEffect, useState } from "react";
2+
import { useEffect, useRef, useState } from "react";
33
import { useAuth } from "../auth/AuthContext.jsx";
44
import { PlatformStatusBar } from "./PlatformStatusBar.jsx";
55
const sideBrick = "https://cdn.hackclub.com/019e3e5a-9d8a-7fcb-ad80-d6166cfd97f8/side_brick.png";
@@ -98,6 +98,51 @@ const emptyJournalEntry = {
9898
toolsUsed: "",
9999
};
100100

101+
const ALLOWED_UPLOAD_TYPES = new Set([
102+
"image/jpeg",
103+
"image/png",
104+
"image/gif",
105+
"image/webp",
106+
"image/avif",
107+
"video/mp4",
108+
"video/webm",
109+
"video/ogg",
110+
"video/quicktime",
111+
]);
112+
113+
function isVideoUrl(url) {
114+
const ext = url.split("?")[0].split(".").pop()?.toLowerCase();
115+
return ["mp4", "webm", "ogg", "mov"].includes(ext);
116+
}
117+
118+
function JournalDescriptionRenderer({ text }) {
119+
if (!text) return null;
120+
const parts = [];
121+
const imgRe = /!\[([^\]]*)\]\((https:\/\/cdn\.hackclub\.com\/[^\s)]+)\)/g;
122+
let last = 0;
123+
let match;
124+
while ((match = imgRe.exec(text)) !== null) {
125+
if (match.index > last) parts.push({ type: "text", value: text.slice(last, match.index) });
126+
parts.push({ type: "media", alt: match[1], url: match[2] });
127+
last = match.index + match[0].length;
128+
}
129+
if (last < text.length) parts.push({ type: "text", value: text.slice(last) });
130+
131+
return (
132+
<div className="journal-description">
133+
{parts.map((part, i) =>
134+
part.type === "text" ? (
135+
<span key={i} style={{ whiteSpace: "pre-wrap" }}>{part.value}</span>
136+
) : isVideoUrl(part.url) ? (
137+
<video key={i} className="journal-media-item" src={part.url} controls preload="metadata" />
138+
) : (
139+
<img key={i} className="journal-media-item" src={part.url} alt={part.alt} />
140+
)
141+
)}
142+
</div>
143+
);
144+
}
145+
101146
function displayStatus(project) {
102147
if (project.status === "approved") return "Approved";
103148
if (project.status === "rejected") return "Rejected";
@@ -593,6 +638,71 @@ function ProjectDetailsModal({ project, onClose, onEdit, onJournal, onShip, onDe
593638
}
594639

595640
function JournalModal({ project, entries, form, onChange, onClose, onSubmit }) {
641+
const [uploading, setUploading] = useState(false);
642+
const [uploadError, setUploadError] = useState("");
643+
const [isDraggingOver, setIsDraggingOver] = useState(false);
644+
const textareaRef = useRef(null);
645+
const descriptionRef = useRef(form.description);
646+
descriptionRef.current = form.description;
647+
648+
async function uploadAndInsert(file) {
649+
if (!ALLOWED_UPLOAD_TYPES.has(file.type)) {
650+
setUploadError(`Unsupported file type: ${file.type}. Use JPEG, PNG, GIF, WebP, AVIF, MP4, WebM, OGG, or MOV.`);
651+
return;
652+
}
653+
setUploading(true);
654+
setUploadError("");
655+
656+
const textarea = textareaRef.current;
657+
const start = textarea?.selectionStart ?? descriptionRef.current.length;
658+
const end = textarea?.selectionEnd ?? start;
659+
const placeholder = `![Uploading ${file.name}…]()`;
660+
const before = descriptionRef.current.slice(0, start);
661+
const after = descriptionRef.current.slice(end);
662+
const withPlaceholder = `${before}${placeholder}${after}`;
663+
onChange("description", withPlaceholder);
664+
descriptionRef.current = withPlaceholder;
665+
666+
try {
667+
const formData = new FormData();
668+
formData.append("file", file);
669+
const response = await fetch("/api/cdn/upload", {
670+
method: "POST",
671+
credentials: "include",
672+
headers: { "x-file-type": file.type },
673+
body: formData,
674+
});
675+
const data = await response.json();
676+
if (!response.ok) throw new Error(data.error || "Upload failed.");
677+
678+
const mdLink = `![${file.name}](${data.url})`;
679+
const resolved = descriptionRef.current.replace(placeholder, mdLink);
680+
onChange("description", resolved);
681+
descriptionRef.current = resolved;
682+
} catch (err) {
683+
const cleaned = descriptionRef.current.replace(placeholder, "");
684+
onChange("description", cleaned);
685+
descriptionRef.current = cleaned;
686+
setUploadError(err.message);
687+
} finally {
688+
setUploading(false);
689+
}
690+
}
691+
692+
function handleTextareaDrop(event) {
693+
event.preventDefault();
694+
setIsDraggingOver(false);
695+
const files = Array.from(event.dataTransfer.files);
696+
for (const file of files) uploadAndInsert(file);
697+
}
698+
699+
function handleTextareaPaste(event) {
700+
const files = Array.from(event.clipboardData?.files ?? []);
701+
if (files.length === 0) return;
702+
event.preventDefault();
703+
for (const file of files) uploadAndInsert(file);
704+
}
705+
596706
return (
597707
<div className="projects-page__modal-overlay" role="presentation" onClick={onClose}>
598708
<section className="projects-page__modal projects-page__modal--journal" role="dialog" aria-modal="true" onClick={(event) => event.stopPropagation()}>
@@ -619,13 +729,33 @@ function JournalModal({ project, entries, form, onChange, onClose, onSubmit }) {
619729
</label>
620730
<label>
621731
Description
622-
<textarea value={form.description} onChange={(event) => onChange("description", event.target.value)} required />
732+
<div className={`journal-textarea-wrap${isDraggingOver ? " journal-textarea-wrap--drag" : ""}`}>
733+
<textarea
734+
ref={textareaRef}
735+
value={form.description}
736+
onChange={(event) => onChange("description", event.target.value)}
737+
onDragOver={(e) => { e.preventDefault(); setIsDraggingOver(true); }}
738+
onDragLeave={() => setIsDraggingOver(false)}
739+
onDrop={handleTextareaDrop}
740+
onPaste={handleTextareaPaste}
741+
required
742+
placeholder="Describe what you worked on… drag & drop or paste images/videos to attach them inline"
743+
/>
744+
{isDraggingOver && (
745+
<div className="journal-textarea-drop-overlay" aria-hidden="true">Drop to upload</div>
746+
)}
747+
{uploading && (
748+
<div className="journal-textarea-uploading" aria-live="polite">Uploading…</div>
749+
)}
750+
</div>
751+
{uploadError ? <p className="journal-upload-error">{uploadError}</p> : null}
752+
<small className="journal-upload-hint-text">Drag &amp; drop or paste images/videos to embed them inline</small>
623753
</label>
624754
<label>
625755
Tools Used
626756
<input value={form.toolsUsed} onChange={(event) => onChange("toolsUsed", event.target.value)} placeholder="React, Figma, CAD" />
627757
</label>
628-
<button type="submit">Save Entry</button>
758+
<button type="submit" disabled={uploading}>Save Entry</button>
629759
</form>
630760

631761
<section className="projects-page__journal-list" aria-label="Journal entries">
@@ -638,7 +768,7 @@ function JournalModal({ project, entries, form, onChange, onClose, onSubmit }) {
638768
<strong>
639769
{entry.timeDone ? new Date(entry.timeDone).toLocaleDateString() : "N/A"} - {entry.hoursWorked || 0} hrs
640770
</strong>
641-
<p>{entry.description}</p>
771+
<JournalDescriptionRenderer text={entry.description} />
642772
{entry.toolsUsed?.length ? <small>Tools: {entry.toolsUsed.join(", ")}</small> : null}
643773
</article>
644774
))

server/index.js

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -353,6 +353,69 @@ app.delete("/api/projects/:id", requireUser, async (req, res) => {
353353
}
354354
});
355355

356+
const CDN_API_KEY = process.env.CDN_API_KEY;
357+
const CDN_UPLOAD_URL = "https://cdn.hackclub.com/api/v4/upload";
358+
const ALLOWED_MEDIA_TYPES = new Set([
359+
"image/jpeg",
360+
"image/png",
361+
"image/gif",
362+
"image/webp",
363+
"image/avif",
364+
"video/mp4",
365+
"video/webm",
366+
"video/ogg",
367+
"video/quicktime",
368+
]);
369+
370+
app.post("/api/cdn/upload", requireUser, async (req, res) => {
371+
if (!CDN_API_KEY) {
372+
return res.status(503).json({ error: "CDN uploads are not configured on this server." });
373+
}
374+
375+
const contentType = req.headers["content-type"] || "";
376+
if (!contentType.startsWith("multipart/form-data")) {
377+
return res.status(400).json({ error: "Expected multipart/form-data." });
378+
}
379+
380+
const fileType = (req.headers["x-file-type"] || "").toLowerCase().trim();
381+
if (!fileType || !ALLOWED_MEDIA_TYPES.has(fileType)) {
382+
return res.status(400).json({ error: "Unsupported file type. Only images (JPEG, PNG, GIF, WebP, AVIF) and videos (MP4, WebM, OGG, MOV) are allowed." });
383+
}
384+
385+
try {
386+
const cdnResponse = await fetch(CDN_UPLOAD_URL, {
387+
method: "POST",
388+
headers: {
389+
Authorization: `Bearer ${CDN_API_KEY}`,
390+
"Content-Type": contentType,
391+
},
392+
body: req,
393+
duplex: "half",
394+
});
395+
396+
const data = await cdnResponse.json();
397+
if (!cdnResponse.ok) {
398+
return res.status(cdnResponse.status).json({ error: data?.error || "CDN upload failed." });
399+
}
400+
401+
let uploadedUrl;
402+
try {
403+
const parsed = new URL(data.url || "");
404+
if (parsed.hostname !== "cdn.hackclub.com" || parsed.protocol !== "https:") {
405+
throw new Error("Unexpected CDN response URL.");
406+
}
407+
uploadedUrl = parsed.href;
408+
} catch {
409+
return res.status(502).json({ error: "CDN returned an unexpected URL." });
410+
}
411+
412+
res.json({ url: uploadedUrl });
413+
} catch (error) {
414+
console.error("CDN upload proxy error:", error);
415+
res.status(500).json({ error: "Failed to upload file." });
416+
}
417+
});
418+
356419
app.get("/api/projects/:id/journal_entries", requireUser, async (req, res) => {
357420
try {
358421
const entries = await listJournalEntriesForUserProject(req.session.userId, req.params.id);

0 commit comments

Comments
 (0)