Edit of links and database versioning
This commit is contained in:
@@ -44,6 +44,8 @@ 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 |
|
||||||
@@ -197,6 +199,9 @@ The public feed is available at:
|
|||||||
GET http://localhost:8000/api/public/feed
|
GET http://localhost:8000/api/public/feed
|
||||||
```
|
```
|
||||||
|
|
||||||
|
When a request includes a valid bearer token, entries owned by that authenticated user include edit permission and show an inline **Edit** action in the feed. The update endpoint is `PUT /api/links/{link_id}` and rejects edits from other users.
|
||||||
|
Feed items also expose `is_owner`; it is `true` only for entries owned by the authenticated user and `false` for anonymous viewers or other users.
|
||||||
|
|
||||||
Admin plugin requests must include the administrator's token:
|
Admin plugin requests must include the administrator's token:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
@@ -214,3 +219,4 @@ Administrators can manage user privileges from the admin page. The `Administrato
|
|||||||
- **Port 8000 is occupied**: run uvicorn with another port and update the extension backend URL, for example `--port 8001`.
|
- **Port 8000 is occupied**: run uvicorn with another port and update the extension backend URL, for example `--port 8001`.
|
||||||
- **Port 80 or 8080 is occupied**: change the host-side ports in `docker-compose.yml`.
|
- **Port 80 or 8080 is occupied**: change the host-side ports in `docker-compose.yml`.
|
||||||
- **Stale development data**: stop the backend and remove `backend/data/linklog.db`, or run `docker compose down -v` for the container volume.
|
- **Stale development data**: stop the backend and remove `backend/data/linklog.db`, or run `docker compose down -v` for the container volume.
|
||||||
|
- **Schema migration issue**: inspect the database version with `sqlite3 backend/data/linklog.db 'PRAGMA user_version;'` and restart the application to apply pending migrations.
|
||||||
|
|||||||
@@ -241,3 +241,33 @@ git commit the whole lot
|
|||||||
|
|
||||||
### Assistant outcome
|
### Assistant outcome
|
||||||
Ran the strict backend suite with 10 passing tests, staged all project changes, expanded `.gitignore` to exclude runtime avatar uploads, confirmed `.env`, caches, virtualenv files, bytecode, databases, and generated assets were not staged, and created the complete-project commit.
|
Ran the strict backend suite with 10 passing tests, staged all project changes, expanded `.gitignore` to exclude runtime avatar uploads, confirmed `.env`, caches, virtualenv files, bytecode, databases, and generated assets were not staged, and created the complete-project commit.
|
||||||
|
|
||||||
|
### User
|
||||||
|
version the sql schema and make sure it can be upgraded in the future.
|
||||||
|
|
||||||
|
### Assistant outcome
|
||||||
|
Replaced the unversioned schema bootstrap with ordered SQLite migrations using `PRAGMA user_version`. The current schema is migration 1, migrations are applied in order and are idempotent, and a database migration test verifies version tracking and table preservation. README migration guidance was added. The strict backend suite passed with 11 tests.
|
||||||
|
|
||||||
|
### User
|
||||||
|
allow editing of individual links - add a edit button for the links owned by the authenticated user - otherwise do not show a link
|
||||||
|
|
||||||
|
### Assistant outcome
|
||||||
|
Added owner-checked `PUT /api/links/{link_id}` updates and `can_edit` feed metadata based on the authenticated bearer token. The feed shows an inline Edit action only for the current user's links; anonymous viewers and other users receive no edit action. Added owner/permission regression coverage, bumped the feed asset to `v5`, and the strict backend suite passed with 12 tests.
|
||||||
|
|
||||||
|
### User
|
||||||
|
Add authenticated link editing
|
||||||
|
|
||||||
|
### Assistant outcome
|
||||||
|
Verified and strengthened the existing authenticated link-editing flow. Anonymous updates return `401`, owners can edit their links, non-owners receive `404`, and the browser only renders the Edit action when feed metadata has `can_edit: true`. Full strict backend validation passed with 12 tests.
|
||||||
|
|
||||||
|
### User
|
||||||
|
Expose ownership if feed data
|
||||||
|
|
||||||
|
### Assistant outcome
|
||||||
|
Added explicit `is_owner` metadata to each public feed item based on the authenticated bearer token. Anonymous and non-owner responses return `false`; the owner receives `true`. The feed Edit control now uses this field, and the strict backend suite passed with 12 tests.
|
||||||
|
|
||||||
|
### User
|
||||||
|
Render edit action for owned links
|
||||||
|
|
||||||
|
### Assistant outcome
|
||||||
|
Verified that the feed renders the Edit button only when `is_owner` is true, while the backend owner check remains enforced by `PUT /api/links/{link_id}`. The live Docker-served feed asset contains the owner-gated action, focused tests passed, and the full strict suite passed with 12 tests.
|
||||||
|
|||||||
@@ -43,6 +43,11 @@
|
|||||||
39. For the user filter create a dropdown of all users that are available on the server
|
39. For the user filter create a dropdown of all users that are available on the server
|
||||||
40. If I select a user filter I want the appropriate page to be opened (e.g. /user/ for user of / -home- for all-users)
|
40. If I select a user filter I want the appropriate page to be opened (e.g. /user/ for user of / -home- for all-users)
|
||||||
41. git commit the whole lot
|
41. git commit the whole lot
|
||||||
|
42. version the sql schema and make sure it can be upgraded in the future.
|
||||||
|
43. allow editing of individual links - add a edit button for the links owned by the authenticated user - otherwise do not show a link
|
||||||
|
44. Add authenticated link editing
|
||||||
|
45. Expose ownership if feed data
|
||||||
|
46. Render edit action for owned links
|
||||||
|
|
||||||
## Future entries
|
## Future entries
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
from fastapi import APIRouter, Header, HTTPException, status
|
from fastapi import APIRouter, Header, HTTPException, status
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from backend.app.services.link_service import create_link, list_public_links
|
from backend.app.services.link_service import create_link, list_public_links, update_link
|
||||||
from backend.app.services.plugin_manager import plugin_manager
|
from backend.app.services.plugin_manager import plugin_manager
|
||||||
from backend.app.services.token_service import validate_token
|
from backend.app.services.token_service import validate_token
|
||||||
|
|
||||||
@@ -15,6 +15,12 @@ class LinkCreate(BaseModel):
|
|||||||
timestamp: str | None = None
|
timestamp: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class LinkUpdate(BaseModel):
|
||||||
|
title: str
|
||||||
|
url: str
|
||||||
|
comment: str = ''
|
||||||
|
|
||||||
|
|
||||||
@router.post('/links', status_code=status.HTTP_201_CREATED)
|
@router.post('/links', status_code=status.HTTP_201_CREATED)
|
||||||
def create_link_endpoint(payload: LinkCreate, authorization: str | None = Header(default=None)):
|
def create_link_endpoint(payload: LinkCreate, authorization: str | None = Header(default=None)):
|
||||||
if not authorization or not authorization.startswith('Bearer '):
|
if not authorization or not authorization.startswith('Bearer '):
|
||||||
@@ -29,6 +35,24 @@ def create_link_endpoint(payload: LinkCreate, authorization: str | None = Header
|
|||||||
return record
|
return record
|
||||||
|
|
||||||
|
|
||||||
|
@router.put('/links/{link_id}')
|
||||||
|
def update_link_endpoint(
|
||||||
|
link_id: str,
|
||||||
|
payload: LinkUpdate,
|
||||||
|
authorization: str | None = Header(default=None),
|
||||||
|
):
|
||||||
|
if not authorization or not authorization.startswith('Bearer '):
|
||||||
|
raise HTTPException(status_code=401, detail='Missing or invalid Authorization header')
|
||||||
|
info = validate_token(authorization.replace('Bearer ', '', 1))
|
||||||
|
if info is None:
|
||||||
|
raise HTTPException(status_code=401, detail='Token expired or invalid')
|
||||||
|
|
||||||
|
record = update_link(link_id, info['user_id'], payload.title, payload.url, payload.comment)
|
||||||
|
if record is None:
|
||||||
|
raise HTTPException(status_code=404, detail='Link not found or not owned by user')
|
||||||
|
return record
|
||||||
|
|
||||||
|
|
||||||
@router.get('/links')
|
@router.get('/links')
|
||||||
def list_links():
|
def list_links():
|
||||||
return list_public_links()
|
return list_public_links()
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
from fastapi import APIRouter
|
from fastapi import APIRouter, Depends
|
||||||
|
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||||
|
|
||||||
from backend.app.services.link_service import list_public_links, list_public_users
|
from backend.app.services.link_service import list_public_links, list_public_users
|
||||||
|
from backend.app.services.token_service import validate_token
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
optional_bearer = HTTPBearer(auto_error=False)
|
||||||
|
|
||||||
|
|
||||||
@router.get('/users')
|
@router.get('/users')
|
||||||
@@ -12,7 +15,15 @@ def public_users():
|
|||||||
|
|
||||||
@router.get('/feed')
|
@router.get('/feed')
|
||||||
@router.get('/feed/{username}')
|
@router.get('/feed/{username}')
|
||||||
def public_feed(username: str | None = None):
|
def public_feed(
|
||||||
|
username: str | None = None,
|
||||||
|
credentials: HTTPAuthorizationCredentials | None = Depends(optional_bearer),
|
||||||
|
):
|
||||||
|
current_user_id = None
|
||||||
|
if credentials:
|
||||||
|
token_data = validate_token(credentials.credentials)
|
||||||
|
if token_data:
|
||||||
|
current_user_id = token_data['user_id']
|
||||||
items = list_public_links(username)
|
items = list_public_links(username)
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
@@ -25,6 +36,8 @@ def public_feed(username: str | None = None):
|
|||||||
'avatar_url': item['avatar_url'],
|
'avatar_url': item['avatar_url'],
|
||||||
'bio': item['bio'],
|
'bio': item['bio'],
|
||||||
},
|
},
|
||||||
|
'is_owner': item['user_id'] == current_user_id,
|
||||||
|
'can_edit': item['user_id'] == current_user_id,
|
||||||
'created_at': item['created_at'],
|
'created_at': item['created_at'],
|
||||||
}
|
}
|
||||||
for item in items
|
for item in items
|
||||||
|
|||||||
+19
-3
@@ -13,7 +13,8 @@ AVATARS_DIR.mkdir(parents=True, exist_ok=True)
|
|||||||
def hash_password(password: str) -> str:
|
def hash_password(password: str) -> str:
|
||||||
return sha256(password.encode('utf-8')).hexdigest()
|
return sha256(password.encode('utf-8')).hexdigest()
|
||||||
|
|
||||||
SCHEMA = '''
|
MIGRATIONS = [
|
||||||
|
(1, '''
|
||||||
CREATE TABLE IF NOT EXISTS users (
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
username TEXT NOT NULL UNIQUE,
|
username TEXT NOT NULL UNIQUE,
|
||||||
@@ -70,7 +71,8 @@ CREATE TABLE IF NOT EXISTS user_plugin_config (
|
|||||||
UNIQUE(user_id, plugin_name),
|
UNIQUE(user_id, plugin_name),
|
||||||
FOREIGN KEY(user_id) REFERENCES users(id)
|
FOREIGN KEY(user_id) REFERENCES users(id)
|
||||||
);
|
);
|
||||||
'''
|
'''),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def get_connection() -> sqlite3.Connection:
|
def get_connection() -> sqlite3.Connection:
|
||||||
@@ -80,9 +82,23 @@ def get_connection() -> sqlite3.Connection:
|
|||||||
return conn
|
return conn
|
||||||
|
|
||||||
|
|
||||||
|
def get_schema_version(conn: sqlite3.Connection) -> int:
|
||||||
|
return conn.execute('PRAGMA user_version').fetchone()[0]
|
||||||
|
|
||||||
|
|
||||||
|
def apply_migrations(conn: sqlite3.Connection) -> None:
|
||||||
|
current_version = get_schema_version(conn)
|
||||||
|
for version, sql in MIGRATIONS:
|
||||||
|
if version <= current_version:
|
||||||
|
continue
|
||||||
|
conn.executescript(sql)
|
||||||
|
conn.execute(f'PRAGMA user_version = {version}')
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
def init_db() -> None:
|
def init_db() -> None:
|
||||||
with get_connection() as conn:
|
with get_connection() as conn:
|
||||||
conn.executescript(SCHEMA)
|
apply_migrations(conn)
|
||||||
alice_hash = hash_password('secret123')
|
alice_hash = hash_password('secret123')
|
||||||
bob_hash = hash_password('secret123')
|
bob_hash = hash_password('secret123')
|
||||||
conn.execute(
|
conn.execute(
|
||||||
|
|||||||
@@ -57,6 +57,24 @@ def list_public_links(username: str | None = None):
|
|||||||
return [dict(row) for row in rows]
|
return [dict(row) for row in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def update_link(link_id: str, user_id: str, title: str, url: str, comment: str):
|
||||||
|
cleaned_url = clean_url(url)
|
||||||
|
with get_connection() as conn:
|
||||||
|
cursor = conn.execute(
|
||||||
|
'''
|
||||||
|
UPDATE links
|
||||||
|
SET title = ?, url = ?, comment = ?, updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = ? AND user_id = ?
|
||||||
|
''',
|
||||||
|
(title, cleaned_url, comment, link_id, user_id),
|
||||||
|
)
|
||||||
|
if cursor.rowcount == 0:
|
||||||
|
return None
|
||||||
|
conn.commit()
|
||||||
|
row = conn.execute('SELECT * FROM links WHERE id = ?', (link_id,)).fetchone()
|
||||||
|
return dict(row)
|
||||||
|
|
||||||
|
|
||||||
def list_public_users():
|
def list_public_users():
|
||||||
with get_connection() as conn:
|
with get_connection() as conn:
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
|
|||||||
@@ -111,6 +111,47 @@ def test_submit_link_stores_cleaned_url_and_public_feed():
|
|||||||
assert 'alice' in users_response.json()
|
assert 'alice' in users_response.json()
|
||||||
|
|
||||||
|
|
||||||
|
def test_only_link_owner_can_edit_link():
|
||||||
|
owner_headers = login_headers('alice')
|
||||||
|
response = client.post('/api/links', headers=owner_headers, json={
|
||||||
|
'title': 'Editable link',
|
||||||
|
'url': 'https://example.com/editable?utm_source=test',
|
||||||
|
'comment': 'Before edit',
|
||||||
|
})
|
||||||
|
assert response.status_code == 201
|
||||||
|
link_id = response.json()['id']
|
||||||
|
|
||||||
|
unauthenticated = client.put(f'/api/links/{link_id}', json={
|
||||||
|
'title': 'Not allowed',
|
||||||
|
'url': 'https://example.com/not-allowed',
|
||||||
|
})
|
||||||
|
assert unauthenticated.status_code == 401
|
||||||
|
|
||||||
|
anonymous_feed = client.get('/api/public/feed').json()
|
||||||
|
anonymous_link = next(item for item in anonymous_feed if item['id'] == link_id)
|
||||||
|
assert anonymous_link['is_owner'] is False
|
||||||
|
assert anonymous_link['can_edit'] is False
|
||||||
|
|
||||||
|
owner_feed = client.get('/api/public/feed', headers=owner_headers).json()
|
||||||
|
owner_link = next(item for item in owner_feed if item['id'] == link_id)
|
||||||
|
assert owner_link['is_owner'] is True
|
||||||
|
assert owner_link['can_edit'] is True
|
||||||
|
|
||||||
|
edited = client.put(f'/api/links/{link_id}', headers=owner_headers, json={
|
||||||
|
'title': 'Edited link',
|
||||||
|
'url': 'https://example.com/edited',
|
||||||
|
'comment': 'After edit',
|
||||||
|
})
|
||||||
|
assert edited.status_code == 200
|
||||||
|
assert edited.json()['title'] == 'Edited link'
|
||||||
|
|
||||||
|
denied = client.put(f'/api/links/{link_id}', headers=login_headers('bob'), json={
|
||||||
|
'title': 'Not allowed',
|
||||||
|
'url': 'https://example.com/not-allowed',
|
||||||
|
})
|
||||||
|
assert denied.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
def test_logout_revokes_token_and_admin_can_list_plugins():
|
def test_logout_revokes_token_and_admin_can_list_plugins():
|
||||||
headers = login_headers()
|
headers = login_headers()
|
||||||
token = headers['Authorization'].removeprefix('Bearer ')
|
token = headers['Authorization'].removeprefix('Bearer ')
|
||||||
@@ -149,6 +190,8 @@ def test_public_and_admin_pages_render_html():
|
|||||||
assert 'id="admin-auth-notice" class="auth-notice hidden"' in admin_page
|
assert 'id="admin-auth-notice" class="auth-notice hidden"' in admin_page
|
||||||
assert 'id="admin-login-button" class="login-button" href="/login"' in admin_page
|
assert 'id="admin-login-button" class="login-button" href="/login"' in admin_page
|
||||||
assert 'id="logout-button" class="logout-button hidden"' in admin_page
|
assert 'id="logout-button" class="logout-button hidden"' in admin_page
|
||||||
|
feed_script = client.get('/static/feed.js?v=5').text
|
||||||
|
assert 'if (item.is_owner)' in feed_script
|
||||||
|
|
||||||
|
|
||||||
def test_link_submission_posts_to_enabled_mastodon_plugin():
|
def test_link_submission_posts_to_enabled_mastodon_plugin():
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import sqlite3
|
||||||
|
|
||||||
|
from backend.app.database import apply_migrations, get_schema_version
|
||||||
|
|
||||||
|
|
||||||
|
def test_database_migrations_are_versioned_and_idempotent():
|
||||||
|
connection = sqlite3.connect(':memory:')
|
||||||
|
|
||||||
|
apply_migrations(connection)
|
||||||
|
assert get_schema_version(connection) == 1
|
||||||
|
tables = {
|
||||||
|
row[0]
|
||||||
|
for row in connection.execute(
|
||||||
|
"SELECT name FROM sqlite_master WHERE type = 'table'"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
assert {'users', 'tokens', 'links', 'plugins', 'user_plugin_config'} <= tables
|
||||||
|
|
||||||
|
apply_migrations(connection)
|
||||||
|
assert get_schema_version(connection) == 1
|
||||||
|
|
||||||
|
connection.close()
|
||||||
+38
-1
@@ -3,6 +3,7 @@ const sortSelect = document.getElementById('sort-select');
|
|||||||
const userFilter = document.getElementById('user-filter');
|
const userFilter = document.getElementById('user-filter');
|
||||||
|
|
||||||
const cookieName = 'linklog-feed-preferences';
|
const cookieName = 'linklog-feed-preferences';
|
||||||
|
const accessToken = localStorage.getItem('linklogAccessToken');
|
||||||
|
|
||||||
function createAvatar(user) {
|
function createAvatar(user) {
|
||||||
const avatar = document.createElement('div');
|
const avatar = document.createElement('div');
|
||||||
@@ -82,6 +83,15 @@ function renderFeed(items, showIdentity = true) {
|
|||||||
meta.className = 'meta';
|
meta.className = 'meta';
|
||||||
meta.textContent = item.created_at || 'updated recently';
|
meta.textContent = item.created_at || 'updated recently';
|
||||||
|
|
||||||
|
if (item.is_owner) {
|
||||||
|
const editButton = document.createElement('button');
|
||||||
|
editButton.type = 'button';
|
||||||
|
editButton.className = 'edit-button';
|
||||||
|
editButton.textContent = 'Edit';
|
||||||
|
editButton.addEventListener('click', () => showEditForm(article, item));
|
||||||
|
article.appendChild(editButton);
|
||||||
|
}
|
||||||
|
|
||||||
if (showIdentity) {
|
if (showIdentity) {
|
||||||
const header = document.createElement('div');
|
const header = document.createElement('div');
|
||||||
header.className = 'link-header';
|
header.className = 'link-header';
|
||||||
@@ -100,12 +110,39 @@ function renderFeed(items, showIdentity = true) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function showEditForm(article, item) {
|
||||||
|
if (article.querySelector('.edit-form')) return;
|
||||||
|
const form = document.createElement('form');
|
||||||
|
form.className = 'edit-form';
|
||||||
|
form.innerHTML = `
|
||||||
|
<label>Title <input name="title" value=""></label>
|
||||||
|
<label>URL <input name="url" type="url" value=""></label>
|
||||||
|
<label>Comment <textarea name="comment"></textarea></label>
|
||||||
|
<button type="submit">Save changes</button>
|
||||||
|
`;
|
||||||
|
form.elements.title.value = item.title || '';
|
||||||
|
form.elements.url.value = item.url || '';
|
||||||
|
form.elements.comment.value = item.comment || '';
|
||||||
|
form.addEventListener('submit', async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const response = await fetch(`/api/links/${encodeURIComponent(item.id)}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {'Content-Type': 'application/json', Authorization: `Bearer ${accessToken}`},
|
||||||
|
body: JSON.stringify(Object.fromEntries(new FormData(form))),
|
||||||
|
});
|
||||||
|
if (response.ok) loadFeed();
|
||||||
|
});
|
||||||
|
article.appendChild(form);
|
||||||
|
}
|
||||||
|
|
||||||
async function loadFeed() {
|
async function loadFeed() {
|
||||||
const routeUser = document.body.dataset.userFilter;
|
const routeUser = document.body.dataset.userFilter;
|
||||||
const endpoint = routeUser
|
const endpoint = routeUser
|
||||||
? `/api/public/feed/${encodeURIComponent(routeUser)}`
|
? `/api/public/feed/${encodeURIComponent(routeUser)}`
|
||||||
: '/api/public/feed';
|
: '/api/public/feed';
|
||||||
const response = await fetch(endpoint);
|
const response = await fetch(endpoint, {
|
||||||
|
headers: accessToken ? {Authorization: `Bearer ${accessToken}`} : {},
|
||||||
|
});
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
let items = data || [];
|
let items = data || [];
|
||||||
|
|||||||
@@ -331,6 +331,23 @@ button:disabled {
|
|||||||
color: var(--red) !important;
|
color: var(--red) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.edit-button {
|
||||||
|
min-width: 0;
|
||||||
|
margin-top: 14px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: var(--surface-1);
|
||||||
|
border-color: var(--surface-2);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-form {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 16px;
|
||||||
|
padding-top: 16px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
.feed {
|
.feed {
|
||||||
padding-bottom: 40px;
|
padding-bottom: 40px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,6 +54,6 @@
|
|||||||
<section id="feed" class="feed" aria-live="polite"></section>
|
<section id="feed" class="feed" aria-live="polite"></section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<script src="/static/feed.js?v=4"></script>
|
<script src="/static/feed.js?v=5"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Reference in New Issue
Block a user