Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,6 @@ temp.txt
# Internal AI docs (kept locally, not tracked)
docs/agentgpt.md
docs/context.md
docs/backend.md
docs/frontend.md
docs/project-understanding.md
docs/architecture_understanding.md
Expand Down
9 changes: 8 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,14 @@ ClassHub is a multi-tenant academic progressive web application built for colleg
```
Fill in your Supabase project URL, anonymous public key, and VAPID key in `.env`.

5. Start the Vite development server:
5. (Optional) Run local Supabase database stack:
```bash
supabase start # Starts local Postgres, Auth, Storage, and Studio
supabase db reset # Applies all migrations and seeds deterministic Section P2 data from supabase/seed.sql
```
For detailed database architecture and seeding documentation, refer to [docs/backend.md](docs/backend.md).

6. Start the Vite development server:
```bash
npm run dev
```
Expand Down
92 changes: 92 additions & 0 deletions docs/backend.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
# ClassHub Backend & Database Architecture

This document describes the backend architecture, local Supabase CLI development workflow, schema relationships, Row-Level Security (RLS) enforcement, and local seed scripts for ClassHub.

---

## 1. Local Supabase CLI Development Workflow

ClassHub uses the Supabase CLI for local database development, migrations, and Edge Function testing.

### Prerequisites

- [Docker Desktop](https://www.docker.com/) running locally.
- [Supabase CLI](https://supabase.com/docs/guides/cli) installed (`npm install -g supabase` or `brew install supabase/tap/supabase`).

### Starting the Local Stack

1. Initialize and start the local Supabase emulator:
```bash
supabase start
```
This spins up PostgreSQL 15, Auth, Storage, and Studio at `http://localhost:54323`.

2. Apply all active migrations and run the deterministic seed script:
```bash
supabase db reset
```
This resets the local database, executes all SQL files under `supabase/migrations/` in chronological order, and executes `supabase/seed.sql`.

3. Access Supabase Studio locally:
- URL: `http://localhost:54323`
- Default local API URL: `http://localhost:54321`
- Default local anon key is output by `supabase status`.

---

## 2. Deterministic Local Seed Data (`supabase/seed.sql`)

When setting up locally or running automated backend tests, `supabase/seed.sql` populates a realistic, reproducible environment for **Section P2**:

- **Demo Section**: Section `P2` (`invite_code`: `P2WXYZ`).
- **Demo Users**:
- **CR**: `cr.p2@skit.ac.in` (Roll: `01`, University Roll: `22ESKCS001`)
- **Student**: `student.p2@skit.ac.in` (Roll: `02`, University Roll: `22ESKCS002`)
- **Faculty**: `teacher.p2@skit.ac.in` (`Dr. Sunita Gupta`)
- **Core Engineering Subjects**: DBMS (`CS401`), Operating Systems (`CS402`), Computer Networks (`CS403`), Data Structures (`CS404`).
- **Weekly Schedule**: Recurring Monday through Wednesday timetable slots with assigned rooms (`LT-101`, `Lab-3`).
- **Sample Attendance Records**: Pre-populated attendance logs with safe bunks and threshold metrics.
- **Notices & Q&A**: High-priority announcements with verified student Q&A responses.

> [!IMPORTANT]
> The seed script strictly uses deterministic static UUIDs (such as `00000000-0000-4000-8000-000000000001`) and zero real student personal identifiable information (PII).

---

## 3. Database Schema & Tenant Isolation

ClassHub operates on a strict multi-tenant model isolated at the **Section** level.

```
sections (id, name, college, invite_code)
├── users (id, name, email, role, section_id, section_roll, university_roll)
├── subjects (id, section_id, code, name, semester)
├── timetable_slots (id, section_id, subject_id, day_of_week, start_time, end_time, room)
├── attendance_records (id, user_id, subject_id, present, od, makeup, absent)
├── announcements (id, section_id, author_id, title, message_content, priority)
│ ├── acknowledgments (announcement_id, user_id)
│ ├── announcement_comments (id, announcement_id, author_id, content, is_verified)
│ └── announcement_reactions (id, announcement_id, user_id, emoji)
└── assignments (id, section_id, subject_id, title, due_date)
├── assignment_sets (id, assignment_id, set_label, roll_start, roll_end)
└── submissions (id, assignment_id, student_id, submission_link, status)
```

