-
Notifications
You must be signed in to change notification settings - Fork 20
Study Groups #75
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
Ananya44444
wants to merge
2
commits into
alphaonelabs:main
Choose a base branch
from
Ananya44444:study
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Study Groups #75
Changes from all commits
Commits
Show all changes
2 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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,'&').replace(/</g,'<').replace(/>/g,'>')); | ||
|
|
||
| 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(''); | ||
| } | ||
|
|
||
| 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(); | ||
| } | ||
|
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> | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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,'&').replace(/</g,'<').replace(/>/g,'>')); | ||
|
|
||
| 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> |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.