Label editing
This commit is contained in:
@@ -44,15 +44,17 @@ On Windows PowerShell, activate the environment with:
|
|||||||
|
|
||||||
The SQLite database is created automatically at `backend/data/linklog.db` when the application starts or when the auth router is imported. The starter accounts are:
|
The SQLite database is created automatically at `backend/data/linklog.db` when the application starts or when the auth router is imported. The starter accounts are:
|
||||||
|
|
||||||
The database schema is versioned with SQLite `PRAGMA user_version`. Application startup applies all pending migrations in order, so updating the application does not require deleting an existing database. New schema changes should be added as a new numbered migration in `backend/app/database.py`; existing migration entries must remain unchanged.
|
|
||||||
|
|
||||||
| Username | Password | Role |
|
| Username | Password | Role |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `alice` | `secret123` | administrator |
|
| `alice` | `secret123` | administrator |
|
||||||
| `bob` | `secret123` | standard user |
|
| `bob` | `secret123` | standard user |
|
||||||
|
|
||||||
|
|
||||||
These credentials are for development only. Change the authentication and seeding design before deploying publicly.
|
These credentials are for development only. Change the authentication and seeding design before deploying publicly.
|
||||||
|
|
||||||
|
The database schema is versioned with SQLite `PRAGMA user_version`. Application startup applies all pending migrations in order, so updating the application does not require deleting an existing database. New schema changes should be added as a new numbered migration in `backend/app/database.py`; existing migration entries must remain unchanged.
|
||||||
## Run The Backend
|
## Run The Backend
|
||||||
|
|
||||||
Activate the virtual environment, then run uvicorn from the repository root:
|
Activate the virtual environment, then run uvicorn from the repository root:
|
||||||
@@ -67,6 +69,7 @@ Open these URLs:
|
|||||||
- Public feed: <http://localhost:8000/>
|
- Public feed: <http://localhost:8000/>
|
||||||
- User feed: <http://localhost:8000/alice>
|
- User feed: <http://localhost:8000/alice>
|
||||||
- Profile settings: <http://localhost:8000/profile>
|
- Profile settings: <http://localhost:8000/profile>
|
||||||
|
- Labels: <http://localhost:8000/labels>
|
||||||
- Admin page: <http://localhost:8000/admin>
|
- Admin page: <http://localhost:8000/admin>
|
||||||
- Web login: <http://localhost:8000/login>
|
- Web login: <http://localhost:8000/login>
|
||||||
- Health check: <http://localhost:8000/health>
|
- Health check: <http://localhost:8000/health>
|
||||||
@@ -147,9 +150,9 @@ docker compose up --build
|
|||||||
|
|
||||||
The services are available at:
|
The services are available at:
|
||||||
|
|
||||||
- LinkLog through Traefik: <http://localhost/>
|
- LinkLog through Traefik: <http://example.com/>
|
||||||
- Direct application port: <http://localhost:8000/>
|
- Direct application port: <http://localhost:8000/>
|
||||||
- Traefik dashboard: <http://localhost:8080/dashboard/>
|
|
||||||
|
|
||||||
The Compose configuration routes the hostname `localhost` through Traefik. The SQLite database is stored in the named Docker volume `linklog_data`, mounted at `/app/backend/data`.
|
The Compose configuration routes the hostname `localhost` through Traefik. The SQLite database is stored in the named Docker volume `linklog_data`, mounted at `/app/backend/data`.
|
||||||
The application runs as a non-root user and reports container health through `/health`; Traefik waits for the application health check before starting.
|
The application runs as a non-root user and reports container health through `/health`; Traefik waits for the application health check before starting.
|
||||||
@@ -209,6 +212,7 @@ Feed items also expose `is_owner`; it is `true` only for entries owned by the au
|
|||||||
Links support zero to ten tags. Tags are trimmed, deduplicated case-insensitively, and retain their original casing for display. The Firefox capture popup shows existing server tags as checkboxes and accepts new comma-separated tags. The home page displays tags and provides a case-insensitive tag filter; editing a link replaces its complete tag set.
|
Links support zero to ten tags. Tags are trimmed, deduplicated case-insensitively, and retain their original casing for display. The Firefox capture popup shows existing server tags as checkboxes and accepts new comma-separated tags. The home page displays tags and provides a case-insensitive tag filter; editing a link replaces its complete tag set.
|
||||||
The inline link editor also allows multiple existing tags to be selected and new tags to be entered. New tag values receive a leading `#` automatically, and the interface prevents saving more than ten tags.
|
The inline link editor also allows multiple existing tags to be selected and new tags to be entered. New tag values receive a leading `#` automatically, and the interface prevents saving more than ten tags.
|
||||||
The installation seeds these available tags: `#Internet`, `#Cybersecurity`, `#Fediverse`, `#Food`, `#Photography`, `#Music`, and `#AI`.
|
The installation seeds these available tags: `#Internet`, `#Cybersecurity`, `#Fediverse`, `#Food`, `#Photography`, `#Music`, and `#AI`.
|
||||||
|
Users can create, edit, and delete their own labels from `/labels`. Administrators can delete any label from `/admin`; system-seeded labels have no user owner.
|
||||||
|
|
||||||
Admin plugin requests must include the administrator's token:
|
Admin plugin requests must include the administrator's token:
|
||||||
|
|
||||||
|
|||||||
@@ -367,3 +367,9 @@ Display the date as "2026 June 18 - 20:22"
|
|||||||
|
|
||||||
### Assistant outcome
|
### Assistant outcome
|
||||||
Updated feed metadata rendering to format timestamps as `YYYY Month D - HH:mm`, such as `2026 June 18 - 20:22`, using zero-padded 24-hour time. Invalid timestamps retain the existing fallback text. The feed script and strict backend suite passed with 13 tests.
|
Updated feed metadata rendering to format timestamps as `YYYY Month D - HH:mm`, such as `2026 June 18 - 20:22`, using zero-padded 24-hour time. Invalid timestamps retain the existing fallback text. The feed script and strict backend suite passed with 13 tests.
|
||||||
|
|
||||||
|
### User
|
||||||
|
Create a page where users can add, delete, or edit labels - Labels can be edited and deleted by the same user that created them. The administrator can delete any label.
|
||||||
|
|
||||||
|
### Assistant outcome
|
||||||
|
Added schema migration 4 with label ownership, authenticated user label CRUD endpoints and a `/labels` management page. Label creators can edit/delete their own labels, non-owners are denied, and administrators can delete any label including another user's labels. Added administrator label listing/deletion controls and regression coverage. The strict backend suite passed with 15 tests.
|
||||||
|
|||||||
@@ -64,6 +64,7 @@
|
|||||||
60. when editing lables I want to be able to selec multiple tags or add one myself.
|
60. when editing lables I want to be able to selec multiple tags or add one myself.
|
||||||
61. Make the tags filtering case incensitive (but maintain case of tags)
|
61. Make the tags filtering case incensitive (but maintain case of tags)
|
||||||
62. Display the date as "2026 June 18 - 20:22"
|
62. Display the date as "2026 June 18 - 20:22"
|
||||||
|
63. Create a page where users can add, delete, or edit labels - Labels can be edited and deleted by the same user that created them. The administrator can delete any label.
|
||||||
|
|
||||||
## Future entries
|
## Future entries
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from pydantic import BaseModel
|
|||||||
|
|
||||||
from backend.app.api.dependencies import require_admin
|
from backend.app.api.dependencies import require_admin
|
||||||
from backend.app.database import get_connection, hash_password
|
from backend.app.database import get_connection, hash_password
|
||||||
|
from backend.app.services.link_service import delete_label
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -127,6 +128,26 @@ def delete_user(user_id: str, current_user: dict = Depends(require_admin)):
|
|||||||
return {'status': 'deleted', 'id': user_id}
|
return {'status': 'deleted', 'id': user_id}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete('/labels/{label_id}')
|
||||||
|
def admin_delete_label(label_id: str, _: dict = Depends(require_admin)):
|
||||||
|
if not delete_label(label_id, is_admin=True):
|
||||||
|
raise HTTPException(status_code=404, detail='Label not found')
|
||||||
|
return {'status': 'deleted', 'id': label_id}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get('/labels')
|
||||||
|
def admin_list_labels(_: dict = Depends(require_admin)):
|
||||||
|
with get_connection() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
'''
|
||||||
|
SELECT tags.id, tags.name, tags.created_by, users.username AS creator
|
||||||
|
FROM tags LEFT JOIN users ON users.id = tags.created_by
|
||||||
|
ORDER BY tags.name
|
||||||
|
'''
|
||||||
|
).fetchall()
|
||||||
|
return [dict(row) for row in rows]
|
||||||
|
|
||||||
|
|
||||||
@router.get('/plugins')
|
@router.get('/plugins')
|
||||||
def list_plugins(_: dict = Depends(require_admin)):
|
def list_plugins(_: dict = Depends(require_admin)):
|
||||||
with get_connection() as conn:
|
with get_connection() as conn:
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from pydantic import BaseModel
|
|||||||
|
|
||||||
from backend.app.api.dependencies import get_current_user
|
from backend.app.api.dependencies import get_current_user
|
||||||
from backend.app.database import AVATARS_DIR, get_connection, hash_password
|
from backend.app.database import AVATARS_DIR, get_connection, hash_password
|
||||||
|
from backend.app.services.link_service import create_label, delete_label, list_user_labels, update_label
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -27,6 +28,10 @@ class UserPluginConfigUpdate(BaseModel):
|
|||||||
hashtag: str | None = None
|
hashtag: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class LabelUpdate(BaseModel):
|
||||||
|
name: str
|
||||||
|
|
||||||
|
|
||||||
@router.get('/me')
|
@router.get('/me')
|
||||||
def get_current_user_profile(user: dict = Depends(get_current_user)):
|
def get_current_user_profile(user: dict = Depends(get_current_user)):
|
||||||
with get_connection() as conn:
|
with get_connection() as conn:
|
||||||
@@ -81,6 +86,37 @@ def update_password(payload: PasswordUpdate, user: dict = Depends(get_current_us
|
|||||||
return {'status': 'password_updated'}
|
return {'status': 'password_updated'}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get('/labels')
|
||||||
|
def get_labels(user: dict = Depends(get_current_user)):
|
||||||
|
return list_user_labels(user['id'])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post('/labels', status_code=201)
|
||||||
|
def add_label(payload: LabelUpdate, user: dict = Depends(get_current_user)):
|
||||||
|
try:
|
||||||
|
return create_label(user['id'], payload.name)
|
||||||
|
except ValueError as error:
|
||||||
|
raise HTTPException(status_code=409, detail=str(error)) from error
|
||||||
|
|
||||||
|
|
||||||
|
@router.put('/labels/{label_id}')
|
||||||
|
def edit_label(label_id: str, payload: LabelUpdate, user: dict = Depends(get_current_user)):
|
||||||
|
try:
|
||||||
|
result = update_label(label_id, user['id'], payload.name)
|
||||||
|
except ValueError as error:
|
||||||
|
raise HTTPException(status_code=409, detail=str(error)) from error
|
||||||
|
if result is None:
|
||||||
|
raise HTTPException(status_code=404, detail='Label not found or not owned by user')
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete('/labels/{label_id}')
|
||||||
|
def remove_label(label_id: str, user: dict = Depends(get_current_user)):
|
||||||
|
if not delete_label(label_id, user['id']):
|
||||||
|
raise HTTPException(status_code=404, detail='Label not found or not owned by user')
|
||||||
|
return {'status': 'deleted', 'id': label_id}
|
||||||
|
|
||||||
|
|
||||||
@router.post('/avatar')
|
@router.post('/avatar')
|
||||||
async def upload_avatar(
|
async def upload_avatar(
|
||||||
avatar: UploadFile = File(...),
|
avatar: UploadFile = File(...),
|
||||||
|
|||||||
@@ -94,6 +94,10 @@ CREATE INDEX IF NOT EXISTS idx_link_tags_tag_id ON link_tags(tag_id);
|
|||||||
'''),
|
'''),
|
||||||
(3, '''
|
(3, '''
|
||||||
UPDATE tags SET name = '#' || name WHERE name NOT LIKE '#%';
|
UPDATE tags SET name = '#' || name WHERE name NOT LIKE '#%';
|
||||||
|
'''),
|
||||||
|
(4, '''
|
||||||
|
ALTER TABLE tags ADD COLUMN created_by TEXT REFERENCES users(id) ON DELETE SET NULL;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tags_created_by ON tags(created_by);
|
||||||
'''),
|
'''),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -40,6 +40,11 @@ async def user_profile_page(request: Request):
|
|||||||
return templates.TemplateResponse(request, 'user_profile.html', {})
|
return templates.TemplateResponse(request, 'user_profile.html', {})
|
||||||
|
|
||||||
|
|
||||||
|
@app.get('/labels', response_class=HTMLResponse)
|
||||||
|
async def labels_page(request: Request):
|
||||||
|
return templates.TemplateResponse(request, 'labels.html', {})
|
||||||
|
|
||||||
|
|
||||||
@app.get('/login', response_class=HTMLResponse)
|
@app.get('/login', response_class=HTMLResponse)
|
||||||
async def login_page(request: Request):
|
async def login_page(request: Request):
|
||||||
return templates.TemplateResponse(request, 'login.html', {})
|
return templates.TemplateResponse(request, 'login.html', {})
|
||||||
|
|||||||
@@ -172,3 +172,65 @@ def list_tags():
|
|||||||
tags.append(row['name'])
|
tags.append(row['name'])
|
||||||
seen.add(row['name'].casefold())
|
seen.add(row['name'].casefold())
|
||||||
return tags
|
return tags
|
||||||
|
|
||||||
|
|
||||||
|
def list_user_labels(user_id: str):
|
||||||
|
with get_connection() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
'SELECT id, name, created_by FROM tags WHERE created_by = ? ORDER BY name',
|
||||||
|
(user_id,),
|
||||||
|
).fetchall()
|
||||||
|
return [dict(row) for row in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def create_label(user_id: str, name: str):
|
||||||
|
normalized = normalize_tags([name])
|
||||||
|
label = normalized[0] if normalized else ''
|
||||||
|
if not label:
|
||||||
|
raise ValueError('Label cannot be empty')
|
||||||
|
with get_connection() as conn:
|
||||||
|
existing = conn.execute(
|
||||||
|
'SELECT id FROM tags WHERE lower(name) = lower(?)', (label,)
|
||||||
|
).fetchone()
|
||||||
|
if existing:
|
||||||
|
raise ValueError('Label already exists')
|
||||||
|
label_id = str(uuid4())
|
||||||
|
conn.execute(
|
||||||
|
'INSERT INTO tags (id, name, created_by) VALUES (?, ?, ?)',
|
||||||
|
(label_id, label, user_id),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return {'id': label_id, 'name': label, 'created_by': user_id}
|
||||||
|
|
||||||
|
|
||||||
|
def update_label(label_id: str, user_id: str, name: str):
|
||||||
|
normalized = normalize_tags([name])
|
||||||
|
if not normalized:
|
||||||
|
raise ValueError('Label cannot be empty')
|
||||||
|
with get_connection() as conn:
|
||||||
|
current = conn.execute(
|
||||||
|
'SELECT id, name, created_by FROM tags WHERE id = ?', (label_id,)
|
||||||
|
).fetchone()
|
||||||
|
if current is None or current['created_by'] != user_id:
|
||||||
|
return None
|
||||||
|
duplicate = conn.execute(
|
||||||
|
'SELECT id FROM tags WHERE lower(name) = lower(?) AND id != ?',
|
||||||
|
(normalized[0], label_id),
|
||||||
|
).fetchone()
|
||||||
|
if duplicate:
|
||||||
|
raise ValueError('Label already exists')
|
||||||
|
conn.execute('UPDATE tags SET name = ? WHERE id = ?', (normalized[0], label_id))
|
||||||
|
conn.commit()
|
||||||
|
return {'id': label_id, 'name': normalized[0], 'created_by': user_id}
|
||||||
|
|
||||||
|
|
||||||
|
def delete_label(label_id: str, user_id: str | None = None, is_admin: bool = False):
|
||||||
|
with get_connection() as conn:
|
||||||
|
current = conn.execute(
|
||||||
|
'SELECT id, created_by FROM tags WHERE id = ?', (label_id,)
|
||||||
|
).fetchone()
|
||||||
|
if current is None or (not is_admin and current['created_by'] != user_id):
|
||||||
|
return False
|
||||||
|
conn.execute('DELETE FROM tags WHERE id = ?', (label_id,))
|
||||||
|
conn.commit()
|
||||||
|
return True
|
||||||
|
|||||||
@@ -84,6 +84,32 @@ def test_admin_can_toggle_privileges_without_removing_last_admin():
|
|||||||
assert last_admin.status_code == 400
|
assert last_admin.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_users_manage_owned_labels_and_admin_can_delete_any_label():
|
||||||
|
alice_headers = login_headers('alice')
|
||||||
|
created = client.post('/api/user/labels', headers=alice_headers, json={'name': 'My Label'})
|
||||||
|
assert created.status_code == 201
|
||||||
|
label = created.json()
|
||||||
|
assert label['name'] == '#My Label'
|
||||||
|
|
||||||
|
edited = client.put(f"/api/user/labels/{label['id']}", headers=alice_headers, json={'name': '#Renamed'})
|
||||||
|
assert edited.status_code == 200
|
||||||
|
assert edited.json()['name'] == '#Renamed'
|
||||||
|
|
||||||
|
denied = client.put(f"/api/user/labels/{label['id']}", headers=login_headers('bob'), json={'name': '#Nope'})
|
||||||
|
assert denied.status_code == 404
|
||||||
|
assert client.delete(f"/api/user/labels/{label['id']}", headers=login_headers('bob')).status_code == 404
|
||||||
|
|
||||||
|
admin_delete = client.delete(f"/api/admin/labels/{label['id']}", headers=alice_headers)
|
||||||
|
assert admin_delete.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_labels_page_renders_authenticated_management_shell():
|
||||||
|
page = client.get('/labels')
|
||||||
|
assert page.status_code == 200
|
||||||
|
assert 'Labels' in page.text
|
||||||
|
assert 'labels.js?v=1' in page.text
|
||||||
|
|
||||||
|
|
||||||
def test_submit_link_stores_cleaned_url_and_public_feed():
|
def test_submit_link_stores_cleaned_url_and_public_feed():
|
||||||
response = client.post('/api/links', headers=login_headers(), json={
|
response = client.post('/api/links', headers=login_headers(), json={
|
||||||
'title': 'Example page',
|
'title': 'Example page',
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ def test_database_migrations_are_versioned_and_idempotent():
|
|||||||
connection = sqlite3.connect(':memory:')
|
connection = sqlite3.connect(':memory:')
|
||||||
|
|
||||||
apply_migrations(connection)
|
apply_migrations(connection)
|
||||||
assert get_schema_version(connection) == 3
|
assert get_schema_version(connection) == 4
|
||||||
tables = {
|
tables = {
|
||||||
row[0]
|
row[0]
|
||||||
for row in connection.execute(
|
for row in connection.execute(
|
||||||
@@ -17,11 +17,13 @@ def test_database_migrations_are_versioned_and_idempotent():
|
|||||||
assert {
|
assert {
|
||||||
'users', 'tokens', 'links', 'plugins', 'user_plugin_config', 'tags', 'link_tags'
|
'users', 'tokens', 'links', 'plugins', 'user_plugin_config', 'tags', 'link_tags'
|
||||||
} <= tables
|
} <= tables
|
||||||
|
columns = {row[1] for row in connection.execute('PRAGMA table_info(tags)')}
|
||||||
|
assert 'created_by' in columns
|
||||||
seed_default_tags(connection)
|
seed_default_tags(connection)
|
||||||
seeded_tags = {row[0] for row in connection.execute('SELECT name FROM tags')}
|
seeded_tags = {row[0] for row in connection.execute('SELECT name FROM tags')}
|
||||||
assert set(DEFAULT_TAGS) <= seeded_tags
|
assert set(DEFAULT_TAGS) <= seeded_tags
|
||||||
|
|
||||||
apply_migrations(connection)
|
apply_migrations(connection)
|
||||||
assert get_schema_version(connection) == 3
|
assert get_schema_version(connection) == 4
|
||||||
|
|
||||||
connection.close()
|
connection.close()
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
(() => {
|
(() => {
|
||||||
const pluginList = document.querySelector('#plugin-list');
|
const pluginList = document.querySelector('#plugin-list');
|
||||||
|
const adminLabelList = document.querySelector('#admin-label-list');
|
||||||
const userList = document.querySelector('#user-list');
|
const userList = document.querySelector('#user-list');
|
||||||
const userForm = document.querySelector('#user-form');
|
const userForm = document.querySelector('#user-form');
|
||||||
const adminControls = document.querySelector('#admin-controls');
|
const adminControls = document.querySelector('#admin-controls');
|
||||||
@@ -31,6 +32,31 @@ function renderPlugins(plugins) {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderLabels(labels) {
|
||||||
|
adminLabelList.replaceChildren(...labels.map((label) => {
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.className = 'plugin-row';
|
||||||
|
const text = document.createElement('span');
|
||||||
|
text.textContent = `${label.name}${label.creator ? ` (${label.creator})` : ' (default)'}`;
|
||||||
|
const button = document.createElement('button');
|
||||||
|
button.type = 'button';
|
||||||
|
button.className = 'danger-button';
|
||||||
|
button.textContent = 'Delete';
|
||||||
|
button.addEventListener('click', async () => {
|
||||||
|
const response = await fetch(`/api/admin/labels/${label.id}`, {method: 'DELETE', headers: authHeaders()});
|
||||||
|
if (response.ok) loadLabels();
|
||||||
|
});
|
||||||
|
row.append(text, button);
|
||||||
|
return row;
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadLabels() {
|
||||||
|
const response = await fetch('/api/admin/labels', {headers: authHeaders()});
|
||||||
|
if (!response.ok) throw new Error('Could not load labels');
|
||||||
|
renderLabels(await response.json());
|
||||||
|
}
|
||||||
|
|
||||||
function renderUsers(users) {
|
function renderUsers(users) {
|
||||||
userList.replaceChildren(...users.map((user) => {
|
userList.replaceChildren(...users.map((user) => {
|
||||||
const row = document.createElement('div');
|
const row = document.createElement('div');
|
||||||
@@ -95,7 +121,7 @@ async function loadAdminState() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await Promise.all([loadUsers(), loadPlugins()]);
|
await Promise.all([loadUsers(), loadPlugins(), loadLabels()]);
|
||||||
showAdminState(true);
|
showAdminState(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -174,5 +200,6 @@ loadAdminState().catch((error) => {
|
|||||||
adminAuthNotice.classList.remove('hidden');
|
adminAuthNotice.classList.remove('hidden');
|
||||||
userList.textContent = '';
|
userList.textContent = '';
|
||||||
pluginList.textContent = '';
|
pluginList.textContent = '';
|
||||||
|
adminLabelList.textContent = '';
|
||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
(() => {
|
(() => {
|
||||||
const loginButton = document.querySelector('#auth-login-button');
|
const loginButton = document.querySelector('#auth-login-button');
|
||||||
const profileLink = document.querySelector('#auth-profile-link');
|
const profileLink = document.querySelector('#auth-profile-link');
|
||||||
|
const labelsLink = document.querySelector('#auth-labels-link');
|
||||||
const adminLink = document.querySelector('#auth-admin-link');
|
const adminLink = document.querySelector('#auth-admin-link');
|
||||||
const session = document.querySelector('#auth-session');
|
const session = document.querySelector('#auth-session');
|
||||||
const username = document.querySelector('#auth-username');
|
const username = document.querySelector('#auth-username');
|
||||||
@@ -10,7 +11,7 @@
|
|||||||
const menuToggle = document.querySelector('.menu-toggle');
|
const menuToggle = document.querySelector('.menu-toggle');
|
||||||
const logoutButton = document.querySelector('#logout-button');
|
const logoutButton = document.querySelector('#logout-button');
|
||||||
|
|
||||||
if (!loginButton || !profileLink || !adminLink || !session || !username || !menu || !menuToggle || !logoutButton) return;
|
if (!loginButton || !profileLink || !labelsLink || !adminLink || !session || !username || !menu || !menuToggle || !logoutButton) return;
|
||||||
|
|
||||||
menuToggle.addEventListener('click', () => {
|
menuToggle.addEventListener('click', () => {
|
||||||
const isOpen = !menu.classList.contains('hidden');
|
const isOpen = !menu.classList.contains('hidden');
|
||||||
@@ -21,6 +22,7 @@
|
|||||||
function showSignedOut() {
|
function showSignedOut() {
|
||||||
loginButton.classList.remove('hidden');
|
loginButton.classList.remove('hidden');
|
||||||
profileLink.classList.add('hidden');
|
profileLink.classList.add('hidden');
|
||||||
|
labelsLink.classList.add('hidden');
|
||||||
adminLink.classList.add('hidden');
|
adminLink.classList.add('hidden');
|
||||||
session.classList.add('hidden');
|
session.classList.add('hidden');
|
||||||
logoutButton.classList.add('hidden');
|
logoutButton.classList.add('hidden');
|
||||||
@@ -29,6 +31,7 @@
|
|||||||
function showSignedIn(user) {
|
function showSignedIn(user) {
|
||||||
loginButton.classList.add('hidden');
|
loginButton.classList.add('hidden');
|
||||||
profileLink.classList.remove('hidden');
|
profileLink.classList.remove('hidden');
|
||||||
|
labelsLink.classList.remove('hidden');
|
||||||
adminLink.classList.toggle('hidden', !user.is_admin);
|
adminLink.classList.toggle('hidden', !user.is_admin);
|
||||||
session.classList.remove('hidden');
|
session.classList.remove('hidden');
|
||||||
logoutButton.classList.remove('hidden');
|
logoutButton.classList.remove('hidden');
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
<a id="auth-home-link" href="/">Home</a>
|
<a id="auth-home-link" href="/">Home</a>
|
||||||
<a id="auth-login-button" href="/login">Sign in</a>
|
<a id="auth-login-button" href="/login">Sign in</a>
|
||||||
<a id="auth-profile-link" class="hidden" href="/profile">Profile</a>
|
<a id="auth-profile-link" class="hidden" href="/profile">Profile</a>
|
||||||
|
<a id="auth-labels-link" class="hidden" href="/labels">Labels</a>
|
||||||
<a id="auth-admin-link" class="hidden" href="/admin">Admin</a>
|
<a id="auth-admin-link" class="hidden" href="/admin">Admin</a>
|
||||||
<div id="auth-session" class="auth-session hidden">
|
<div id="auth-session" class="auth-session hidden">
|
||||||
<a id="auth-username" class="user-name" href="/"></a>
|
<a id="auth-username" class="user-name" href="/"></a>
|
||||||
@@ -63,6 +64,10 @@
|
|||||||
<h2>Plugins</h2>
|
<h2>Plugins</h2>
|
||||||
<div id="plugin-list" class="plugin-list" aria-live="polite">Loading plugins...</div>
|
<div id="plugin-list" class="plugin-list" aria-live="polite">Loading plugins...</div>
|
||||||
</section>
|
</section>
|
||||||
|
<section class="link-item settings-panel">
|
||||||
|
<h2>Labels</h2>
|
||||||
|
<div id="admin-label-list" class="plugin-list" aria-live="polite">Loading labels...</div>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
<script src="/static/auth-header.js?v=3"></script>
|
<script src="/static/auth-header.js?v=3"></script>
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
<a id="auth-home-link" href="/">Home</a>
|
<a id="auth-home-link" href="/">Home</a>
|
||||||
<a id="auth-login-button" href="/login">Sign in</a>
|
<a id="auth-login-button" href="/login">Sign in</a>
|
||||||
<a id="auth-profile-link" class="hidden" href="/profile">Profile</a>
|
<a id="auth-profile-link" class="hidden" href="/profile">Profile</a>
|
||||||
|
<a id="auth-labels-link" class="hidden" href="/labels">Labels</a>
|
||||||
<a id="auth-admin-link" class="hidden" href="/admin">Admin</a>
|
<a id="auth-admin-link" class="hidden" href="/admin">Admin</a>
|
||||||
<div id="auth-session" class="auth-session hidden">
|
<div id="auth-session" class="auth-session hidden">
|
||||||
<a id="auth-username" class="user-name" href="/"></a>
|
<a id="auth-username" class="user-name" href="/"></a>
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<title>Labels - LinkLog</title>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<link rel="stylesheet" href="/static/style.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header class="site-header">
|
||||||
|
<div class="container">
|
||||||
|
<div class="header-row">
|
||||||
|
<div>
|
||||||
|
<h1>Labels</h1>
|
||||||
|
<p>Manage your link labels</p>
|
||||||
|
</div>
|
||||||
|
<div class="header-actions">
|
||||||
|
<button class="menu-toggle" type="button" aria-expanded="false" aria-controls="auth-menu">Menu</button>
|
||||||
|
<nav id="auth-menu" class="auth-menu hidden" aria-label="Account menu">
|
||||||
|
<a id="auth-home-link" href="/">Home</a>
|
||||||
|
<a id="auth-login-button" href="/login">Sign in</a>
|
||||||
|
<a id="auth-profile-link" class="hidden" href="/profile">Profile</a>
|
||||||
|
<a id="auth-labels-link" class="hidden" href="/labels">Labels</a>
|
||||||
|
<a id="auth-admin-link" class="hidden" href="/admin">Admin</a>
|
||||||
|
<div id="auth-session" class="auth-session hidden"><a id="auth-username" class="user-name" href="/"></a></div>
|
||||||
|
<button id="logout-button" class="logout-button hidden" type="button">Sign out</button>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<main class="container">
|
||||||
|
<p id="label-auth-notice" class="auth-notice hidden">Sign in to manage your labels. <a href="/login">Sign in</a></p>
|
||||||
|
<section id="label-controls" class="link-item settings-panel hidden">
|
||||||
|
<form id="label-form">
|
||||||
|
<label>Label <input id="label-name" name="name" type="text" placeholder="#Example" required /></label>
|
||||||
|
<button type="submit">Add label</button>
|
||||||
|
<p id="label-status" class="status" role="status"></p>
|
||||||
|
</form>
|
||||||
|
<div id="label-list" class="plugin-list"></div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
<script src="/static/auth-header.js?v=3"></script>
|
||||||
|
<script src="/static/logout.js?v=3"></script>
|
||||||
|
<script src="/static/labels.js?v=1"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -20,6 +20,7 @@
|
|||||||
<a id="auth-home-link" href="/">Home</a>
|
<a id="auth-home-link" href="/">Home</a>
|
||||||
<a id="auth-login-button" href="/login">Sign in</a>
|
<a id="auth-login-button" href="/login">Sign in</a>
|
||||||
<a id="auth-profile-link" class="hidden" href="/profile">Profile</a>
|
<a id="auth-profile-link" class="hidden" href="/profile">Profile</a>
|
||||||
|
<a id="auth-labels-link" class="hidden" href="/labels">Labels</a>
|
||||||
<a id="auth-admin-link" class="hidden" href="/admin">Admin</a>
|
<a id="auth-admin-link" class="hidden" href="/admin">Admin</a>
|
||||||
<div id="auth-session" class="auth-session hidden">
|
<div id="auth-session" class="auth-session hidden">
|
||||||
<a id="auth-username" class="user-name" href="/"></a>
|
<a id="auth-username" class="user-name" href="/"></a>
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
<a id="auth-home-link" href="/">Home</a>
|
<a id="auth-home-link" href="/">Home</a>
|
||||||
<a id="auth-login-button" href="/login">Sign in</a>
|
<a id="auth-login-button" href="/login">Sign in</a>
|
||||||
<a id="auth-profile-link" class="hidden" href="/profile">Profile</a>
|
<a id="auth-profile-link" class="hidden" href="/profile">Profile</a>
|
||||||
|
<a id="auth-labels-link" class="hidden" href="/labels">Labels</a>
|
||||||
<a id="auth-admin-link" class="hidden" href="/admin">Admin</a>
|
<a id="auth-admin-link" class="hidden" href="/admin">Admin</a>
|
||||||
<div id="auth-session" class="auth-session hidden">
|
<div id="auth-session" class="auth-session hidden">
|
||||||
<a id="auth-username" class="user-name" href="/"></a>
|
<a id="auth-username" class="user-name" href="/"></a>
|
||||||
|
|||||||
Reference in New Issue
Block a user