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),
|
||||
result: "",
|
||||
imageUrls: [],
|
||||
studyMaterials: [],
|
||||
userId: auth.userId,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
|
||||
@@ -28,6 +28,7 @@ export = new fileRouter.Path("/").http(
|
||||
}),
|
||||
result: z.string().min(1).max(100).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),
|
||||
result: data.result || "",
|
||||
imageUrls: data.imageUrls || [],
|
||||
studyMaterials: data.studyMaterials || [],
|
||||
userId: auth.userId,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
|
||||
@@ -25,6 +25,7 @@ export = new fileRouter.Path("/").http(
|
||||
date: z.string().refine((val) => !isNaN(Date.parse(val))).optional(),
|
||||
result: z.string().min(1).max(100).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
|
||||
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;
|
||||
updatedAt: Date;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
services:
|
||||
application:
|
||||
image: node:24-slim
|
||||
image: node:24
|
||||
working_dir: /app
|
||||
volumes:
|
||||
- ./:/app
|
||||
@@ -8,9 +8,4 @@ services:
|
||||
- "$PORT:8080"
|
||||
env_file:
|
||||
- .env
|
||||
command: sh -c "git pull && 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
|
||||
command: sh -c "npm i -g pnpm && cd frontend && pnpm install && pnpm run build && cd ../backend && pnpm install && pnpm run start"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { X } from 'lucide-react';
|
||||
import { X, Plus, Trash2, Link as LinkIcon } from 'lucide-react';
|
||||
import { Exam } from '../types';
|
||||
|
||||
interface ExamModalProps {
|
||||
@@ -13,6 +13,7 @@ interface ExamModalProps {
|
||||
description: string;
|
||||
result: string;
|
||||
duration: string;
|
||||
studyMaterials: string[];
|
||||
};
|
||||
setFormData: React.Dispatch<React.SetStateAction<{
|
||||
name: string;
|
||||
@@ -21,6 +22,7 @@ interface ExamModalProps {
|
||||
description: string;
|
||||
result: string;
|
||||
duration: string;
|
||||
studyMaterials: string[];
|
||||
}>>;
|
||||
editingExam: Exam | null;
|
||||
}
|
||||
@@ -33,6 +35,40 @@ const ExamModal: React.FC<ExamModalProps> = ({
|
||||
setFormData,
|
||||
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;
|
||||
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">
|
||||
@@ -114,6 +150,50 @@ const ExamModal: React.FC<ExamModalProps> = ({
|
||||
placeholder="Topic coverage, materials needed..."
|
||||
/>
|
||||
</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">
|
||||
<button
|
||||
type="submit"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import axios from 'axios';
|
||||
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 { Exam, ApiResponse, User } from '../types';
|
||||
import ExamModal from '../modals/ExamModal';
|
||||
@@ -11,6 +11,15 @@ type HomeProps = {
|
||||
user: User;
|
||||
};
|
||||
|
||||
const getDomain = (url: string) => {
|
||||
try {
|
||||
const domain = new URL(url).hostname.replace('www.', '');
|
||||
return domain;
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
};
|
||||
|
||||
function Home({ user }: HomeProps) {
|
||||
const [exams, setExams] = useState<Exam[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -18,7 +27,7 @@ function Home({ user }: HomeProps) {
|
||||
const [showImportModal, setShowImportModal] = useState(false);
|
||||
const [showCompleted, setShowCompleted] = useState(false);
|
||||
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 () => {
|
||||
try {
|
||||
@@ -47,11 +56,12 @@ function Home({ user }: HomeProps) {
|
||||
date: new Date(exam.date).toISOString().split('T')[0],
|
||||
description: exam.description || '',
|
||||
result: exam.result || '',
|
||||
duration: exam.duration?.toString() || ''
|
||||
duration: exam.duration?.toString() || '',
|
||||
studyMaterials: exam.studyMaterials || []
|
||||
});
|
||||
} else {
|
||||
setEditingExam(null);
|
||||
setFormData({ name: '', subject: '', date: '', description: '', result: '', duration: '' });
|
||||
setFormData({ name: '', subject: '', date: '', description: '', result: '', duration: '', studyMaterials: [] });
|
||||
}
|
||||
setShowModal(true);
|
||||
};
|
||||
@@ -61,8 +71,8 @@ function Home({ user }: HomeProps) {
|
||||
// Convert "" values to undefined
|
||||
const cleanedFormData = Object.fromEntries(
|
||||
Object.entries(formData).map(([k, v]) => {
|
||||
if (k === 'duration') return [k, v === '' ? 0 : parseInt(v) || 0];
|
||||
return [k, v === '' ? undefined : v];
|
||||
if (k === 'duration') return [k, (v as any) === '' ? 0 : parseInt(v as any) || 0];
|
||||
return [k, (v as any) === '' ? undefined : v];
|
||||
})
|
||||
);
|
||||
try {
|
||||
@@ -143,6 +153,25 @@ function Home({ user }: HomeProps) {
|
||||
</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 && (
|
||||
<p className="text-apple-muted text-sm line-clamp-2 mb-6 h-10">
|
||||
{exam.description}
|
||||
|
||||
@@ -14,6 +14,7 @@ export interface Exam {
|
||||
result: string;
|
||||
userId: string;
|
||||
imageUrls?: string[];
|
||||
studyMaterials?: string[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
subject: string;
|
||||
|
||||
Reference in New Issue
Block a user