Add study materials field to exam routes and update ExamModal for handling materials
This commit is contained in:
@@ -43,6 +43,7 @@ export = new fileRouter.Path("/").http(
|
|||||||
date: new Date(exam.date),
|
date: new Date(exam.date),
|
||||||
result: "",
|
result: "",
|
||||||
imageUrls: [],
|
imageUrls: [],
|
||||||
|
studyMaterials: [],
|
||||||
userId: auth.userId,
|
userId: auth.userId,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ export = new fileRouter.Path("/").http(
|
|||||||
}),
|
}),
|
||||||
result: z.string().min(1).max(100).optional(),
|
result: z.string().min(1).max(100).optional(),
|
||||||
imageUrls: z.array(z.string().url()).optional(),
|
imageUrls: z.array(z.string().url()).optional(),
|
||||||
|
studyMaterials: z.array(z.string().url()).optional(),
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -46,6 +47,7 @@ export = new fileRouter.Path("/").http(
|
|||||||
date: new Date(data.date),
|
date: new Date(data.date),
|
||||||
result: data.result || "",
|
result: data.result || "",
|
||||||
imageUrls: data.imageUrls || [],
|
imageUrls: data.imageUrls || [],
|
||||||
|
studyMaterials: data.studyMaterials || [],
|
||||||
userId: auth.userId,
|
userId: auth.userId,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ export = new fileRouter.Path("/").http(
|
|||||||
date: z.string().refine((val) => !isNaN(Date.parse(val))).optional(),
|
date: z.string().refine((val) => !isNaN(Date.parse(val))).optional(),
|
||||||
result: z.string().min(1).max(100).optional(),
|
result: z.string().min(1).max(100).optional(),
|
||||||
imageUrls: z.array(z.string().url()).optional(),
|
imageUrls: z.array(z.string().url()).optional(),
|
||||||
|
studyMaterials: z.array(z.string().url()).optional(),
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ interface Exam {
|
|||||||
|
|
||||||
description?: string; // Optional field for additional details about the exam
|
description?: string; // Optional field for additional details about the exam
|
||||||
imageUrls?: string[]; // Optional array of image URLs related to the exam (e.g. scanned results, certificates)
|
imageUrls?: string[]; // Optional array of image URLs related to the exam (e.g. scanned results, certificates)
|
||||||
|
studyMaterials?: string[]; // Optional array of study materials URLs
|
||||||
|
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
services:
|
services:
|
||||||
application:
|
application:
|
||||||
image: node:24-slim
|
image: node:24
|
||||||
working_dir: /app
|
working_dir: /app
|
||||||
volumes:
|
volumes:
|
||||||
- ./:/app
|
- ./:/app
|
||||||
@@ -8,9 +8,4 @@ services:
|
|||||||
- "$PORT:8080"
|
- "$PORT:8080"
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
command: sh -c "git pull && npm i -g pnpm && cd frontend && pnpm install && pnpm run build && cd ../backend && pnpm install && pnpm run start"
|
command: sh -c "npm i -g pnpm && cd frontend && pnpm install && pnpm run build && cd ../backend && pnpm install && pnpm run start"
|
||||||
healthcheck:
|
|
||||||
test: ["CMD", "curl", "-f", "http://localhost:$PORT/health"]
|
|
||||||
interval: 30s
|
|
||||||
timeout: 10s
|
|
||||||
retries: 3
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { X } from 'lucide-react';
|
import { X, Plus, Trash2, Link as LinkIcon } from 'lucide-react';
|
||||||
import { Exam } from '../types';
|
import { Exam } from '../types';
|
||||||
|
|
||||||
interface ExamModalProps {
|
interface ExamModalProps {
|
||||||
@@ -13,6 +13,7 @@ interface ExamModalProps {
|
|||||||
description: string;
|
description: string;
|
||||||
result: string;
|
result: string;
|
||||||
duration: string;
|
duration: string;
|
||||||
|
studyMaterials: string[];
|
||||||
};
|
};
|
||||||
setFormData: React.Dispatch<React.SetStateAction<{
|
setFormData: React.Dispatch<React.SetStateAction<{
|
||||||
name: string;
|
name: string;
|
||||||
@@ -21,6 +22,7 @@ interface ExamModalProps {
|
|||||||
description: string;
|
description: string;
|
||||||
result: string;
|
result: string;
|
||||||
duration: string;
|
duration: string;
|
||||||
|
studyMaterials: string[];
|
||||||
}>>;
|
}>>;
|
||||||
editingExam: Exam | null;
|
editingExam: Exam | null;
|
||||||
}
|
}
|
||||||
@@ -33,6 +35,40 @@ const ExamModal: React.FC<ExamModalProps> = ({
|
|||||||
setFormData,
|
setFormData,
|
||||||
editingExam
|
editingExam
|
||||||
}) => {
|
}) => {
|
||||||
|
const [newMaterial, setNewMaterial] = React.useState('');
|
||||||
|
|
||||||
|
const addMaterial = () => {
|
||||||
|
const trimmedMaterial = newMaterial.trim();
|
||||||
|
|
||||||
|
if (!trimmedMaterial) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Validate URL format; will throw if invalid
|
||||||
|
// eslint-disable-next-line no-new
|
||||||
|
new URL(trimmedMaterial);
|
||||||
|
} catch {
|
||||||
|
// Invalid URL; do not add to study materials
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!formData.studyMaterials.includes(trimmedMaterial)) {
|
||||||
|
setFormData({
|
||||||
|
...formData,
|
||||||
|
studyMaterials: [...formData.studyMaterials, trimmedMaterial]
|
||||||
|
});
|
||||||
|
setNewMaterial('');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeMaterial = (index: number) => {
|
||||||
|
setFormData({
|
||||||
|
...formData,
|
||||||
|
studyMaterials: formData.studyMaterials.filter((_, i) => i !== index)
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
if (!show) return null;
|
if (!show) return null;
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 bg-black/80 backdrop-blur-md flex items-center justify-center z-[100] p-4 animate-in fade-in duration-300">
|
<div className="fixed inset-0 bg-black/80 backdrop-blur-md flex items-center justify-center z-[100] p-4 animate-in fade-in duration-300">
|
||||||
@@ -114,6 +150,50 @@ const ExamModal: React.FC<ExamModalProps> = ({
|
|||||||
placeholder="Topic coverage, materials needed..."
|
placeholder="Topic coverage, materials needed..."
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<label className="block text-xs font-bold text-apple-muted uppercase tracking-widest ml-1">Study Materials</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
value={newMaterial}
|
||||||
|
onChange={(e) => setNewMaterial(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
addMaterial();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="flex-1 bg-white/5 border border-white/10 rounded-xl px-4 py-3 focus:outline-none focus:ring-2 focus:ring-apple-accent/50 transition-all text-white placeholder:text-apple-muted/50"
|
||||||
|
placeholder="https://example.com/notes"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={addMaterial}
|
||||||
|
className="bg-apple-accent p-3 rounded-xl text-white hover:opacity-90 transition-opacity"
|
||||||
|
>
|
||||||
|
<Plus size={20} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{formData.studyMaterials.length > 0 && (
|
||||||
|
<div className="space-y-2 max-h-32 overflow-y-auto pr-2 custom-scrollbar">
|
||||||
|
{formData.studyMaterials.map((url, index) => (
|
||||||
|
<div key={index} className="flex items-center justify-between bg-white/5 rounded-lg px-3 py-2 border border-white/5 group">
|
||||||
|
<div className="flex items-center gap-2 truncate text-sm text-apple-muted">
|
||||||
|
<LinkIcon size={14} className="flex-shrink-0" />
|
||||||
|
<span className="truncate">{url}</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removeMaterial(index)}
|
||||||
|
className="text-apple-muted hover:text-red-400 opacity-0 group-hover:opacity-100 transition-all"
|
||||||
|
>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<div className="pt-4">
|
<div className="pt-4">
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { toast } from 'react-toastify';
|
import { toast } from 'react-toastify';
|
||||||
import { Plus, Edit2, Trash2, Calendar, BookOpen, Loader2, Clock, ChevronDown, ChevronRight, Sparkles } from 'lucide-react';
|
import { Plus, Edit2, Trash2, Calendar, BookOpen, Loader2, Clock, ChevronDown, ChevronRight, Sparkles, Link as LinkIcon, ExternalLink } from 'lucide-react';
|
||||||
import { format, isFuture, differenceInCalendarDays, formatDistanceToNow } from 'date-fns';
|
import { format, isFuture, differenceInCalendarDays, formatDistanceToNow } from 'date-fns';
|
||||||
import { Exam, ApiResponse, User } from '../types';
|
import { Exam, ApiResponse, User } from '../types';
|
||||||
import ExamModal from '../modals/ExamModal';
|
import ExamModal from '../modals/ExamModal';
|
||||||
@@ -11,6 +11,15 @@ type HomeProps = {
|
|||||||
user: User;
|
user: User;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getDomain = (url: string) => {
|
||||||
|
try {
|
||||||
|
const domain = new URL(url).hostname.replace('www.', '');
|
||||||
|
return domain;
|
||||||
|
} catch {
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
function Home({ user }: HomeProps) {
|
function Home({ user }: HomeProps) {
|
||||||
const [exams, setExams] = useState<Exam[]>([]);
|
const [exams, setExams] = useState<Exam[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -18,7 +27,7 @@ function Home({ user }: HomeProps) {
|
|||||||
const [showImportModal, setShowImportModal] = useState(false);
|
const [showImportModal, setShowImportModal] = useState(false);
|
||||||
const [showCompleted, setShowCompleted] = useState(false);
|
const [showCompleted, setShowCompleted] = useState(false);
|
||||||
const [editingExam, setEditingExam] = useState<Exam | null>(null);
|
const [editingExam, setEditingExam] = useState<Exam | null>(null);
|
||||||
const [formData, setFormData] = useState({ name: '', subject: '', date: '', description: '', result: '', duration: '' });
|
const [formData, setFormData] = useState({ name: '', subject: '', date: '', description: '', result: '', duration: '', studyMaterials: [] as string[] });
|
||||||
|
|
||||||
const fetchExams = async () => {
|
const fetchExams = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -47,11 +56,12 @@ function Home({ user }: HomeProps) {
|
|||||||
date: new Date(exam.date).toISOString().split('T')[0],
|
date: new Date(exam.date).toISOString().split('T')[0],
|
||||||
description: exam.description || '',
|
description: exam.description || '',
|
||||||
result: exam.result || '',
|
result: exam.result || '',
|
||||||
duration: exam.duration?.toString() || ''
|
duration: exam.duration?.toString() || '',
|
||||||
|
studyMaterials: exam.studyMaterials || []
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
setEditingExam(null);
|
setEditingExam(null);
|
||||||
setFormData({ name: '', subject: '', date: '', description: '', result: '', duration: '' });
|
setFormData({ name: '', subject: '', date: '', description: '', result: '', duration: '', studyMaterials: [] });
|
||||||
}
|
}
|
||||||
setShowModal(true);
|
setShowModal(true);
|
||||||
};
|
};
|
||||||
@@ -61,8 +71,8 @@ function Home({ user }: HomeProps) {
|
|||||||
// Convert "" values to undefined
|
// Convert "" values to undefined
|
||||||
const cleanedFormData = Object.fromEntries(
|
const cleanedFormData = Object.fromEntries(
|
||||||
Object.entries(formData).map(([k, v]) => {
|
Object.entries(formData).map(([k, v]) => {
|
||||||
if (k === 'duration') return [k, v === '' ? 0 : parseInt(v) || 0];
|
if (k === 'duration') return [k, (v as any) === '' ? 0 : parseInt(v as any) || 0];
|
||||||
return [k, v === '' ? undefined : v];
|
return [k, (v as any) === '' ? undefined : v];
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
@@ -143,6 +153,25 @@ function Home({ user }: HomeProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{exam.studyMaterials && exam.studyMaterials.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-2 mb-4">
|
||||||
|
{exam.studyMaterials.map((url, i) => (
|
||||||
|
<a
|
||||||
|
key={i}
|
||||||
|
href={url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
className="group/link flex items-center gap-1.5 px-3 py-1.5 bg-white/5 hover:bg-white/10 border border-white/5 rounded-full text-[11px] font-medium text-apple-muted hover:text-white transition-all"
|
||||||
|
>
|
||||||
|
<LinkIcon size={12} />
|
||||||
|
{getDomain(url)}
|
||||||
|
<ExternalLink size={10} className="opacity-0 group-hover/link:opacity-100 transition-opacity" />
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{exam.description && (
|
{exam.description && (
|
||||||
<p className="text-apple-muted text-sm line-clamp-2 mb-6 h-10">
|
<p className="text-apple-muted text-sm line-clamp-2 mb-6 h-10">
|
||||||
{exam.description}
|
{exam.description}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export interface Exam {
|
|||||||
result: string;
|
result: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
imageUrls?: string[];
|
imageUrls?: string[];
|
||||||
|
studyMaterials?: string[];
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
subject: string;
|
subject: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user