### Security & Row-Level Security (RLS) Rules

1. **Section Isolation**: Every query must be scoped to the authenticated user's `section_id` via Postgres function `public.current_user_section_id()`.
2. **Domain Protection**: Only accounts with the `@skit.ac.in` Google Workspace domain are permitted to authenticate.
3. **Zero ERP Passwords**: ClassHub never scrapes, requests, or stores external ERP passwords.
4. **Anonymous Poll Tokenization**: Anonymous voting utilizes salted one-way hashes (`calculate_anonymous_token`) to preserve ballot secrecy.

---

## 4. Creating New Database Migrations

To introduce schema modifications or new database functions:

```bash
supabase migration new <descriptive_migration_name>
```

Write idempotent SQL in the generated file under `supabase/migrations/`. Ensure all new tables have `ALTER TABLE ... ENABLE ROW LEVEL SECURITY;` and corresponding RLS policies.
74 changes: 74 additions & 0 deletions src/components/CopyButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import React, { useState } from 'react';
import { Copy, Check } from 'lucide-react';
import { copyToClipboard } from '../lib/utils/clipboard';

export interface CopyButtonProps {
text: string;
label?: string;
ariaLabel?: string;
successMessage?: string;
iconSize?: number;
className?: string;
style?: React.CSSProperties;
children?: React.ReactNode;
}

