Complete authentication and profile features
This commit is contained in:
@@ -14,3 +14,4 @@ __pycache__/
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
*.log
|
||||
backend/data/avatars/
|
||||
|
||||
@@ -76,7 +76,7 @@ Appending a username to the root URL, such as `/alice`, opens that user's public
|
||||
|
||||
Configuration APIs require a bearer token returned by the login endpoint. User configuration uses the identity in that token. Plugin administration additionally requires an administrator account; the development `alice` account is seeded as an administrator, while `bob` is a standard user.
|
||||
|
||||
On the profile page, the authenticated username is displayed as read-only. The profile form prepopulates the default avatar URL when it has not been customized. Bio and email fields remain empty until the user provides values. Mastodon settings default to the `mastodon.social` instance and the `From my #LinkLog: "` post prefix.
|
||||
On the profile page, the authenticated username is displayed as read-only. Users can upload a PNG, JPEG, GIF, or WebP avatar up to 2 MB; uploaded files are stored in the persistent data volume and served by the application. Bio and email fields remain empty until the user provides values. Mastodon settings default to the `mastodon.social` instance and the `From my #LinkLog: "` post prefix.
|
||||
|
||||
## Run Tests
|
||||
|
||||
|
||||
@@ -205,3 +205,33 @@ remove default for bio and email in the profile page
|
||||
|
||||
### Assistant outcome
|
||||
Removed the default bio text from the profile template and browser loader. Email remains API-driven and empty until stored for the user. The avatar, Mastodon instance, and post-prefix defaults remain unchanged. Focused profile validation passed, followed by the strict backend suite with 10 passing tests.
|
||||
|
||||
### User
|
||||
Don't use an avatar URL but allow the user to upload an avatar
|
||||
|
||||
### Assistant outcome
|
||||
Replaced the avatar URL field with a multipart image upload, added persistent avatar storage under the database data directory, mounted `/media` for serving uploaded files, limited uploads to PNG/JPEG/GIF/WebP files up to 2 MB, and added a profile preview. Added `python-multipart` to the backend dependencies and verified upload, persistence, serving, scripts, tests, and Docker image build.
|
||||
|
||||
### User
|
||||
Show the avatar with all posts
|
||||
|
||||
### Assistant outcome
|
||||
Updated feed rendering to display each user's uploaded avatar beside every post, with an initial fallback when no avatar exists or the image fails to load. Filtered profile headers now render uploaded avatars as well. Added avatar data coverage and verified the strict backend suite with 10 passing tests.
|
||||
|
||||
### User
|
||||
Show the avatar with each entry on the home page. On the specific /<user>/ page don't show the avatar and user name with each entry
|
||||
|
||||
### Assistant outcome
|
||||
Updated feed rendering so home-page entries include the avatar and username, while entries on `/<user>/` omit the repeated identity block. The filtered page still displays the user avatar and name once in its profile summary. Added cache-busting for the updated feed script and verified the strict backend suite with 10 passing tests.
|
||||
|
||||
### User
|
||||
For the user filter create a dropdown of all users that are available on the server
|
||||
|
||||
### Assistant outcome
|
||||
Replaced the free-text user filter with a dropdown populated from `GET /api/public/users`, including all registered users ordered by username. Preserved cookie-based selection and fallback to all users when a saved choice is unavailable. Bumped the feed asset version, rebuilt the Docker service, verified the live dropdown, and passed the strict backend suite with 10 tests.
|
||||
|
||||
### User
|
||||
If I select a user filter I want the appropriate page to be opened (e.g. /user/ for user of / -home- for all-users)
|
||||
|
||||
### Assistant outcome
|
||||
Changed the user dropdown to navigate to `/<username>/` for a selected user and `/` for all users, instead of filtering only in the current page. Added direct support for the trailing-slash user route, bumped the feed asset version to `v4`, rebuilt Docker, and verified the live routes and strict backend suite with 10 passing tests.
|
||||
|
||||
@@ -37,6 +37,11 @@
|
||||
33. Check the whole authentication flow as it seems broken.
|
||||
34. In the admin interface allow to toggle administrative rights for users. But always enforce there to be at least one user with admin rights
|
||||
35. remove default for bio and email in the profile page
|
||||
36. Don't use an avatar URL but allow the user to upload an avatar
|
||||
37. Show the avatar with all posts
|
||||
38. Show the avatar with each entry on the home page. On the specific /<user>/ page don't show the avatar and user name with each entry
|
||||
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)
|
||||
|
||||
## Future entries
|
||||
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from backend.app.services.link_service import list_public_links
|
||||
from backend.app.services.link_service import list_public_links, list_public_users
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get('/users')
|
||||
def public_users():
|
||||
return list_public_users()
|
||||
|
||||
|
||||
@router.get('/feed')
|
||||
@router.get('/feed/{username}')
|
||||
def public_feed(username: str | None = None):
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import json
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
|
||||
from pydantic import BaseModel
|
||||
|
||||
from backend.app.api.dependencies import get_current_user
|
||||
from backend.app.database import get_connection
|
||||
from backend.app.database import AVATARS_DIR, get_connection
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -13,7 +13,6 @@ router = APIRouter()
|
||||
class UserConfigUpdate(BaseModel):
|
||||
email: str | None = None
|
||||
bio: str | None = None
|
||||
avatar_url: str | None = None
|
||||
|
||||
|
||||
class UserPluginConfigUpdate(BaseModel):
|
||||
@@ -47,21 +46,55 @@ def update_current_user_profile(
|
||||
|
||||
email = payload.email or current['email']
|
||||
bio = payload.bio if payload.bio is not None else current['bio']
|
||||
avatar_url = payload.avatar_url if payload.avatar_url is not None else current['avatar_url']
|
||||
|
||||
conn.execute(
|
||||
'''
|
||||
UPDATE users
|
||||
SET email = ?, bio = ?, avatar_url = ?, updated_at = CURRENT_TIMESTAMP
|
||||
SET email = ?, bio = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
''',
|
||||
(email, bio, avatar_url, user['id']),
|
||||
(email, bio, user['id']),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
return {'status': 'updated'}
|
||||
|
||||
|
||||
@router.post('/avatar')
|
||||
async def upload_avatar(
|
||||
avatar: UploadFile = File(...),
|
||||
user: dict = Depends(get_current_user),
|
||||
):
|
||||
allowed_types = {
|
||||
'image/gif': '.gif',
|
||||
'image/jpeg': '.jpg',
|
||||
'image/png': '.png',
|
||||
'image/webp': '.webp',
|
||||
}
|
||||
suffix = allowed_types.get(avatar.content_type or '')
|
||||
if suffix is None:
|
||||
raise HTTPException(status_code=415, detail='Avatar must be a PNG, JPEG, GIF, or WebP image')
|
||||
|
||||
contents = await avatar.read(2 * 1024 * 1024 + 1)
|
||||
if len(contents) > 2 * 1024 * 1024:
|
||||
raise HTTPException(status_code=413, detail='Avatar must be 2 MB or smaller')
|
||||
|
||||
avatar_path = AVATARS_DIR / f'{user["id"]}{suffix}'
|
||||
for existing_path in AVATARS_DIR.glob(f'{user["id"]}.*'):
|
||||
if existing_path != avatar_path:
|
||||
existing_path.unlink(missing_ok=True)
|
||||
avatar_path.write_bytes(contents)
|
||||
avatar_url = f'/media/{avatar_path.name}'
|
||||
|
||||
with get_connection() as conn:
|
||||
conn.execute(
|
||||
'UPDATE users SET avatar_url = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
|
||||
(avatar_url, user['id']),
|
||||
)
|
||||
conn.commit()
|
||||
return {'avatar_url': avatar_url}
|
||||
|
||||
|
||||
@router.get('/plugins/{plugin_name}')
|
||||
def get_user_plugin_config(plugin_name: str, user: dict = Depends(get_current_user)):
|
||||
with get_connection() as conn:
|
||||
|
||||
@@ -6,6 +6,8 @@ from pathlib import Path
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
DB_PATH = Path(os.getenv('LINKLOG_DATABASE_PATH', BASE_DIR / 'data' / 'linklog.db'))
|
||||
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
AVATARS_DIR = DB_PATH.parent / 'avatars'
|
||||
AVATARS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
|
||||
@@ -9,10 +9,12 @@ from backend.app.api.auth import router as auth_router
|
||||
from backend.app.api.links import router as links_router
|
||||
from backend.app.api.public import router as public_router
|
||||
from backend.app.api.user_config import router as user_config_router
|
||||
from backend.app.database import AVATARS_DIR
|
||||
from backend.app.services.link_service import list_public_links
|
||||
|
||||
app = FastAPI(title='LinkLog API')
|
||||
app.mount('/static', StaticFiles(directory='frontend/static'), name='static')
|
||||
app.mount('/media', StaticFiles(directory=AVATARS_DIR), name='media')
|
||||
app.include_router(auth_router, prefix='/api/auth')
|
||||
app.include_router(links_router, prefix='/api')
|
||||
app.include_router(public_router, prefix='/api/public')
|
||||
@@ -49,6 +51,7 @@ def health_check():
|
||||
|
||||
|
||||
@app.get('/{username}', response_class=HTMLResponse)
|
||||
@app.get('/{username}/', response_class=HTMLResponse)
|
||||
async def public_user_feed(request: Request, username: str):
|
||||
feed = list_public_links(username)
|
||||
profile = None
|
||||
|
||||
@@ -55,3 +55,11 @@ def list_public_links(username: str | None = None):
|
||||
(username, username),
|
||||
).fetchall()
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
|
||||
def list_public_users():
|
||||
with get_connection() as conn:
|
||||
rows = conn.execute(
|
||||
'SELECT username FROM users ORDER BY username'
|
||||
).fetchall()
|
||||
return [row['username'] for row in rows]
|
||||
|
||||
@@ -2,6 +2,7 @@ fastapi==0.141.1
|
||||
uvicorn==0.52.4
|
||||
pydantic==2.13.4
|
||||
jinja2==3.1.6
|
||||
python-multipart==0.0.20
|
||||
pytest==9.1.1
|
||||
httpx==0.28.1
|
||||
httpx2==2.12.0
|
||||
|
||||
@@ -100,11 +100,15 @@ def test_submit_link_stores_cleaned_url_and_public_feed():
|
||||
matching = next(item for item in feed if item['id'] == data['id'])
|
||||
assert matching['comment'] == 'Interesting read'
|
||||
assert matching['user']['username'] == 'alice'
|
||||
assert 'avatar_url' in matching['user']
|
||||
|
||||
filtered_response = client.get('/api/public/feed/alice')
|
||||
assert filtered_response.status_code == 200
|
||||
assert all(item['user']['username'] == 'alice' for item in filtered_response.json())
|
||||
assert client.get('/api/public/feed/does-not-exist').json() == []
|
||||
users_response = client.get('/api/public/users')
|
||||
assert users_response.status_code == 200
|
||||
assert 'alice' in users_response.json()
|
||||
|
||||
|
||||
def test_logout_revokes_token_and_admin_can_list_plugins():
|
||||
@@ -129,7 +133,13 @@ def test_public_and_admin_pages_render_html():
|
||||
assert 'LinkLog' in root_page.text
|
||||
assert 'class="login-button" href="/login"' in root_page.text
|
||||
assert client.get('/alice').status_code == 200
|
||||
assert 'alice' in client.get('/alice').text
|
||||
assert client.get('/alice/').status_code == 200
|
||||
user_page = client.get('/alice').text
|
||||
assert 'alice' in user_page
|
||||
assert 'data-user-filter="alice"' in user_page
|
||||
assert 'profile-summary' in user_page
|
||||
feed_script = TestClient(app).get('/static/feed.js?v=4').text
|
||||
assert 'window.location.assign(selectedUser ? `/${encodeURIComponent(selectedUser)}/` : \'/\')' in feed_script
|
||||
assert client.get('/login').status_code == 200
|
||||
assert 'Sign in' in client.get('/login').text
|
||||
assert client.get('/admin').status_code == 200
|
||||
|
||||
@@ -19,11 +19,11 @@ def test_user_config_api_and_profile_page():
|
||||
assert payload['username'] == 'alice'
|
||||
|
||||
update_response = client.put('/api/user/me', json={
|
||||
'username': 'should-not-change',
|
||||
'email': 'alice@example.com',
|
||||
'bio': 'Updated bio',
|
||||
'avatar_url': 'https://example.com/new-avatar.png'
|
||||
}, headers=headers)
|
||||
assert update_response.status_code == 200
|
||||
assert client.get('/api/user/me', headers=headers).json()['username'] == 'alice'
|
||||
|
||||
page_response = client.get('/profile')
|
||||
assert page_response.status_code == 200
|
||||
@@ -31,9 +31,23 @@ def test_user_config_api_and_profile_page():
|
||||
assert '<output id="username" class="readonly-value">Loading...</output>' in page_response.text
|
||||
assert 'name="username"' not in page_response.text
|
||||
assert '<textarea id="bio" name="bio" rows="4"></textarea>' in page_response.text
|
||||
assert 'https://example.com/avatar.png' in page_response.text
|
||||
assert 'name="avatar" type="file"' in page_response.text
|
||||
assert 'name="avatar_url"' not in page_response.text
|
||||
assert 'value="mastodon.social"' in page_response.text
|
||||
assert 'From my #LinkLog: "' in page_response.text
|
||||
assert 'id="admin-link"' in page_response.text
|
||||
assert 'class="login-button hidden"' in page_response.text
|
||||
assert 'id="logout-button" class="logout-button hidden"' in page_response.text
|
||||
|
||||
upload_response = client.post(
|
||||
'/api/user/avatar',
|
||||
headers=headers,
|
||||
files={'avatar': ('avatar.png', b'fake-png-data', 'image/png')},
|
||||
)
|
||||
assert upload_response.status_code == 200
|
||||
avatar_url = upload_response.json()['avatar_url']
|
||||
assert avatar_url.startswith('/media/user-1.png')
|
||||
assert client.get(avatar_url).content == b'fake-png-data'
|
||||
|
||||
updated_profile = client.get('/api/user/me', headers=headers).json()
|
||||
assert updated_profile['avatar_url'] == avatar_url
|
||||
|
||||
+46
-20
@@ -4,6 +4,26 @@ const userFilter = document.getElementById('user-filter');
|
||||
|
||||
const cookieName = 'linklog-feed-preferences';
|
||||
|
||||
function createAvatar(user) {
|
||||
const avatar = document.createElement('div');
|
||||
avatar.className = 'avatar';
|
||||
const initial = (user?.username || 'U').slice(0, 1).toUpperCase();
|
||||
avatar.textContent = initial;
|
||||
|
||||
if (user?.avatar_url) {
|
||||
const image = document.createElement('img');
|
||||
image.src = user.avatar_url;
|
||||
image.alt = `${user.username || 'User'} avatar`;
|
||||
image.addEventListener('error', () => {
|
||||
image.remove();
|
||||
avatar.textContent = initial;
|
||||
});
|
||||
avatar.textContent = '';
|
||||
avatar.appendChild(image);
|
||||
}
|
||||
return avatar;
|
||||
}
|
||||
|
||||
function readPreferences() {
|
||||
const cookie = document.cookie
|
||||
.split('; ')
|
||||
@@ -25,7 +45,16 @@ function writePreferences(pref) {
|
||||
document.cookie = `${cookieName}=${value}; path=/; max-age=31536000`;
|
||||
}
|
||||
|
||||
function renderFeed(items) {
|
||||
async function loadUsers() {
|
||||
const response = await fetch('/api/public/users');
|
||||
if (!response.ok) throw new Error('Could not load users');
|
||||
const users = await response.json();
|
||||
const selectedUser = readPreferences().user;
|
||||
userFilter.replaceChildren(new Option('All users', ''), ...users.map((username) => new Option(username, username)));
|
||||
userFilter.value = users.includes(selectedUser) ? selectedUser : '';
|
||||
}
|
||||
|
||||
function renderFeed(items, showIdentity = true) {
|
||||
feedEl.innerHTML = '';
|
||||
|
||||
if (!items.length) {
|
||||
@@ -37,20 +66,6 @@ function renderFeed(items) {
|
||||
const article = document.createElement('article');
|
||||
article.className = 'link-item';
|
||||
|
||||
const header = document.createElement('div');
|
||||
header.className = 'link-header';
|
||||
|
||||
const avatar = document.createElement('div');
|
||||
avatar.className = 'avatar';
|
||||
avatar.textContent = (item.user?.username || 'U').slice(0, 1).toUpperCase();
|
||||
|
||||
const userName = document.createElement('div');
|
||||
userName.className = 'user-name';
|
||||
userName.textContent = item.user?.username || 'unknown';
|
||||
|
||||
header.appendChild(avatar);
|
||||
header.appendChild(userName);
|
||||
|
||||
const title = document.createElement('h2');
|
||||
const link = document.createElement('a');
|
||||
link.href = item.url;
|
||||
@@ -67,7 +82,17 @@ function renderFeed(items) {
|
||||
meta.className = 'meta';
|
||||
meta.textContent = item.created_at || 'updated recently';
|
||||
|
||||
if (showIdentity) {
|
||||
const header = document.createElement('div');
|
||||
header.className = 'link-header';
|
||||
header.appendChild(createAvatar(item.user));
|
||||
|
||||
const userName = document.createElement('div');
|
||||
userName.className = 'user-name';
|
||||
userName.textContent = item.user?.username || 'unknown';
|
||||
header.appendChild(userName);
|
||||
article.appendChild(header);
|
||||
}
|
||||
article.appendChild(title);
|
||||
article.appendChild(comment);
|
||||
article.appendChild(meta);
|
||||
@@ -94,7 +119,7 @@ async function loadFeed() {
|
||||
items = [...items].reverse();
|
||||
}
|
||||
|
||||
renderFeed(items);
|
||||
renderFeed(items, !routeUser);
|
||||
}
|
||||
|
||||
function syncPreferences() {
|
||||
@@ -108,12 +133,13 @@ function syncPreferences() {
|
||||
loadFeed();
|
||||
});
|
||||
|
||||
userFilter.addEventListener('input', (event) => {
|
||||
const next = { ...readPreferences(), user: event.target.value.trim() };
|
||||
userFilter.addEventListener('change', (event) => {
|
||||
const selectedUser = event.target.value.trim();
|
||||
const next = { ...readPreferences(), user: selectedUser };
|
||||
writePreferences(next);
|
||||
loadFeed();
|
||||
window.location.assign(selectedUser ? `/${encodeURIComponent(selectedUser)}/` : '/');
|
||||
});
|
||||
}
|
||||
|
||||
syncPreferences();
|
||||
loadFeed();
|
||||
loadUsers().then(loadFeed).catch(() => loadFeed());
|
||||
|
||||
@@ -3,7 +3,6 @@ const profileForm = document.querySelector('#profile-form');
|
||||
const mastodonForm = document.querySelector('#mastodon-form');
|
||||
const profileLogoutButton = document.querySelector('#logout-button');
|
||||
const accessToken = localStorage.getItem('linklogAccessToken');
|
||||
const defaultAvatarUrl = 'https://example.com/avatar.png';
|
||||
const defaultMastodonInstance = 'mastodon.social';
|
||||
const defaultPostPrefix = 'From my #LinkLog: "';
|
||||
|
||||
@@ -27,7 +26,11 @@ async function loadProfile() {
|
||||
document.querySelector('#username').textContent = profile.username || '';
|
||||
document.querySelector('#email').value = profile.email || '';
|
||||
document.querySelector('#bio').value = profile.bio || '';
|
||||
document.querySelector('#avatar-url').value = profile.avatar_url || defaultAvatarUrl;
|
||||
const avatarPreview = document.querySelector('#avatar-preview');
|
||||
if (profile.avatar_url) {
|
||||
avatarPreview.src = profile.avatar_url;
|
||||
avatarPreview.classList.remove('hidden');
|
||||
}
|
||||
if (profile.is_admin) {
|
||||
document.querySelector('#admin-link').classList.remove('hidden');
|
||||
}
|
||||
@@ -46,6 +49,7 @@ async function loadMastodonConfig() {
|
||||
profileForm.addEventListener('submit', async (event) => {
|
||||
event.preventDefault();
|
||||
const formData = new FormData(profileForm);
|
||||
formData.delete('avatar');
|
||||
const response = await fetch('/api/user/me', {
|
||||
method: 'PUT',
|
||||
headers: authHeaders(true),
|
||||
@@ -54,6 +58,27 @@ profileForm.addEventListener('submit', async (event) => {
|
||||
setStatus('#profile-status', response.ok ? 'Profile saved.' : 'Could not save profile.', !response.ok);
|
||||
});
|
||||
|
||||
document.querySelector('#avatar-file').addEventListener('change', async (event) => {
|
||||
const file = event.target.files[0];
|
||||
if (!file) return;
|
||||
const formData = new FormData();
|
||||
formData.append('avatar', file);
|
||||
const response = await fetch('/api/user/avatar', {
|
||||
method: 'POST',
|
||||
headers: authHeaders(),
|
||||
body: formData,
|
||||
});
|
||||
if (!response.ok) {
|
||||
setStatus('#profile-status', 'Could not upload avatar.', true);
|
||||
return;
|
||||
}
|
||||
const data = await response.json();
|
||||
const avatarPreview = document.querySelector('#avatar-preview');
|
||||
avatarPreview.src = data.avatar_url;
|
||||
avatarPreview.classList.remove('hidden');
|
||||
setStatus('#profile-status', 'Avatar uploaded.');
|
||||
});
|
||||
|
||||
mastodonForm.addEventListener('submit', async (event) => {
|
||||
event.preventDefault();
|
||||
const formData = new FormData(mastodonForm);
|
||||
|
||||
@@ -229,6 +229,14 @@ button:focus-visible {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.profile-avatar-preview {
|
||||
width: 96px;
|
||||
height: 96px;
|
||||
object-fit: cover;
|
||||
border: 2px solid var(--mauve);
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
button {
|
||||
width: fit-content;
|
||||
border-color: var(--mauve);
|
||||
@@ -363,6 +371,13 @@ button:disabled {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.avatar img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
border-radius: inherit;
|
||||
}
|
||||
|
||||
.user-name {
|
||||
color: var(--lavender);
|
||||
font-weight: 700;
|
||||
|
||||
@@ -23,7 +23,13 @@
|
||||
{% if profile %}
|
||||
<section class="link-item profile-summary">
|
||||
<div class="link-header">
|
||||
<div class="avatar">{{ profile.username[:1].upper() }}</div>
|
||||
<div class="avatar">
|
||||
{% if profile.avatar_url %}
|
||||
<img src="{{ profile.avatar_url }}" alt="{{ profile.username }} avatar" />
|
||||
{% else %}
|
||||
{{ profile.username[:1].upper() }}
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="user-name">{{ profile.username }}</div>
|
||||
</div>
|
||||
<p>{{ profile.bio or 'No profile information provided.' }}</p>
|
||||
@@ -39,13 +45,15 @@
|
||||
</label>
|
||||
<label>
|
||||
User filter
|
||||
<input id="user-filter" type="text" placeholder="alice" />
|
||||
<select id="user-filter">
|
||||
<option value="">All users</option>
|
||||
</select>
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<section id="feed" class="feed" aria-live="polite"></section>
|
||||
</main>
|
||||
|
||||
<script src="/static/feed.js"></script>
|
||||
<script src="/static/feed.js?v=4"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -37,9 +37,10 @@
|
||||
<textarea id="bio" name="bio" rows="4"></textarea>
|
||||
</label>
|
||||
<label>
|
||||
Avatar URL
|
||||
<input id="avatar-url" name="avatar_url" type="url" value="https://example.com/avatar.png" />
|
||||
Avatar image
|
||||
<input id="avatar-file" name="avatar" type="file" accept="image/png,image/jpeg,image/gif,image/webp" />
|
||||
</label>
|
||||
<img id="avatar-preview" class="profile-avatar-preview hidden" alt="Avatar preview" />
|
||||
<button type="submit">Save profile</button>
|
||||
<p id="profile-status" class="status" role="status"></p>
|
||||
</form>
|
||||
|
||||
Reference in New Issue
Block a user