added resources functionality

This commit is contained in:
Harivansh Rathi 2024-12-05 18:03:03 -05:00
parent 054abcee98
commit 39e3a77c08
7 changed files with 360 additions and 123 deletions

56
package-lock.json generated
View file

@ -17,12 +17,14 @@
"@radix-ui/react-separator": "^1.0.3",
"@radix-ui/react-slot": "^1.0.2",
"@radix-ui/react-tabs": "^1.0.4",
"@supabase/supabase-js": "^2.47.0",
"@supabase/auth-helpers-react": "^0.5.0",
"@supabase/supabase-js": "^2.47.1",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.0",
"lucide-react": "^0.344.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-hot-toast": "^2.4.1",
"react-router-dom": "^6.22.3",
"tailwind-merge": "^2.2.1"
},
@ -2029,6 +2031,15 @@
"win32"
]
},
"node_modules/@supabase/auth-helpers-react": {
"version": "0.5.0",
"resolved": "https://registry.npmjs.org/@supabase/auth-helpers-react/-/auth-helpers-react-0.5.0.tgz",
"integrity": "sha512-5QSaV2CGuhDhd7RlQCoviVEAYsP7XnrFMReOcBazDvVmqSIyjKcDwhLhWvnrxMOq5qjOaA44MHo7wXqDiF0puQ==",
"license": "MIT",
"peerDependencies": {
"@supabase/supabase-js": "^2.39.8"
}
},
"node_modules/@supabase/auth-js": {
"version": "2.65.1",
"resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.65.1.tgz",
@ -2069,9 +2080,9 @@
}
},
"node_modules/@supabase/realtime-js": {
"version": "2.11.2",
"resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.11.2.tgz",
"integrity": "sha512-u/XeuL2Y0QEhXSoIPZZwR6wMXgB+RQbJzG9VErA3VghVt7uRfSVsjeqd7m5GhX3JR6dM/WRmLbVR8URpDWG4+w==",
"version": "2.10.9",
"resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.10.9.tgz",
"integrity": "sha512-0AjN65VDNIScZzrrPaVvlND4vbgVS+j9Wcy3zf7e+l9JY4IwCTahFenPLcKy9bkr7KY0wfB7MkipZPKxMaDnjw==",
"license": "MIT",
"dependencies": {
"@supabase/node-fetch": "^2.6.14",
@ -2090,16 +2101,16 @@
}
},
"node_modules/@supabase/supabase-js": {
"version": "2.47.0",
"resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.47.0.tgz",
"integrity": "sha512-SvLKFI21m3b1NAbc8nvZbOhHihUtujhUaR5TqYxdt52ag6moUJnQNVt0l0Upw3z0BMuG/YOIuI3hMzuo1EqUEw==",
"version": "2.47.1",
"resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.47.1.tgz",
"integrity": "sha512-Q5zBX3BhK4tIFE6W8TK7NP29G9XCX8nzMydHONI8e/6HjYnKmUyNf33rsTTwGVVlXz2ruzJKO+EX4wuwD21Q4g==",
"license": "MIT",
"dependencies": {
"@supabase/auth-js": "2.65.1",
"@supabase/functions-js": "2.4.3",
"@supabase/node-fetch": "2.6.15",
"@supabase/postgrest-js": "1.16.3",
"@supabase/realtime-js": "2.11.2",
"@supabase/realtime-js": "2.10.9",
"@supabase/storage-js": "2.7.1"
}
},
@ -2838,8 +2849,7 @@
"node_modules/csstype": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
"devOptional": true
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="
},
"node_modules/debug": {
"version": "4.3.7",
@ -3456,6 +3466,15 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/goober": {
"version": "2.1.16",
"resolved": "https://registry.npmjs.org/goober/-/goober-2.1.16.tgz",
"integrity": "sha512-erjk19y1U33+XAMe1VTvIONHYoSqE4iS7BYUZfHaqeohLmnC0FdxEh7rQU+6MZ4OajItzjZFSRtVANrQwNq6/g==",
"license": "MIT",
"peerDependencies": {
"csstype": "^3.0.10"
}
},
"node_modules/graphemer": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz",
@ -3762,6 +3781,7 @@
"version": "0.344.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.344.0.tgz",
"integrity": "sha512-6YyBnn91GB45VuVT96bYCOKElbJzUHqp65vX8cDcu55MQL9T969v4dhGClpljamuI/+KMO9P6w9Acq1CVQGvIQ==",
"license": "ISC",
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0"
}
@ -4255,6 +4275,22 @@
"react": "^18.3.1"
}
},
"node_modules/react-hot-toast": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/react-hot-toast/-/react-hot-toast-2.4.1.tgz",
"integrity": "sha512-j8z+cQbWIM5LY37pR6uZR6D4LfseplqnuAO4co4u8917hBUvXlEqyP1ZzqVLcqoyUesZZv/ImreoCeHVDpE5pQ==",
"license": "MIT",
"dependencies": {
"goober": "^2.1.10"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"react": ">=16",
"react-dom": ">=16"
}
},
"node_modules/react-refresh": {
"version": "0.14.2",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz",

View file

@ -19,12 +19,14 @@
"@radix-ui/react-separator": "^1.0.3",
"@radix-ui/react-slot": "^1.0.2",
"@radix-ui/react-tabs": "^1.0.4",
"@supabase/supabase-js": "^2.47.0",
"@supabase/auth-helpers-react": "^0.5.0",
"@supabase/supabase-js": "^2.47.1",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.0",
"lucide-react": "^0.344.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-hot-toast": "^2.4.1",
"react-router-dom": "^6.22.3",
"tailwind-merge": "^2.2.1"
},