export function CopyButton({
text,
label,
ariaLabel = 'Copy to clipboard',
successMessage = 'Copied to clipboard!',
iconSize = 13,
className,
style,
children,
}: CopyButtonProps) {
const [copied, setCopied] = useState(false);

const handleCopy = async (e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
const success = await copyToClipboard(text, {
successMessage,
errorMessage: 'Clipboard permission denied',
});

if (success) {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
};

return (
<button
type="button"
onClick={handleCopy}
aria-label={ariaLabel}
title={ariaLabel}
className={className}
style={{
background: 'none',
border: 'none',
cursor: 'pointer',
padding: '3px',
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
gap: 4,
color: copied ? 'var(--status-safe)' : 'var(--text-muted)',
borderRadius: 'var(--radius-sm)',
transition: 'color var(--transition-fast), background var(--transition-fast)',
...style,
}}
onMouseEnter={(e) => {
if (!copied) e.currentTarget.style.color = 'var(--text-primary)';
}}
onMouseLeave={(e) => {
if (!copied) e.currentTarget.style.color = 'var(--text-muted)';
}}
>
{copied ? <Check size={iconSize} color="var(--status-safe)" /> : <Copy size={iconSize} />}
{label && <span>{copied ? 'Copied' : label}</span>}
{children}
</button>
);
}
22 changes: 17 additions & 5 deletions src/components/announcement-qa/AnnouncementCommentsDrawer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import {
import { useSectionMembers } from '../../hooks/useSectionMembers';
import { useUserTagsBatch } from '../../hooks/useUserTags';
import { TagPill, TagOverflow } from '../TagPill';
import { MAX_COMMENT_LENGTH } from '../../lib/validation/comments.schema';
import { MAX_COMMENT_LENGTH, commentSchema } from '../../lib/validation/comments.schema';

interface AnnouncementCommentsDrawerProps {
open: boolean;
Expand Down Expand Up @@ -169,7 +169,13 @@ export function AnnouncementCommentsDrawer({

const handlePostComment = async (e: React.FormEvent) => {
e.preventDefault();
if (!inputVal.trim() || inputVal.length > MAX_COMMENT_LENGTH || isSubmitting) return;
if (isSubmitting) return;

const validation = commentSchema.safeParse({ content: inputVal });
if (!validation.success) {
toast.error(validation.error.issues[0]?.message || 'Invalid comment');
return;
}

// Rate-limiting: Max 1 comment per 3 seconds
const now = Date.now();
Expand All @@ -180,7 +186,7 @@ export function AnnouncementCommentsDrawer({

setIsSubmitting(true);
try {
await addComment.mutateAsync(inputVal);
await addComment.mutateAsync(validation.data.content);
setInputVal('');
setLastSubmitTime(now);
toast.success('Comment posted ✓');
Expand Down Expand Up @@ -216,10 +222,16 @@ export function AnnouncementCommentsDrawer({
};

const handleSaveEdit = async (commentId: string) => {
if (!editInputVal.trim() || editInputVal.length > MAX_COMMENT_LENGTH || editComment.isPending) return;
if (editComment.isPending) return;

const validation = commentSchema.safeParse({ content: editInputVal });
if (!validation.success) {
toast.error(validation.error.issues[0]?.message || 'Invalid comment');
return;
}

try {
await editComment.mutateAsync({ commentId, content: editInputVal });
await editComment.mutateAsync({ commentId, content: validation.data.content });
setEditingCommentId(null);
} catch {
// Error handled in hook
Expand Down
80 changes: 80 additions & 0 deletions src/lib/utils/clipboard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { toast } from 'sonner';

export interface CopyOptions {
successMessage?: string;
errorMessage?: string;
showToast?: boolean;
}

/**
* Resilient copy-to-clipboard utility.
* Attempts modern navigator.clipboard.writeText with a fallback to document.execCommand('copy')
* for unsupported, legacy, or insecure contexts.
*/
export async function copyToClipboard(
text: string,
options: CopyOptions = {}
): Promise<boolean> {
const {
successMessage,
errorMessage = 'Clipboard permission denied',
showToast = true,
} = options;

if (!text) {
if (showToast) {
toast.error(errorMessage);
}
return false;
}

// 1. Try modern navigator.clipboard API
if (typeof navigator !== 'undefined' && navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
try {
await navigator.clipboard.writeText(text);
if (showToast && successMessage) {
toast.success(successMessage);
}
return true;
} catch {
// Modern clipboard failed or was rejected by permissions policy; fall through to legacy fallback
}
}

// 2. Legacy fallback: document.execCommand('copy')
if (typeof document !== 'undefined') {
try {
const textarea = document.createElement('textarea');
textarea.value = text;
// Ensure textarea is not visible or disruptive to user focus
textarea.setAttribute('readonly', '');
textarea.style.position = 'fixed';
textarea.style.left = '-9999px';
textarea.style.top = '0';
textarea.style.opacity = '0';
textarea.style.pointerEvents = 'none';

document.body.appendChild(textarea);
textarea.focus();
textarea.select();

const successful = document.execCommand('copy');
document.body.removeChild(textarea);

if (successful) {
if (showToast && successMessage) {
toast.success(successMessage);
}
return true;
}
} catch {
// Fallback failed
}
}

// 3. Complete failure handling
if (showToast) {
toast.error(errorMessage);
}
return false;
}
6 changes: 6 additions & 0 deletions src/lib/validation/comments.schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,9 @@ export const commentContentSchema = z
.max(MAX_COMMENT_LENGTH, `Comment must be ${MAX_COMMENT_LENGTH} characters or fewer`);

export type CommentContent = z.infer<typeof commentContentSchema>;

export const commentSchema = z.object({
content: commentContentSchema,
});

export type CommentInput = z.infer<typeof commentSchema>;
35 changes: 31 additions & 4 deletions src/pages/app/PDFViewerPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -858,17 +858,44 @@ export default function PDFViewerPage() {
jumpToPage(parseInt(pageInputValue, 10));
};

const goToPrevPage = () => {
const goToPrevPage = useCallback(() => {
if (activePageNum > 1) {
jumpToPage(activePageNum - 1);
}
};
}, [activePageNum, jumpToPage]);

const goToNextPage = () => {
const goToNextPage = useCallback(() => {
if (activePageNum < numPages) {
jumpToPage(activePageNum + 1);
}
};
}, [activePageNum, numPages, jumpToPage]);

// Keyboard navigation shortcuts for desktop/laptop navigation
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
const target = e.target as HTMLElement | null;
const tagName = target?.tagName?.toUpperCase();
if (tagName === 'INPUT' || tagName === 'TEXTAREA' || tagName === 'SELECT' || target?.isContentEditable) {
return;
}

if (e.key === 'ArrowRight' || e.key === 'ArrowDown') {
e.preventDefault();
goToNextPage();
} else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') {
e.preventDefault();
goToPrevPage();
} else if (e.key === 'Escape') {
e.preventDefault();
navigate(-1);
}
};

window.addEventListener('keydown', handleKeyDown);
return () => {
window.removeEventListener('keydown', handleKeyDown);
};
}, [goToNextPage, goToPrevPage, navigate]);

const handleZoomIn = () => {
setScale(prev => Math.min(prev + 0.2, 3.0));
Expand Down
Loading
Loading