Skip to content
Open
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
47 changes: 47 additions & 0 deletions migrations/0011_add_study_groups.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
-- Migration 0011: Add study groups, members, and invites

CREATE TABLE IF NOT EXISTS study_groups (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
description TEXT,
activity_id TEXT,
creator_id TEXT NOT NULL,
max_members INTEGER NOT NULL DEFAULT 10,
is_private INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (activity_id) REFERENCES activities(id) ON DELETE SET NULL,
FOREIGN KEY (creator_id) REFERENCES users(id) ON DELETE CASCADE
);

CREATE TABLE IF NOT EXISTS study_group_members (
id TEXT PRIMARY KEY,
group_id TEXT NOT NULL,
user_id TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'member',
joined_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE (group_id, user_id),
FOREIGN KEY (group_id) REFERENCES study_groups(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);

CREATE TABLE IF NOT EXISTS study_group_invites (
id TEXT PRIMARY KEY,
group_id TEXT NOT NULL,
inviter_id TEXT NOT NULL,
invitee_id TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE (group_id, invitee_id),
FOREIGN KEY (group_id) REFERENCES study_groups(id) ON DELETE CASCADE,
FOREIGN KEY (inviter_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (invitee_id) REFERENCES users(id) ON DELETE CASCADE
);

CREATE INDEX IF NOT EXISTS idx_sg_activity ON study_groups(activity_id);
CREATE INDEX IF NOT EXISTS idx_sg_creator ON study_groups(creator_id);
CREATE INDEX IF NOT EXISTS idx_sgm_group ON study_group_members(group_id);
CREATE INDEX IF NOT EXISTS idx_sgm_user ON study_group_members(user_id);
CREATE INDEX IF NOT EXISTS idx_sgi_group ON study_group_invites(group_id);
CREATE INDEX IF NOT EXISTS idx_sgi_invitee_status ON study_group_invites(invitee_id, status);
117 changes: 117 additions & 0 deletions public/invitations.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
<!DOCTYPE html>
<html lang="en" class="scroll-smooth">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="Study Group Invitations - Alpha One Labs" />
<title>Study Group Invitations - Alpha One Labs</title>
<link rel="icon" type="image/png" href="https://alphaonelabs.com/static/images/logo.png" />
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
<script>
tailwind.config = { darkMode: 'class', theme: { extend: {} } };
(function () {
if (localStorage.getItem('darkMode') === 'true') document.documentElement.classList.add('dark');
})();
</script>
</head>
<body class="min-h-screen flex flex-col bg-white text-gray-900 dark:bg-black dark:text-gray-100 transition-colors duration-300 overflow-x-hidden">
<div id="site-navbar"></div>

<main class="flex-1 max-w-4xl w-full mx-auto px-4 py-8">
<h1 class="text-2xl sm:text-3xl font-bold mb-5"><i class="fas fa-envelope text-teal-500 mr-2"></i>Pending Invitations</h1>
<div id="invite-list" class="space-y-3" aria-live="polite"></div>
</main>

<div id="site-footer"></div>
<script src="/js/layout.js" defer></script>
<script>
const token = localStorage.getItem('edu_token');
const user = JSON.parse(localStorage.getItem('edu_user') || 'null');
const esc = window.esc || (s => String(s || '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'));

if (!token || !user) window.location.href = '/login.html';

let invites = [];

async function apiFetch(path, options = {}) {
const res = await fetch(path, {
...options,
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
...(options.headers || {}),
},
});
if (res.status === 401) {
localStorage.removeItem('edu_token');
localStorage.removeItem('edu_user');
window.location.href = '/login.html';
return null;
}
return res;
}

function render() {
const list = document.getElementById('invite-list');
if (!invites.length) {
list.innerHTML = '<p class="text-gray-500">You have no pending invitations.</p>';
return;
}
list.innerHTML = invites.map(i => `
<article class="rounded-xl border border-gray-200 dark:border-gray-700 p-4 bg-white dark:bg-gray-900" data-id="${i.id}">
<h2 class="font-semibold text-lg mb-1">${esc(i.group_name)}</h2>
<p class="text-sm text-gray-600 dark:text-gray-300 mb-1">Invited by <strong>${esc(i.inviter_username || 'Unknown')}</strong></p>
<p class="text-xs text-gray-500 mb-3">${esc(i.created_at || '')}</p>
<div class="flex gap-2">
<button data-action="accept" class="px-3 py-1.5 rounded bg-green-600 text-white hover:bg-green-700 text-sm">Accept</button>
<button data-action="decline" class="px-3 py-1.5 rounded bg-red-600 text-white hover:bg-red-700 text-sm">Decline</button>
<a class="px-3 py-1.5 rounded border border-gray-300 dark:border-gray-700 text-sm" href="/study-group-detail.html?group_id=${encodeURIComponent(i.group_id)}">View</a>
</div>
</article>
`).join('');
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

async function loadInvites() {
const res = await apiFetch('/api/invitations');
if (!res) return;
if (!res.ok) {
document.getElementById('invite-list').innerHTML = '<p class="text-red-600">Failed to load invitations.</p>';
return;
}
const body = await res.json();
invites = ((body.data || {}).invitations || []);
render();
}

async function respond(inviteId, action, article) {
const idx = invites.findIndex(i => i.id === inviteId);
if (idx !== -1) invites.splice(idx, 1);
render();

const res = await apiFetch(`/api/invitations/${encodeURIComponent(inviteId)}/respond`, {
method: 'POST',
body: JSON.stringify({ action }),
});

if (!res || !res.ok) {
await loadInvites();
return;
}
await loadInvites();
}
Comment thread
Ananya44444 marked this conversation as resolved.

document.getElementById('invite-list').addEventListener('click', async (e) => {
const btn = e.target.closest('[data-action]');
if (!btn) return;
const article = e.target.closest('[data-id]');
if (!article) return;
const inviteId = article.getAttribute('data-id');
const action = btn.getAttribute('data-action');
await respond(inviteId, action, article);
});

loadInvites();
</script>
</body>
</html>
5 changes: 4 additions & 1 deletion public/partials/navbar.html
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@
<a href="#" class="block px-4 py-2 rounded-md text-gray-700 dark:text-gray-200 hover:bg-teal-50 dark:hover:bg-teal-900 hover:text-teal-600 dark:hover:text-teal-300">
<i class="fas fa-palette mr-2 text-teal-500"></i>Whiteboard
</a>
<a href="#" class="block px-4 py-2 rounded-md text-gray-700 dark:text-gray-200 hover:bg-teal-50 dark:hover:bg-teal-900 hover:text-teal-600 dark:hover:text-teal-300">
<a href="/study-groups.html" class="block px-4 py-2 rounded-md text-gray-700 dark:text-gray-200 hover:bg-teal-50 dark:hover:bg-teal-900 hover:text-teal-600 dark:hover:text-teal-300">
<i class="fas fa-users mr-2 text-teal-500"></i>Study Groups
</a>
</div>
Expand Down Expand Up @@ -278,6 +278,9 @@
<a href="/" class="flex items-center py-2 px-3 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-md">
<i class="fas fa-book-open mr-2 text-teal-500"></i><span>Courses</span>
</a>
<a href="/study-groups.html" class="flex items-center py-2 px-3 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-md">
<i class="fas fa-users mr-2 text-teal-500"></i><span>Study Groups</span>
</a>
</div>
</div>
</div>
Expand Down
180 changes: 180 additions & 0 deletions public/study-group-detail.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
<!DOCTYPE html>
<html lang="en" class="scroll-smooth">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="Study Group Detail - Alpha One Labs" />
<title>Study Group Detail - Alpha One Labs</title>
<link rel="icon" type="image/png" href="https://alphaonelabs.com/static/images/logo.png" />
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
<script>
tailwind.config = { darkMode: 'class', theme: { extend: {} } };
(function () {
if (localStorage.getItem('darkMode') === 'true') document.documentElement.classList.add('dark');
})();
</script>
</head>
<body class="min-h-screen flex flex-col bg-white text-gray-900 dark:bg-black dark:text-gray-100 transition-colors duration-300 overflow-x-hidden">
<div id="site-navbar"></div>

<main class="flex-1 max-w-5xl w-full mx-auto px-4 py-8">
<div id="status" class="mb-4 text-sm"></div>
<section id="content" class="hidden grid grid-cols-1 lg:grid-cols-3 gap-6">
<div class="lg:col-span-2 border border-gray-200 dark:border-gray-700 rounded-xl p-5 bg-white dark:bg-gray-900">
<h1 id="name" class="text-2xl font-bold mb-2"></h1>
<p id="meta" class="text-sm text-gray-600 dark:text-gray-300 mb-4"></p>
<p id="desc" class="text-gray-700 dark:text-gray-200 mb-4"></p>
<div id="actions" class="flex flex-wrap gap-2 mb-5"></div>

<div id="invite-wrap" class="hidden border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<h2 class="font-semibold mb-2">Invite user</h2>
<div class="flex gap-2">
<label for="invite-username" class="sr-only">Username or email</label>
<input id="invite-username" placeholder="Username or email" class="flex-1 rounded-lg border border-gray-300 dark:border-gray-700 bg-white dark:bg-black px-3 py-2" />
<button id="invite-btn" type="button" class="px-3 py-2 rounded-lg bg-teal-600 text-white hover:bg-teal-700">Invite</button>
</div>
<p id="invite-msg" class="text-sm mt-2"></p>
</div>
</div>

<div class="border border-gray-200 dark:border-gray-700 rounded-xl p-5 bg-white dark:bg-gray-900">
<h2 class="text-lg font-semibold mb-3">Members</h2>
<ul id="members" class="space-y-2"></ul>
</div>
</section>
</main>

<div id="site-footer"></div>
<script src="/js/layout.js" defer></script>
<script>
const token = localStorage.getItem('edu_token');
const user = JSON.parse(localStorage.getItem('edu_user') || 'null');
const esc = window.esc || (s => String(s || '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'));

if (!token || !user) window.location.href = '/login.html';

const params = new URLSearchParams(window.location.search);
const groupId = params.get('group_id');

if (!groupId) {
document.getElementById('status').className = 'mb-4 text-sm text-red-600';
document.getElementById('status').textContent = 'Missing group_id in URL.';
}

async function apiFetch(path, options = {}) {
const res = await fetch(path, {
...options,
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
...(options.headers || {}),
},
});
if (res.status === 401) {
localStorage.removeItem('edu_token');
localStorage.removeItem('edu_user');
window.location.href = '/login.html';
return null;
}
return res;
}

function render(group) {
document.getElementById('content').classList.remove('hidden');
document.getElementById('name').textContent = group.name;
document.getElementById('desc').textContent = group.description || '';
document.getElementById('meta').textContent = `${group.member_count}/${group.max_members} members • ${group.is_private ? 'Private' : 'Public'}${group.activity_id ? ` • Activity ${group.activity_id}` : ''}`;

const actions = [];
if (group.requester_role && group.requester_role !== 'creator') {
actions.push(`<button id="leave-btn" class="px-3 py-2 rounded-lg bg-gray-800 dark:bg-gray-200 text-white dark:text-black hover:opacity-90">Leave</button>`);
}
if (group.requester_role === 'creator') {
actions.push(`<button id="delete-btn" class="px-3 py-2 rounded-lg bg-red-600 text-white hover:bg-red-700">Delete Group</button>`);
}
document.getElementById('actions').innerHTML = actions.join('');

document.getElementById('members').innerHTML = (group.members || []).map(m => `
<li class="flex items-center justify-between rounded-lg border border-gray-200 dark:border-gray-700 p-2">
<span>${esc(m.username || m.user_id)}</span>
<span class="text-xs px-2 py-0.5 rounded ${m.role === 'creator' ? 'bg-teal-100 text-teal-700 dark:bg-teal-900/40 dark:text-teal-200' : 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300'}">${esc(m.role)}</span>
</li>
`).join('');

const canInvite = !!group.requester_role;
document.getElementById('invite-wrap').classList.toggle('hidden', !canInvite);

const leaveBtn = document.getElementById('leave-btn');
if (leaveBtn) {
leaveBtn.addEventListener('click', async () => {
const res = await apiFetch(`/api/study-groups/${encodeURIComponent(groupId)}/leave`, { method: 'DELETE' });
if (!res) return;
if (res.ok) window.location.href = '/study-groups.html';
else {
const body = await res.json();
document.getElementById('status').className = 'mb-4 text-sm text-red-600';
document.getElementById('status').textContent = body.error || 'Failed to leave group';
}
});
}

const deleteBtn = document.getElementById('delete-btn');
if (deleteBtn) {
deleteBtn.addEventListener('click', async () => {
if (!confirm('Delete this study group?')) return;
const res = await apiFetch(`/api/study-groups/${encodeURIComponent(groupId)}`, { method: 'DELETE' });
if (!res) return;
if (res.ok) window.location.href = '/study-groups.html';
else {
const body = await res.json();
document.getElementById('status').className = 'mb-4 text-sm text-red-600';
document.getElementById('status').textContent = body.error || 'Failed to delete group';
}
});
}
}

async function loadGroup() {
if (!groupId) return;
const res = await apiFetch(`/api/study-groups/${encodeURIComponent(groupId)}`);
if (!res) return;
if (res.status === 403) {
document.getElementById('status').className = 'mb-4 text-sm text-red-600';
document.getElementById('status').textContent = '403: This private group is only visible to members.';
return;
}
if (!res.ok) {
const body = await res.json();
document.getElementById('status').className = 'mb-4 text-sm text-red-600';
document.getElementById('status').textContent = body.error || 'Unable to load group.';
return;
}
const body = await res.json();
render((body.data || {}).group || {});
}

document.getElementById('invite-btn').addEventListener('click', async () => {
const username = (document.getElementById('invite-username').value || '').trim();
if (!username) return;
const res = await apiFetch(`/api/study-groups/${encodeURIComponent(groupId)}/invite`, {
method: 'POST',
body: JSON.stringify({ username }),
});
const msg = document.getElementById('invite-msg');
if (!res) return;
const body = await res.json();
if (res.ok) {
msg.className = 'text-sm mt-2 text-green-600';
msg.textContent = 'Invitation sent';
document.getElementById('invite-username').value = '';
} else {
msg.className = 'text-sm mt-2 text-red-600';
msg.textContent = body.error || 'Failed to send invitation';
}
});

loadGroup();
</script>
</body>
</html>
Loading
Loading