View file

@ -1,18 +1,24 @@
import React from 'react';
import { BrowserRouter } from 'react-router-dom';
import { AppRouter } from './routes';
import { SessionContextProvider } from '@supabase/auth-helpers-react';
import { Toaster } from 'react-hot-toast';
import { supabase } from './lib/supabase';
import { AuthProvider } from './contexts/AuthContext';
import './index.css';
const App: React.FC = () => {
return (
<AuthProvider>
<BrowserRouter>
<div className="min-h-screen bg-background text-foreground">
<AppRouter />
</div>
</BrowserRouter>
</AuthProvider>
<SessionContextProvider supabaseClient={supabase}>
<AuthProvider>
<Toaster position="top-right" />
<BrowserRouter>
<div className="min-h-screen bg-background text-foreground">
<AppRouter />
</div>
</BrowserRouter>
</AuthProvider>
</SessionContextProvider>
);
};

View file

@ -1,6 +1,6 @@
import React, { createContext, useContext, useEffect, useState } from 'react';
import { Session, User, AuthError } from '@supabase/supabase-js';
import { auth } from '../lib/supabase';
import { supabase } from '../lib/supabase';
interface AuthContextType {
session: Session | null;
@ -20,14 +20,14 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
useEffect(() => {
// Get initial session
auth.getSession().then(({ session: initialSession }) => {
supabase.auth.getSession().then(({ data: { session: initialSession } }) => {
setSession(initialSession);
setUser(initialSession?.user ?? null);
setLoading(false);
});
// Listen for auth changes
const { data: { subscription } } = auth.onAuthStateChange((_event, session) => {
const { data: { subscription } } = supabase.auth.onAuthStateChange((_event, session) => {
setSession(session);
setUser(session?.user ?? null);
setLoading(false);
@ -43,15 +43,21 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
user,
loading,
signIn: async (email: string, password: string) => {
const { error } = await auth.signIn(email, password);
const { error } = await supabase.auth.signInWithPassword({
email,
password,
});
return { error };
},
signUp: async (email: string, password: string) => {
const { error } = await auth.signUp(email, password);
const { error } = await supabase.auth.signUp({
email,
password,
});
return { error };
},
signOut: async () => {
const { error } = await auth.signOut();
const { error } = await supabase.auth.signOut();
return { error };
},
};

View file

@ -1,5 +1,4 @@
import { createClient, AuthError, Session, AuthResponse } from '@supabase/supabase-js';
import type { Database } from '../types/supabase';
import { createClient } from '@supabase/supabase-js';
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL;
const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY;
@ -8,37 +7,9 @@ if (!supabaseUrl || !supabaseAnonKey) {
throw new Error('Missing Supabase environment variables');
}
export const supabase = createClient<Database>(supabaseUrl, supabaseAnonKey);
// Auth helper functions
export const auth = {
signUp: async (email: string, password: string): Promise<{ data: AuthResponse | null; error: AuthError | null }> => {
const { data, error } = await supabase.auth.signUp({
email,
password,
});
return { data: data as AuthResponse | null, error };
},
signIn: async (email: string, password: string): Promise<{ data: AuthResponse | null; error: AuthError | null }> => {
const { data, error } = await supabase.auth.signInWithPassword({
email,
password,
});
return { data: data as AuthResponse | null, error };
},
signOut: async (): Promise<{ error: AuthError | null }> => {
const { error } = await supabase.auth.signOut();
return { error };
},
getSession: async (): Promise<{ session: Session | null; error: AuthError | null }> => {
const { data: { session }, error } = await supabase.auth.getSession();
return { session, error };
},
onAuthStateChange: (callback: (event: string, session: Session | null) => void) => {
return supabase.auth.onAuthStateChange(callback);
},
};
export const supabase = createClient(supabaseUrl, supabaseAnonKey, {
auth: {
persistSession: true,
autoRefreshToken: true,
}
});

View file

@ -1,63 +1,148 @@
import React from 'react';
import { Book, File, Folder } from 'lucide-react';
import { useEffect, useState } from 'react';
import { useSupabaseClient, useUser } from '@supabase/auth-helpers-react';
import { FileIcon, LinkIcon, Trash2 } from 'lucide-react';
import { toast } from 'react-hot-toast';
import { Card } from '../../components/ui/card';
import { Button } from '../../components/ui/Button';
const mockResources = [
{
id: '1',
type: 'folder',
name: 'Biology',
itemCount: 12,
},
{
id: '2',
type: 'folder',
name: 'Physics',
itemCount: 8,
},
{
id: '3',
type: 'file',
name: 'Math Notes.pdf',
size: '2.4 MB',
},
{
id: '4',
type: 'book',
name: 'Chemistry Textbook',
author: 'John Smith',
},
];
interface Resource {
id: string;
title: string;
resource_type: 'file' | 'link';
url?: string;
file_path?: string;
file_type?: string;
created_at: string;
}
export default function ResourcesPage() {
const [resources, setResources] = useState<Resource[]>([]);
const [isLoading, setIsLoading] = useState(true);
const supabase = useSupabaseClient();
const user = useUser();
useEffect(() => {
if (user) {
fetchResources();
}
}, [user]);
const fetchResources = async () => {
try {
const { data, error } = await supabase
.from('study_resources')
.select('*')
.eq('user_id', user?.id)
.order('created_at', { ascending: false });
if (error) throw error;
setResources(data || []);
} catch (error) {
console.error('Error fetching resources:', error);
toast.error('Failed to load resources');
} finally {
setIsLoading(false);
}
};
const handleDownload = async (resource: Resource) => {
if (resource.resource_type === 'file' && resource.file_path) {
try {
const { data, error } = await supabase.storage
.from('study-materials')
.download(resource.file_path);
if (error) throw error;
const url = URL.createObjectURL(data);
const a = document.createElement('a');
a.href = url;
a.download = resource.file_path.split('/').pop() || 'download';
document.body.appendChild(a);
a.click();
URL.revokeObjectURL(url);
document.body.removeChild(a);
} catch (error) {
console.error('Download error:', error);
toast.error('Failed to download file');
}
} else if (resource.resource_type === 'link' && resource.url) {
window.open(resource.url, '_blank');
}
};
const handleDelete = async (resource: Resource) => {
try {
if (resource.resource_type === 'file' && resource.file_path) {
const { error: storageError } = await supabase.storage
.from('study-materials')
.remove([resource.file_path]);
if (storageError) throw storageError;
}
const { error: dbError } = await supabase
.from('study_resources')
.delete()
.eq('id', resource.id);
if (dbError) throw dbError;
setResources(resources.filter(r => r.id !== resource.id));
toast.success('Resource deleted successfully');
} catch (error) {
console.error('Delete error:', error);
toast.error('Failed to delete resource');
}
};
if (isLoading) {
return <div className="flex justify-center p-6">Loading...</div>;
}
function StudyResources() {
return (
<div className="space-y-6">
<h1 className="text-3xl font-bold">Study Resources</h1>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{mockResources.map((resource) => (
<Card key={resource.id} className="p-4 hover:bg-muted/50 cursor-pointer">
<div className="flex items-start space-x-4">
{resource.type === 'folder' && <Folder className="h-6 w-6 text-blue-500" />}
{resource.type === 'file' && <File className="h-6 w-6 text-green-500" />}
{resource.type === 'book' && <Book className="h-6 w-6 text-purple-500" />}
<div>
<h3 className="font-medium">{resource.name}</h3>
{resource.type === 'folder' && (
<p className="text-sm text-muted-foreground">{resource.itemCount} items</p>
)}
{resource.type === 'file' && (
<p className="text-sm text-muted-foreground">{resource.size}</p>
)}
{resource.type === 'book' && (
<p className="text-sm text-muted-foreground">By {resource.author}</p>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{resources.map((resource) => (
<Card key={resource.id} className="p-4 hover:bg-muted/50">
<div className="flex items-start justify-between">
<div className="flex items-center space-x-2">
{resource.resource_type === 'file' ? (
<FileIcon className="h-5 w-5 text-primary" />
) : (
<LinkIcon className="h-5 w-5 text-primary" />
)}
<h3 className="font-medium truncate">{resource.title}</h3>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => handleDelete(resource)}
className="text-destructive hover:text-destructive/90 h-8 w-8 p-0"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
<Button
variant="ghost"
className="mt-4 w-full justify-start text-muted-foreground hover:text-foreground"
onClick={() => handleDownload(resource)}
>
{resource.resource_type === 'file' ? 'Download' : 'Open Link'}
</Button>
</Card>
))}
{resources.length === 0 && (
<div className="col-span-full text-center text-muted-foreground py-12">
No resources found. Start by uploading some materials!
</div>
)}
</div>
</div>
);
}
export default StudyResources;

View file

@ -1,25 +1,156 @@
import React from 'react';
import { Upload } from 'lucide-react';
import { useState, ChangeEvent } from 'react';
import { useSupabaseClient, useUser } from '@supabase/auth-helpers-react';
import { toast } from 'react-hot-toast';
import { Button } from '../../components/ui/Button';
import { Card } from '../../components/ui/card';
type UploadType = 'file' | 'link';
export default function UploadPage() {
const [uploadType, setUploadType] = useState<UploadType>('file');
const [title, setTitle] = useState('');
const [url, setUrl] = useState('');
const [file, setFile] = useState<File | null>(null);
const [isUploading, setIsUploading] = useState(false);
const supabase = useSupabaseClient();
const user = useUser();
const handleFileChange = (e: ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files[0]) {
setFile(e.target.files[0]);
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!user) return;
try {
setIsUploading(true);
if (uploadType === 'file' && file) {
// Upload file to Supabase Storage
const fileExt = file.name.split('.').pop();
const fileName = `${Math.random()}.${fileExt}`;
const filePath = `${user.id}/${fileName}`;
const { error: uploadError } = await supabase.storage
.from('study-materials')
.upload(filePath, file);
if (uploadError) throw uploadError;
// Create database entry
const { error: dbError } = await supabase
.from('study_resources')
.insert({
user_id: user.id,
title,
resource_type: 'file',
file_path: filePath,
file_type: file.type,
file_size: file.size
});
if (dbError) throw dbError;
} else if (uploadType === 'link') {
// Create database entry for link
const { error: dbError } = await supabase
.from('study_resources')
.insert({
user_id: user.id,
title,
resource_type: 'link',
url
});
if (dbError) throw dbError;
}
toast.success('Resource uploaded successfully!');
// Reset form
setTitle('');
setUrl('');
setFile(null);
} catch (error) {
console.error('Upload error:', error);
toast.error('Failed to upload resource');
} finally {
setIsUploading(false);
}
};
function UploadMaterials() {
return (
<div className="space-y-6">
<h1 className="text-3xl font-bold">Upload Study Materials</h1>
<div className="rounded-lg bg-card p-6 shadow-sm">
<div className="flex flex-col items-center justify-center space-y-4 py-12">
<Upload className="h-12 w-12 text-muted-foreground" />
<div className="text-center">
<h2 className="text-lg font-semibold">Drop your files here</h2>
<p className="text-sm text-muted-foreground">
or click to browse from your computer
</p>
<Card className="p-6">
<div className="space-y-6">
<div className="flex space-x-4">
<Button
variant={uploadType === 'file' ? 'default' : 'outline'}
onClick={() => setUploadType('file')}
>
Upload File
</Button>
<Button
variant={uploadType === 'link' ? 'default' : 'outline'}
onClick={() => setUploadType('link')}
>
Add Link
</Button>
</div>
<Button variant="outline">Choose Files</Button>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="text-sm font-medium text-muted-foreground">Title</label>
<input
type="text"
value={title}
onChange={(e: ChangeEvent<HTMLInputElement>) => setTitle(e.target.value)}
required
placeholder="Enter resource title"
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
/>
</div>
{uploadType === 'file' ? (
<div>
<label className="text-sm font-medium text-muted-foreground">File</label>
<div className="mt-1.5">
<input
type="file"
onChange={handleFileChange}
accept=".pdf,.doc,.docx,.txt,.ppt,.pptx"
required
className="flex w-full rounded-md border border-input bg-background text-sm ring-offset-background file:mr-4 file:py-2.5 file:px-4 file:mt-0 file:border-0 file:text-sm file:font-medium file:bg-primary file:text-primary-foreground hover:file:bg-primary/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
/>
</div>
</div>
) : (
<div>
<label className="text-sm font-medium text-muted-foreground">URL</label>
<input
type="url"
value={url}
onChange={(e: ChangeEvent<HTMLInputElement>) => setUrl(e.target.value)}
required
placeholder="Enter resource URL"
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
/>
</div>
)}
<Button
type="submit"
disabled={isUploading}
className="w-full"
>
{isUploading ? 'Uploading...' : 'Upload Resource'}
</Button>
</form>
</div>
</div>
</Card>
</div>
);
}
export default UploadMaterials;