Compare commits

...
34 Commits
Author SHA1 Message Date
retoor 49853e079b Update. 2026-05-23 08:31:16 +02:00
retoor 2afb2038e5 iUpdate 2026-05-23 08:27:04 +02:00
retoor 18c9f20090 Done
DevPlace CI / test (push) Failing after 3m27s
2026-05-23 06:55:11 +02:00
retoor 28bc8c1b74 Update
DevPlace CI / test (push) Failing after 1m6s
2026-05-23 06:21:41 +02:00
retoor 8778d60c21 Update
DevPlace CI / test (push) Has been cancelled
2026-05-23 06:20:58 +02:00
retoor 9c88d2120c Updat
DevPlace CI / test (push) Successful in 4m58s
2026-05-23 05:57:21 +02:00
retoor 065944a5b1 Update
DevPlace CI / test (push) Has been cancelled
2026-05-23 05:56:21 +02:00
retoor c47ab9e635 Update 2026-05-23 05:55:50 +02:00
retoor cb49f0cee1 Update
DevPlace CI / test (push) Successful in 5m51s
2026-05-23 05:44:04 +02:00
retoor 4c30a32eb2 Update
DevPlace CI / test (push) Successful in 4m56s
2026-05-23 04:29:26 +02:00
retoor 5b99ea8e17 Update
DevPlace CI / test (push) Successful in 5m8s
2026-05-23 04:12:41 +02:00
retoor b23f655389 Update
DevPlace CI / test (push) Failing after 2m53s
2026-05-23 03:21:55 +02:00
retoor 22e066a202 Update
DevPlace CI / test (push) Failing after 3m44s
2026-05-23 01:50:31 +02:00
retoor e7f139437e Update
DevPlace CI / test (push) Failing after 3m33s
2026-05-19 23:36:17 +02:00
retoor 335366d064 Show error
DevPlace CI / test (push) Failing after 3m24s
2026-05-19 23:27:44 +02:00
retoor 78c21cc507 Spacing
DevPlace CI / test (push) Failing after 2m6s
2026-05-16 03:29:43 +02:00
retoor f1fb8b8c16 Repaired gists. 2026-05-16 03:10:55 +02:00
retoor f65a4aed9a Repaired chat 2026-05-16 03:02:10 +02:00
retoor c6ad62063a Click on profile page and downvote button 2026-05-16 02:58:43 +02:00
retoor 06f884b827 Notification fix. 2026-05-16 02:49:53 +02:00
retoor d74c87a733 Mention fix 2026-05-16 02:33:14 +02:00
retoor 3532497f3c Downvote button and click on post 2026-05-16 02:31:11 +02:00
retoor d08038fc6c Burgr
DevPlace CI / test (push) Failing after 2m8s
2026-05-16 01:56:07 +02:00
retoor 7b79097328 REsponsive.
DevPlace CI / test (push) Failing after 2m11s
2026-05-16 01:35:22 +02:00
retoor 58c1a37d34 Production setup.
DevPlace CI / test (push) Failing after 2m22s
2026-05-16 01:28:39 +02:00
retoor 87f6ec63ac No concurrent
DevPlace CI / test (push) Failing after 2m8s
2026-05-14 04:36:38 +02:00
retoor 94f6001c85 Updated..
DevPlace CI / test (push) Failing after 2m47s
2026-05-14 04:25:52 +02:00
retoor 2691684c08 Fix
DevPlace CI / test (push) Failing after 2m44s
2026-05-14 04:12:19 +02:00
retoor d2bafabbad Update
DevPlace CI / test (push) Failing after 1m46s
2026-05-13 23:26:18 +02:00
retoor cc4374f9ba Updated
DevPlace CI / test (push) Failing after 1m45s
2026-05-13 23:15:14 +02:00
retoor 4c8d8663de Update
DevPlace CI / test (push) Failing after 1m51s
2026-05-13 23:03:06 +02:00
retoor 1423c688cf Master
DevPlace CI / test (push) Failing after 1m34s
2026-05-13 22:53:42 +02:00
retoor f44cea570a Progress 2026-05-13 22:48:39 +02:00
retoor 8e51eed0b6 Working in exception of upload 2026-05-13 21:17:57 +02:00
151 changed files with 11328 additions and 1955 deletions
+5 -2
View File
@@ -18,8 +18,7 @@ jobs:
- name: Install dependencies - name: Install dependencies
run: | run: |
pip install -e . pip install -e ".[dev]"
pip install playwright
python -m playwright install chromium --with-deps python -m playwright install chromium --with-deps
- name: Run integration tests - name: Run integration tests
@@ -32,3 +31,7 @@ jobs:
with: with:
name: failure-screenshots name: failure-screenshots
path: /tmp/devplace_test_screenshots/ path: /tmp/devplace_test_screenshots/
- name: Deploy to production
if: success() && github.event_name == 'push' && github.ref == 'refs/heads/master'
run: make deploy
+6
View File
@@ -5,3 +5,9 @@ __pycache__/
devplace.db* devplace.db*
.pytest_cache/ .pytest_cache/
.opencode .opencode
devplacepy/static/uploads/attachments/
devplacepy/static/uploads/*.png
devplacepy/static/uploads/*.jpg
devplacepy/static/uploads/*.jpeg
devplacepy/static/uploads/*.gif
devplacepy/static/uploads/*.webp
+66 -70
View File
@@ -1,4 +1,4 @@
# DevPlace Agent Guide # DevPlace - Agent Guide
## Quick start ## Quick start
@@ -21,7 +21,7 @@ make locust-headless # Locust in headless CLI mode (for CI)
- **Database:** `dataset` (auto-syncs schema, uses `uid` for PKs). SQLite. - **Database:** `dataset` (auto-syncs schema, uses `uid` for PKs). SQLite.
- **Auth:** Session cookies (`session` cookie), SHA256+SALT via passlib. No JWT. - **Auth:** Session cookies (`session` cookie), SHA256+SALT via passlib. No JWT.
- **Static:** `devplacepy/static/` mounted at `/static` - **Static:** `devplacepy/static/` mounted at `/static`
- **Templates:** `devplacepy/templates/`. Shared `templates` instance from `devplacepy.templating` all routers import from there, do NOT create their own. - **Templates:** `devplacepy/templates/`. Shared `templates` instance from `devplacepy.templating` - all routers import from there, do NOT create their own.
- **Ports:** 10500 (dev), 10501 (tests) - **Ports:** 10500 (dev), 10501 (tests)
- **Username:** letters, numbers, hyphens, underscores only. 3-32 chars. - **Username:** letters, numbers, hyphens, underscores only. 3-32 chars.
- **Password:** minimum 6 chars. - **Password:** minimum 6 chars.
@@ -60,7 +60,7 @@ make locust-headless # Locust in headless CLI mode (for CI)
5. **YouTube URLs**`youtube.com/watch?v=` or `youtu.be/` become embedded iframe players 5. **YouTube URLs**`youtube.com/watch?v=` or `youtu.be/` become embedded iframe players
6. **All URLs** → become `<a>` links with `target="_blank"` and `rel="noopener"` 6. **All URLs** → become `<a>` links with `target="_blank"` and `rel="noopener"`
**Code blocks are protected** `NodeIterator` skips `CODE`, `PRE`, `SCRIPT`, `STYLE` elements during URL/media processing, so source code in markdown code blocks is never touched. **Code blocks are protected** - `NodeIterator` skips `CODE`, `PRE`, `SCRIPT`, `STYLE` elements during URL/media processing, so source code in markdown code blocks is never touched.
Elements with `data-render` attribute are auto-rendered by `Application.js` on page load. The `.rendered-content` CSS class provides table styles, code block backgrounds, and image sizing. Elements with `data-render` attribute are auto-rendered by `Application.js` on page load. The `.rendered-content` CSS class provides table styles, code block backgrounds, and image sizing.
@@ -77,7 +77,7 @@ Loaded via `<script>` tags in `base.html`. ALL must use `defer` to avoid blockin
<script type="module" src="/static/js/Application.js"></script> <script type="module" src="/static/js/Application.js"></script>
``` ```
**Never use `<script>` without `defer` for CDN libraries** they block HTML parsing and cause `wait_until="domcontentloaded"` to timeout in Playwright tests. **Never use `<script>` without `defer` for CDN libraries** - they block HTML parsing and cause `wait_until="domcontentloaded"` to timeout in Playwright tests.
## Emoji Picker ## Emoji Picker
@@ -91,7 +91,7 @@ Uses `emoji-picker-element` web component (Discord-style, searchable, skin tones
`Application.js` `initModals()` toggles the `.visible` CSS class on the modal overlay. The CSS rule `.modal-overlay.visible { display: flex; }` handles visibility: `Application.js` `initModals()` toggles the `.visible` CSS class on the modal overlay. The CSS rule `.modal-overlay.visible { display: flex; }` handles visibility:
```javascript ```javascript
// CORRECT toggle the .visible class on the modal: // CORRECT - toggle the .visible class on the modal:
modal.classList.add("visible"); // show modal.classList.add("visible"); // show
modal.classList.remove("visible"); // hide modal.classList.remove("visible"); // hide
``` ```
@@ -105,7 +105,7 @@ trigger.addEventListener("click", (e) => {
}); });
``` ```
The `modal-close` class is handled by `Application.js` no inline JS needed in templates for basic modals. The `modal-close` class is handled by `Application.js` - no inline JS needed in templates for basic modals.
## Database ## Database
@@ -121,27 +121,27 @@ PRAGMA temp_store=MEMORY; -- temp tables in memory
Configured via `dataset.connect(engine_kwargs={"connect_args": {"timeout": 30, "check_same_thread": False}}, on_connect_statements=[...])`. Configured via `dataset.connect(engine_kwargs={"connect_args": {"timeout": 30, "check_same_thread": False}}, on_connect_statements=[...])`.
All indexes are created via `_index()` helper wrapped in try/except safe to run on every startup regardless of table state. All indexes are created via `_index()` helper wrapped in try/except - safe to run on every startup regardless of table state.
## Dataset rules (hard-learned) ## Dataset rules (hard-learned)
**`find()` does NOT accept raw SQL strings.** It takes keyword arguments for equality filters, dict comparison operators, or SQLAlchemy column expressions. **`find()` does NOT accept raw SQL strings.** It takes keyword arguments for equality filters, dict comparison operators, or SQLAlchemy column expressions.
```python ```python
# WRONG causes 500 Internal Server Error: # WRONG - causes 500 Internal Server Error:
table.find("created_at >= :start", {"start": today}) table.find("created_at >= :start", {"start": today})
table.find(text("created_at >= :start"), start=today) table.find(text("created_at >= :start"), start=today)
# CORRECT dict comparison syntax: # CORRECT - dict comparison syntax:
table.find(created_at={">=": today}) table.find(created_at={">=": today})
# CORRECT keyword equality: # CORRECT - keyword equality:
table.find(country="France") table.find(country="France")
# CORRECT SQLAlchemy column expression for IN clause: # CORRECT - SQLAlchemy column expression for IN clause:
table.find(table.table.columns.user_uid.in_(["uid1", "uid2"])) table.find(table.table.columns.user_uid.in_(["uid1", "uid2"]))
# CORRECT multiple equality filters combined: # CORRECT - multiple equality filters combined:
table.find(topic="devlog", user_uid=some_uid) table.find(topic="devlog", user_uid=some_uid)
``` ```
@@ -169,25 +169,25 @@ if "comments" not in db.tables:
## FastAPI patterns ## FastAPI patterns
- **All routes are async.** Use `await request.form()` to read form data. - **All routes are async.** Form data is validated via a typed Pydantic body param: `data: Annotated[SomeForm, Form()]` (models in `models.py`). Read raw `await request.form()` only when also handling an uploaded file (a separate `File()` param would embed the model under its parameter name).
- **Return `RedirectResponse(url=..., status_code=302)`** for redirects. - **Return `RedirectResponse(url=..., status_code=302)`** for redirects.
- **Return `templates.TemplateResponse("name.html", {...})`** from `devplacepy.templating` to render. - **Return `templates.TemplateResponse("name.html", {...})`** from `devplacepy.templating` to render.
- **Never create your own `Jinja2Templates` instance.** Import the shared one: `from devplacepy.templating import templates`. - **Never create your own `Jinja2Templates` instance.** Import the shared one: `from devplacepy.templating import templates`.
- **Register new routers in `main.py`:** `app.include_router(router_instance, prefix="/{path}")` - **Register new routers in `main.py`:** `app.include_router(router_instance, prefix="/{path}")`
- **`require_user(request)` raises 303 redirect to `/`** if not authenticated. Only post/comment/vote/etc. routes use this the feed is public. - **`require_user(request)` raises 303 redirect to `/`** if not authenticated. Only post/comment/vote/etc. routes use this - the feed is public.
- **`get_current_user(request)` is cached** in `_user_cache` dict by session token (per-process, no TTL). Use this for pages viewable by both auth guests (feed, news detail, projects). - **`get_current_user(request)` is cached** in `_user_cache` dict by session token (per-process, no TTL). Use this for pages viewable by both auth guests (feed, news detail, projects).
- **Post deletion must cascade:** delete comments and votes first, then the post. Always check ownership: `post["user_uid"] == user["uid"]`. - **Post deletion must cascade:** delete comments and votes first, then the post. Always check ownership: `post["user_uid"] == user["uid"]`.
- **Message deduplication needed** when `sender_uid == receiver_uid` (messaging yourself): `seen = set()` of message UIDs before appending to result list. - **Message deduplication needed** when `sender_uid == receiver_uid` (messaging yourself): `seen = set()` of message UIDs before appending to result list.
## Key conventions ## Key conventions
- No comments/docstrings in source code is self-documenting. - No comments/docstrings in source - code is self-documenting.
- Forbidden variable name patterns: `_new`, `_old`, `_temp`, `_v2`, `better_`, `my_`, `the_` (see CLAUDE.md for full list). - Forbidden variable name patterns: `_new`, `_old`, `_temp`, `_v2`, `better_`, `my_`, `the_` (see CLAUDE.md for full list).
- Pydantic models exist in `models.py` but routers use `await request.form()` directly for validation. - Form validation uses Pydantic models in `models.py` via `Annotated[Model, Form()]` params; invalid input is caught by the global `RequestValidationError` handler in `main.py` (auth pages re-render with messages at 400, other routes redirect).
- Template globals: `get_unread_count(user_uid)`, `get_user_projects(user_uid)`, `avatar_url(style, seed, size)`, `format_date(dt_str, include_time=False)` (ISO → `DD/MM/YYYY` or `DD/MM/YYYY HH:MM`). - Template globals: `get_unread_count(user_uid)`, `get_user_projects(user_uid)`, `avatar_url(style, seed, size)`, `format_date(dt_str, include_time=False)` (ISO → `DD/MM/YYYY` or `DD/MM/YYYY HH:MM`).
- `DEVPLACE_DATABASE_URL` env var overrides the SQLite path (used by tests). - `DEVPLACE_DATABASE_URL` env var overrides the SQLite path (used by tests).
- All `RedirectResponse` must use `status_code=302` (integer, not `status` module). - All `RedirectResponse` must use `status_code=302` (integer, not `status` module).
- All `dataset` operations are synchronous and run in the async event loop keep them fast. No external HTTP calls in request handlers. - All `dataset` operations are synchronous and run in the async event loop - keep them fast. No external HTTP calls in request handlers.
- For ownership-sensitive operations (delete, edit), always check `user["uid"]` against the resource's `user_uid`. - For ownership-sensitive operations (delete, edit), always check `user["uid"]` against the resource's `user_uid`.
## Clickable Avatars & Usernames ## Clickable Avatars & Usernames
@@ -223,14 +223,14 @@ File validation: max 5MB, allowed extensions: `.png`, `.jpg`, `.jpeg`, `.gif`, `
- **148 tests across 14 files.** Playwright integration + unit tests. All must pass before any merge. - **148 tests across 14 files.** Playwright integration + unit tests. All must pass before any merge.
- **Tests use `-x` (fail-fast).** The suite stops at the first failure. Fix that test, then re-run. - **Tests use `-x` (fail-fast).** The suite stops at the first failure. Fix that test, then re-run.
- **Never run the full test suite unless specifically asked by user.** If something needs to be tested, run only the affected test file. Leave the others alone to speed up the development process. - **NEVER run tests unless specifically asked by user.** Not the full suite, not a single file - do not run any tests unless the user explicitly requests it.
- **`hawk .` validates Python (compile + AST), JS (bracket matching), CSS (brace matching), HTML (tag matching).** Zero tolerance. - **`hawk .` validates Python (compile + AST), JS (bracket matching), CSS (brace matching), HTML (tag matching).** Zero tolerance.
### Playwright navigation ### Playwright navigation
- **Every `page.goto()` must use `wait_until="domcontentloaded"`**, never the default `"load"`. CDN scripts and avatar images cause `load` to timeout. - **Every `page.goto()` must use `wait_until="domcontentloaded"`**, never the default `"load"`. CDN scripts and avatar images cause `load` to timeout.
- **Every `page.wait_for_url()` must also use `wait_until="domcontentloaded"`** for the same reason. - **Every `page.wait_for_url()` must also use `wait_until="domcontentloaded"`** for the same reason.
- **Prefer `page.locator(...).wait_for(state="visible")`** over bare `wait_for_selector` it gives better error messages. - **Prefer `page.locator(...).wait_for(state="visible")`** over bare `wait_for_selector` - it gives better error messages.
- **Default timeout is 15 seconds** (increased from 10s for CDN script loading). - **Default timeout is 15 seconds** (increased from 10s for CDN script loading).
```python ```python
@@ -243,10 +243,10 @@ page.locator(".feed-fab").first.wait_for(state="visible", timeout=10000)
When both a post Delete and comment Delete button exist, always scope to the comment: When both a post Delete and comment Delete button exist, always scope to the comment:
```python ```python
# CORRECT scoped to comment: # CORRECT - scoped to comment:
page.locator(".comment-action-btn:has-text('Delete')") page.locator(".comment-action-btn:has-text('Delete')")
# WRONG matches both post and comment Delete: # WRONG - matches both post and comment Delete:
page.locator("button:has-text('Delete')") page.locator("button:has-text('Delete')")
``` ```
@@ -255,8 +255,8 @@ page.locator("button:has-text('Delete')")
- **`browser_context` is session-scoped** (one per test session, shared by all tests in all files). - **`browser_context` is session-scoped** (one per test session, shared by all tests in all files).
- **Cookies are cleared per test via `browser_context.clear_cookies()`** in the `page` fixture. - **Cookies are cleared per test via `browser_context.clear_cookies()`** in the `page` fixture.
- **Each test gets a fresh `page`** from the shared context. - **Each test gets a fresh `page`** from the shared context.
- **`bob` fixture creates its own context** from the session `browser` necessary for multi-user tests. - **`bob` fixture creates its own context** from the session `browser` - necessary for multi-user tests.
- **Never share a page between two logged-in users** in the same test use separate contexts. - **Never share a page between two logged-in users** in the same test - use separate contexts.
### Test users ### Test users
@@ -285,7 +285,7 @@ page.locator("button:has-text('Delete')")
| Edit modal textarea conflicts with comment textarea | Scope to `.comment-form textarea[name='content']` for comments | | Edit modal textarea conflicts with comment textarea | Scope to `.comment-form textarea[name='content']` for comments |
| Tests fail in sequence | Session-scoped context + `clear_cookies()` per test | | Tests fail in sequence | Session-scoped context + `clear_cookies()` per test |
| Double messages in chat | Deduplicate by message UID with `seen` set | | Double messages in chat | Deduplicate by message UID with `seen` set |
| Avatar generation fails | Falls back to initial-based SVG check multiavatar import | | Avatar generation fails | Falls back to initial-based SVG - check multiavatar import |
## Feature Workflow (for automated agents) ## Feature Workflow (for automated agents)
@@ -318,7 +318,7 @@ Notifications are grouped by time period in `notifications.py` `_group_label()`:
- Within 7 days → **This week** - Within 7 days → **This week**
- Older → **Older** - Older → **Older**
The template uses `notification_groups` (list of `{label, entries}` dicts). Jinja2 note: avoid `.items` as a dict key it clashes with Python's `dict.items()` method. The template uses `notification_groups` (list of `{label, entries}` dicts). Jinja2 note: avoid `.items` as a dict key - it clashes with Python's `dict.items()` method.
### Vote notification messages ### Vote notification messages
@@ -333,7 +333,7 @@ Every post card on the feed now has an inline comment form (`.feed-comment-form`
## Post Editing ## Post Editing
Post owners see an "Edit" button on the post detail page that opens `#edit-post-modal`. The edit form allows changing title, content, and topic. The POST route is `/posts/edit/{post_uid}` with ownership check. The edit modal's textarea has `id="edit-content"` tests must scope to `.comment-form textarea[name='content']` for comment operations. Post owners see an "Edit" button on the post detail page that opens `#edit-post-modal`. The edit form allows changing title, content, and topic. The POST route is `/posts/edit/{post_uid}` with ownership check. The edit modal's textarea has `id="edit-content"` - tests must scope to `.comment-form textarea[name='content']` for comment operations.
## Project Detail Page ## Project Detail Page
@@ -372,8 +372,8 @@ A dedicated `/gists` page for sharing code snippets. Uses `gists` table (auto-cr
### Polymorphic reuse ### Polymorphic reuse
- **Comments**: Uses `_comment_section.html` with `target_type="gist"` same component as posts/projects - **Comments**: Uses `_comment_section.html` with `target_type="gist"` - same component as posts/projects
- **Voting**: Uses existing `/votes/gist/{uid}` route updates `gists.stars` - **Voting**: Uses existing `/votes/gist/{uid}` route - updates `gists.stars`
- **Content rendering**: Description rendered via `ContentRenderer.js` (.rendered-content[data-render]) - **Content rendering**: Description rendered via `ContentRenderer.js` (.rendered-content[data-render])
- **Profile tab**: "Gists" tab between Projects and Activity on profile pages - **Profile tab**: "Gists" tab between Projects and Activity on profile pages
@@ -403,18 +403,18 @@ The `devplacepy/services/` package provides a generic framework for running back
### `BaseService` (`services/base.py`) ### `BaseService` (`services/base.py`)
Abstract class for all services: Abstract class for all services:
- **`name`** unique identifier (used in routing, logs, and DB) - **`name`** - unique identifier (used in routing, logs, and DB)
- **`interval_seconds`** run interval (3600 for news), runs immediately on boot then every interval - **`interval_seconds`** - run interval (3600 for news), runs immediately on boot then every interval
- **`log_buffer`** `deque(maxlen=20)` for log tail (served via `/services` page + auto-refresh) - **`log_buffer`** - `deque(maxlen=20)` for log tail (served via `/services` page + auto-refresh)
- **`log(message)`** writes to both the buffer and standard `logging` - **`log(message)`** - writes to both the buffer and standard `logging`
- **`run_once()`** abstract; override with actual work - **`run_once()`** - abstract; override with actual work
- **`start()`** / **`stop()`** asyncio task lifecycle with graceful cancellation (10s timeout) - **`start()`** / **`stop()`** - asyncio task lifecycle with graceful cancellation (10s timeout)
### `ServiceManager` (`services/manager.py`) ### `ServiceManager` (`services/manager.py`)
Singleton that manages all registered services: Singleton that manages all registered services:
- `register(service)` add a service - `register(service)` - add a service
- `start_all()` start all registered services - `start_all()` - start all registered services
- `stop_all()` cancel all tasks (called on server shutdown) - `stop_all()` - cancel all tasks (called on server shutdown)
- `list_services()``list[dict]` with name, status, uptime, log buffer - `list_services()``list[dict]` with name, status, uptime, log buffer
### `NewsService` (`services/news.py`) ### `NewsService` (`services/news.py`)
@@ -424,8 +424,8 @@ Implements `BaseService`:
- ALL articles are inserted into `news` table regardless of grade (never silently skipped) - ALL articles are inserted into `news` table regardless of grade (never silently skipped)
- Each article gets a `status` field: `"published"` if grade >= threshold, `"draft"` otherwise - Each article gets a `status` field: `"published"` if grade >= threshold, `"draft"` otherwise
- Threshold configurable in admin settings - Threshold configurable in admin settings
- Articles re-synced each run (upsert by `external_id`) grade, status, images updated on every cycle - Articles re-synced each run (upsert by `external_id`) - grade, status, images updated on every cycle
- Slugs generated via `make_combined_slug(title, uid)` same format as posts/projects - Slugs generated via `make_combined_slug(title, uid)` - same format as posts/projects
### Database tables ### Database tables
@@ -433,7 +433,7 @@ Implements `BaseService`:
|-------|---------| |-------|---------|
| `news` | All synced articles with `status` (published/draft), `grade`, `slug`, `show_on_landing` | | `news` | All synced articles with `status` (published/draft), `grade`, `slug`, `show_on_landing` |
| `news_images` | Images extracted from article URLs | | `news_images` | Images extracted from article URLs |
| `news_sync` | Sync state per article `guid` tracks grading history | | `news_sync` | Sync state per article `guid` - tracks grading history |
### Site settings (seeded on startup) ### Site settings (seeded on startup)
@@ -469,7 +469,7 @@ The `signals` topic is available as a feed filter sidebar item and post topic. A
All dates displayed to users use European DD/MM/YYYY format. Implemented via: All dates displayed to users use European DD/MM/YYYY format. Implemented via:
- **`format_date(dt_str, include_time=False)`** in `utils.py` converts ISO datetime → `DD/MM/YYYY` or `DD/MM/YYYY HH:MM` - **`format_date(dt_str, include_time=False)`** in `utils.py` - converts ISO datetime → `DD/MM/YYYY` or `DD/MM/YYYY HH:MM`
- Registered as template global in `templating.py`: `{{ format_date(dt) }}` - Registered as template global in `templating.py`: `{{ format_date(dt) }}`
- **`time_ago()`** returns `DD/MM/YYYY` for items older than 30 days (instead of `"Xmo ago"`) - **`time_ago()`** returns `DD/MM/YYYY` for items older than 30 days (instead of `"Xmo ago"`)
- Services page has a JS `formatDate()` function for live polling updates - Services page has a JS `formatDate()` function for live polling updates
@@ -478,7 +478,7 @@ All dates displayed to users use European DD/MM/YYYY format. Implemented via:
Both `/admin/users` and `/admin/news` use offset-based pagination via a reusable component: Both `/admin/users` and `/admin/news` use offset-based pagination via a reusable component:
- **`templates/_pagination.html`** numbered page links with ellipsis, Previous/Next buttons, total count - **`templates/_pagination.html`** - numbered page links with ellipsis, Previous/Next buttons, total count
- Routes accept `?page=N` query param, clamped to valid range - Routes accept `?page=N` query param, clamped to valid range
- `per_page = 25`, pagination metadata computed server-side and passed as `pagination` dict - `per_page = 25`, pagination metadata computed server-side and passed as `pagination` dict
- Only renders when `total_pages > 1` - Only renders when `total_pages > 1`
@@ -488,9 +488,9 @@ Both `/admin/users` and `/admin/news` use offset-based pagination via a reusable
News articles have an internal detail page at `/news/{slug}` with full comment support: News articles have an internal detail page at `/news/{slug}` with full comment support:
- **Route:** `GET /news/{news_slug}` in `routers/news.py` resolves by slug first, then UUID - **Route:** `GET /news/{news_slug}` in `routers/news.py` - resolves by slug first, then UUID
- **Template:** `templates/news_detail.html` shows image, source, grade, description, content, external link - **Template:** `templates/news_detail.html` - shows image, source, grade, description, content, external link
- **Comments:** Uses `_comment_section.html` with `target_type="news"` same component as posts/projects - **Comments:** Uses `_comment_section.html` with `target_type="news"` - same component as posts/projects
- **`resolve_target_redirect()`** in `comments.py` handles `"news"` → `/news/{slug}` - **`resolve_target_redirect()`** in `comments.py` handles `"news"` → `/news/{slug}`
- Listing links in `news.html` point to internal detail page; "Read on Source" still goes to external URL - Listing links in `news.html` point to internal detail page; "Read on Source" still goes to external URL
@@ -507,7 +507,7 @@ Articles can be toggled to appear on the landing page via `/admin/news/{uid}/lan
The feed page (`GET /feed`) is accessible without authentication: The feed page (`GET /feed`) is accessible without authentication:
- Uses `get_current_user(request)` instead of `require_user()` returns `None` for guests - Uses `get_current_user(request)` instead of `require_user()` - returns `None` for guests
- Guests see posts but not the FAB, create modal, inline comment forms, or following tab - Guests see posts but not the FAB, create modal, inline comment forms, or following tab
- All POST routes (create, comment, vote) remain guarded by `require_user()` - All POST routes (create, comment, vote) remain guarded by `require_user()`
- Topnav shows Login/Sign Up for unauthenticated visitors; Messages, Admin, notifications for authenticated - Topnav shows Login/Sign Up for unauthenticated visitors; Messages, Admin, notifications for authenticated
@@ -515,11 +515,11 @@ The feed page (`GET /feed`) is accessible without authentication:
### Step 2: Implement backend ### Step 2: Implement backend
- Add/modify the route in `routers/{area}.py` - Add/modify the route in `routers/{area}.py`
- Use `await request.form()`, never Pydantic models - Validate form input with a typed `Annotated[Model, Form()]` param (define the model in `models.py`); read raw `await request.form()` only for file uploads
- Use `templates.TemplateResponse(...)` from `devplacepy.templating` - Use `templates.TemplateResponse(...)` from `devplacepy.templating`
- Redirect with `RedirectResponse(url=..., status_code=302)` - Redirect with `RedirectResponse(url=..., status_code=302)`
- Log every action: `logger.info(...)` - Log every action: `logger.info(...)`
- New DB fields auto-sync via `dataset` just add to the insert/update dict - New DB fields auto-sync via `dataset` - just add to the insert/update dict
- For ownership checks: `if resource["user_uid"] == user["uid"]` - For ownership checks: `if resource["user_uid"] == user["uid"]`
- For deletion: cascade related data first (comments → votes → post) - For deletion: cascade related data first (comments → votes → post)
- Register new routers in `main.py`: `app.include_router(router, prefix="/{path}")` - Register new routers in `main.py`: `app.include_router(router, prefix="/{path}")`
@@ -532,7 +532,7 @@ The feed page (`GET /feed`) is accessible without authentication:
- Jinja2 globals: `avatar_url()`, `get_unread_count()`, `get_user_projects()` - Jinja2 globals: `avatar_url()`, `get_unread_count()`, `get_user_projects()`
- For clickable avatars: `{% set _user = ... %}{% include "_avatar_link.html" %}` - For clickable avatars: `{% set _user = ... %}{% include "_avatar_link.html" %}`
- For rendered content: add `class="rendered-content"` and `data-render` attribute - For rendered content: add `class="rendered-content"` and `data-render` attribute
- No NPM, no frameworks pure ES6 modules - No NPM, no frameworks - pure ES6 modules
### Step 4: Validate code ### Step 4: Validate code
@@ -542,7 +542,7 @@ hawk .
Zero errors required. Zero errors required.
### Step 5: Run existing tests ### Step 5: Run existing tests (only if asked by user)
```bash ```bash
make test make test
@@ -560,7 +560,7 @@ All tests must pass. Tests stop at first failure (`-x`).
- Test both success paths and error/validation paths - Test both success paths and error/validation paths
- For delete buttons, scope to the specific element type (e.g., `.comment-action-btn`) - For delete buttons, scope to the specific element type (e.g., `.comment-action-btn`)
### Step 7: Run full suite again ### Step 7: Run full suite again (only if asked by user)
```bash ```bash
hawk . hawk .
@@ -587,13 +587,9 @@ while feature_not_complete:
1. Plan: read existing code, design the change 1. Plan: read existing code, design the change
2. Implement: write code (router → template → CSS → JS) 2. Implement: write code (router → template → CSS → JS)
3. hawk . # must pass 3. hawk . # must pass
4. make test # all must pass, -x stops at first failure 4. falcon take + describe # visual check for UI changes
5. If tests fail: diagnose → fix → goto 3 5. If visual fail: fix CSS/template → goto 3
6. Update tests if new functionality was added 6. Update AGENTS.md if needed
7. make test # re-verify after test changes
8. falcon take + describe # visual check for UI changes
9. If visual fail: fix CSS/template → goto 3
10. Update AGENTS.md if needed
``` ```
Failures at any step block the workflow. Never skip a failed step. Failures at any step block the workflow. Never skip a failed step.
@@ -607,8 +603,8 @@ Gitea Actions workflow at `.gitea/workflows/test.yaml` runs on every push/PR to
All SEO features are implemented across the following locations: All SEO features are implemented across the following locations:
### Core SEO utilities ### Core SEO utilities
- `devplacepy/seo.py` JSON-LD schema generators (WebSite, BreadcrumbList, DiscussionForumPosting, ProfilePage, SoftwareApplication), meta description truncation, schema combiner, sitemap XML generator - `devplacepy/seo.py` - JSON-LD schema generators (WebSite, BreadcrumbList, DiscussionForumPosting, ProfilePage, SoftwareApplication), meta description truncation, schema combiner, sitemap XML generator
- `routers/seo.py` robots.txt and sitemap.xml routes - `routers/seo.py` - robots.txt and sitemap.xml routes
### SEO template context ### SEO template context
- Every router passes `page_title`, `meta_description`, `meta_robots`, `canonical_url`, `og_title`, `og_description`, `og_image`, `og_type`, `breadcrumbs`, `page_schema` via `base_seo_context()` - Every router passes `page_title`, `meta_description`, `meta_robots`, `canonical_url`, `og_title`, `og_description`, `og_image`, `og_type`, `breadcrumbs`, `page_schema` via `base_seo_context()`
@@ -618,15 +614,15 @@ All SEO features are implemented across the following locations:
- All other pages: `index,follow` - All other pages: `index,follow`
### Template layer ### Template layer
- `templates/base.html` dynamic `<title>`, `<meta description>`, `<link canonical>`, `<meta robots>`, Open Graph, Twitter Cards, JSON-LD injection, breadcrumb nav, CDN `dns-prefetch`/`preconnect` - `templates/base.html` - dynamic `<title>`, `<meta description>`, `<link canonical>`, `<meta robots>`, Open Graph, Twitter Cards, JSON-LD injection, breadcrumb nav, CDN `dns-prefetch`/`preconnect`
- `static/css/base.css` `.breadcrumb` (aria-label breadcrumb nav), `.sr-only` (accessible hidden headings) - `static/css/base.css` - `.breadcrumb` (aria-label breadcrumb nav), `.sr-only` (accessible hidden headings)
### Heading hierarchy ### Heading hierarchy
- `feed.html` `<h1 class="sr-only">Feed</h1>` - `feed.html` - `<h1 class="sr-only">Feed</h1>`
- `profile.html` username rendered as `<h1 class="profile-name">` - `profile.html` - username rendered as `<h1 class="profile-name">`
- `messages.html` `<h1 class="sr-only">Messages</h1>` - `messages.html` - `<h1 class="sr-only">Messages</h1>`
- `projects.html` `<h1>Projects</h1>` - `projects.html` - `<h1>Projects</h1>`
- `post.html` post title as `<h1>`, "Related Discussions" as `<h3>` - `post.html` - post title as `<h1>`, "Related Discussions" as `<h3>`
### Post slugs ### Post slugs
- Slug generated on post creation via `slugify()` and stored in `posts.slug` column - Slug generated on post creation via `slugify()` and stored in `posts.slug` column
@@ -634,7 +630,7 @@ All SEO features are implemented across the following locations:
- Minimum content validation: post body >= 10 chars, comment >= 3 chars - Minimum content validation: post body >= 10 chars, comment >= 3 chars
### Related posts ### Related posts
- `templates/post.html` "Related Discussions" widget at bottom of post page (queried by matching topic) - `templates/post.html` - "Related Discussions" widget at bottom of post page (queried by matching topic)
### Performance ### Performance
- `loading="lazy"` on all avatar images - `loading="lazy"` on all avatar images
@@ -642,8 +638,8 @@ All SEO features are implemented across the following locations:
- Security headers middleware: `X-Robots-Tag`, `X-Content-Type-Options` - Security headers middleware: `X-Robots-Tag`, `X-Content-Type-Options`
### Default OG image ### Default OG image
- `static/og-default.svg` 1200x630 SVG with DevPlace branding - `static/og-default.svg` - 1200x630 SVG with DevPlace branding
- Used as fallback `og:image` on all pages - Used as fallback `og:image` on all pages
### SEO tests ### SEO tests
- `tests/test_seo.py` 13 tests covering: robots.txt, sitemap.xml, page titles, noindex, canonical URLs, OG tags, Twitter cards, structured data, security headers - `tests/test_seo.py` - 13 tests covering: robots.txt, sitemap.xml, page titles, noindex, canonical URLs, OG tags, Twitter cards, structured data, security headers
+2 -2
View File
@@ -1,4 +1,4 @@
FROM python:3.11-slim FROM python:3.13-slim
WORKDIR /app WORKDIR /app
@@ -19,4 +19,4 @@ EXPOSE 10500
HEALTHCHECK --interval=30s --timeout=10s --retries=3 --start-period=10s \ HEALTHCHECK --interval=30s --timeout=10s --retries=3 --start-period=10s \
CMD curl -f http://localhost:10500/ || exit 1 CMD curl -f http://localhost:10500/ || exit 1
CMD ["uvicorn", "devplacepy.main:app", "--host", "0.0.0.0", "--port", "10500", "--workers", "4", "--backlog", "8192"] CMD ["uvicorn", "devplacepy.main:app", "--host", "0.0.0.0", "--port", "10500", "--workers", "4", "--backlog", "8192", "--proxy-headers", "--forwarded-allow-ips", "*"]
+11 -3
View File
@@ -5,6 +5,7 @@ LOCUST_DB ?= $(LOCUST_DB_DIR)/datastore.db
LOCUST_USERS ?= 20 LOCUST_USERS ?= 20
LOCUST_SPAWN_RATE ?= 5 LOCUST_SPAWN_RATE ?= 5
LOCUST_RUN_TIME ?= 120s LOCUST_RUN_TIME ?= 120s
DEVPLACE_RATE_LIMIT ?= 1000000
.PHONY: install dev clean test test-headed demo locust locust-headless .PHONY: install dev clean test test-headed demo locust locust-headless
@@ -15,19 +16,20 @@ dev:
uvicorn devplacepy.main:app --reload --host 0.0.0.0 --port 10500 --backlog 4096 uvicorn devplacepy.main:app --reload --host 0.0.0.0 --port 10500 --backlog 4096
prod: prod:
uvicorn devplacepy.main:app --host 0.0.0.0 --port 10500 --workers 2 --backlog 8192 uvicorn devplacepy.main:app --host 0.0.0.0 --port 10500 --workers 2 --backlog 8192 --proxy-headers --forwarded-allow-ips '*'
test: test:
PLAYWRIGHT_HEADLESS=1 python -m pytest tests/test_landing.py tests/test_auth.py tests/test_feed.py tests/test_post.py tests/test_profile.py tests/test_projects.py tests/test_messages.py tests/test_notifications.py tests/test_services.py tests/test_seo.py -v --tb=line -x PLAYWRIGHT_HEADLESS=1 python -m pytest tests/ -v --tb=line -x
test-headed: test-headed:
PLAYWRIGHT_HEADLESS=0 python -m pytest tests/test_landing.py tests/test_auth.py tests/test_feed.py tests/test_post.py tests/test_profile.py tests/test_projects.py tests/test_messages.py tests/test_notifications.py tests/test_services.py -v --tb=line -x PLAYWRIGHT_HEADLESS=0 python -m pytest tests/ -v --tb=line -x
demo: demo:
PLAYWRIGHT_HEADLESS=0 python -m pytest tests/test_demo.py -v -s --tb=line -x PLAYWRIGHT_HEADLESS=0 python -m pytest tests/test_demo.py -v -s --tb=line -x
locust: locust:
export DEVPLACE_DATABASE_URL="sqlite:///$(LOCUST_DB)"; \ export DEVPLACE_DATABASE_URL="sqlite:///$(LOCUST_DB)"; \
export DEVPLACE_RATE_LIMIT=$(DEVPLACE_RATE_LIMIT); \
mkdir -p $(LOCUST_DB_DIR); \ mkdir -p $(LOCUST_DB_DIR); \
rm -f $(LOCUST_DB); \ rm -f $(LOCUST_DB); \
uvicorn devplacepy.main:app --host 127.0.0.1 --port $(LOCUST_PORT) --backlog 8192 > /tmp/devplace_locust_server.log 2>&1 & \ uvicorn devplacepy.main:app --host 127.0.0.1 --port $(LOCUST_PORT) --backlog 8192 > /tmp/devplace_locust_server.log 2>&1 & \
@@ -39,6 +41,7 @@ locust:
locust-headless: locust-headless:
export DEVPLACE_DATABASE_URL="sqlite:///$(LOCUST_DB)"; \ export DEVPLACE_DATABASE_URL="sqlite:///$(LOCUST_DB)"; \
export DEVPLACE_RATE_LIMIT=$(DEVPLACE_RATE_LIMIT); \
mkdir -p $(LOCUST_DB_DIR); \ mkdir -p $(LOCUST_DB_DIR); \
rm -f $(LOCUST_DB); \ rm -f $(LOCUST_DB); \
uvicorn devplacepy.main:app --host 127.0.0.1 --port $(LOCUST_PORT) --backlog 8192 > /tmp/devplace_locust_server.log 2>&1 & \ uvicorn devplacepy.main:app --host 127.0.0.1 --port $(LOCUST_PORT) --backlog 8192 > /tmp/devplace_locust_server.log 2>&1 & \
@@ -70,3 +73,8 @@ docker-logs:
docker-clean: docker-clean:
docker compose down -v docker compose down -v
deploy:
git checkout production
git merge master
git push origin production
+12 -12
View File
@@ -1,4 +1,4 @@
# DevPlace The Developer Social Network # DevPlace - The Developer Social Network
Server-rendered social network for developers. FastAPI backend serving Jinja2 templates with ES6 interactivity. Avatar generation via Multiavatar (local, no network). SQLite via `dataset` with WAL mode and concurrency tuning. Server-rendered social network for developers. FastAPI backend serving Jinja2 templates with ES6 interactivity. Avatar generation via Multiavatar (local, no network). SQLite via `dataset` with WAL mode and concurrency tuning.
@@ -81,9 +81,9 @@ devplacepy/
### Service framework ### Service framework
- **`BaseService`** abstract class with async run loop, `deque(maxlen=20)` log buffer, `name`, `interval_seconds` - **`BaseService`** - abstract class with async run loop, `deque(maxlen=20)` log buffer, `name`, `interval_seconds`
- **`ServiceManager`** singleton that registers, starts, and stops all services - **`ServiceManager`** - singleton that registers, starts, and stops all services
- **`NewsService`** fetches news from `news.app.molodetz.nl/api`, grades with AI, stores articles >= threshold - **`NewsService`** - fetches news from `news.app.molodetz.nl/api`, grades with AI, stores articles >= threshold
### Adding a service ### Adding a service
@@ -108,7 +108,7 @@ Configuration via admin site settings:
News articles have detail pages at `/news/{slug}` with full comment support (same component as posts/projects). The landing page can display curated articles toggled from admin. News articles have detail pages at `/news/{slug}` with full comment support (same component as posts/projects). The landing page can display curated articles toggled from admin.
CLI: `devplace news clear` delete all news from local database. CLI: `devplace news clear` - delete all news from local database.
## Database ## Database
@@ -123,12 +123,12 @@ PRAGMA temp_store=MEMORY; -- temp tables in memory
PRAGMA mmap_size=268435456; -- 256MB memory map for reads PRAGMA mmap_size=268435456; -- 256MB memory map for reads
``` ```
All indexes are created via `CREATE INDEX IF NOT EXISTS` wrapped in try/except safe to run on every startup regardless of table state. All indexes are created via `CREATE INDEX IF NOT EXISTS` wrapped in try/except - safe to run on every startup regardless of table state.
## Testing ## Testing
- **148 tests** across 14 files: Playwright integration + unit tests - **148 tests** across 14 files: Playwright integration + unit tests
- Playwright (NOT pytest-playwright plugin conflicts, uninstall it) - Playwright (NOT pytest-playwright plugin - conflicts, uninstall it)
- Server starts as subprocess on port 10501 with isolated temp database - Server starts as subprocess on port 10501 with isolated temp database
- Test users `alice_test` / `bob_test` seeded via HTTP at session start - Test users `alice_test` / `bob_test` seeded via HTTP at session start
- Tests stop at first failure (`-x` flag) - Tests stop at first failure (`-x` flag)
@@ -137,13 +137,13 @@ All indexes are created via `CREATE INDEX IF NOT EXISTS` wrapped in try/except
### Key test patterns ### Key test patterns
- Every `page.goto()` and `page.wait_for_url()` uses `wait_until="domcontentloaded"` avatar images don't block test execution - Every `page.goto()` and `page.wait_for_url()` uses `wait_until="domcontentloaded"` - avatar images don't block test execution
- Session-scoped browser context with per-test cookie clearing - Session-scoped browser context with per-test cookie clearing
- `page.locator(...).wait_for(state="visible")` preferred over bare selectors - `page.locator(...).wait_for(state="visible")` preferred over bare selectors
## Avatars ## Avatars
Uses [Multiavatar](https://github.com/multiavatar/multiavatar-python) generates deterministic SVG avatars locally from a seed string (the username). No external API calls, no network dependency. Generation takes <5ms. Results are cached in-memory (cleared on server restart). Uses [Multiavatar](https://github.com/multiavatar/multiavatar-python) - generates deterministic SVG avatars locally from a seed string (the username). No external API calls, no network dependency. Generation takes <5ms. Results are cached in-memory (cleared on server restart).
Avatar URL format: `/avatar/multiavatar/{username}?size={size}` Avatar URL format: `/avatar/multiavatar/{username}?size={size}`
@@ -159,12 +159,12 @@ Gitea Actions workflow at `.gitea/workflows/test.yaml`:
## Feature workflow ## Feature workflow
1. Implement the feature (router + template + CSS + JS) 1. Implement the feature (router + template + CSS + JS)
2. `hawk .` validate all source files 2. `hawk .` - validate all source files
3. `make test` run all tests (fail-fast) 3. `make test` - run all tests (fail-fast)
4. Add Playwright tests in `tests/test_*.py` for new functionality 4. Add Playwright tests in `tests/test_*.py` for new functionality
5. For visual features: `falcon take --output /tmp/verify.png && falcon describe /tmp/verify.png` 5. For visual features: `falcon take --output /tmp/verify.png && falcon describe /tmp/verify.png`
6. Update `AGENTS.md` and `README.md` if new conventions were introduced 6. Update `AGENTS.md` and `README.md` if new conventions were introduced
## License ## License
MIT DevPlace MIT - DevPlace
+210 -74
View File
@@ -1,33 +1,95 @@
import logging import logging
from datetime import datetime from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from PIL import Image from PIL import Image
from io import BytesIO from io import BytesIO
from devplacepy.database import get_table from devplacepy.database import get_table, db
from devplacepy.config import STATIC_DIR from devplacepy.config import STATIC_DIR
from devplacepy.utils import generate_uid from devplacepy.utils import generate_uid
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
ATTACHMENTS_DIR = STATIC_DIR / "uploads" / "attachments"
UPLOADS_DIR = STATIC_DIR / "uploads"
ATTACHMENTS_DIR = UPLOADS_DIR / "attachments"
THUMBNAIL_SIZE = (200, 200) THUMBNAIL_SIZE = (200, 200)
THUMBNAIL_QUALITY = 80 THUMBNAIL_QUALITY = 80
IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".tiff"} IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".tiff"}
THUMBNAIL_EXTENSIONS = IMAGE_EXTENSIONS - {".gif"} THUMBNAIL_EXTENSIONS = IMAGE_EXTENSIONS - {".gif"}
POST_IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".gif", ".webp"}
ALLOWED_UPLOAD_TYPES = {
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".png": "image/png",
".gif": "image/gif",
".webp": "image/webp",
".bmp": "image/bmp",
".tiff": "image/tiff",
".pdf": "application/pdf",
".zip": "application/zip",
".mp4": "video/mp4",
".mp3": "audio/mpeg",
".txt": "text/plain",
".py": "text/x-python",
".js": "text/javascript",
".css": "text/css",
".md": "text/markdown",
}
FILE_ICONS = {
".pdf": "\U0001F4C4",
".zip": "\U0001F4E6",
".gz": "\U0001F4E6",
".tar": "\U0001F4E6",
".rar": "\U0001F4E6",
".7z": "\U0001F4E6",
".mp4": "\U0001F3AC",
".mp3": "\U0001F3B5",
".py": "\U0001F4BB",
".js": "\U0001F4BB",
".ts": "\U0001F4BB",
".html": "\U0001F4BB",
".css": "\U0001F4BB",
".json": "\U0001F4BB",
".md": "\U0001F4BB",
".csv": "\U0001F4CA",
".xls": "\U0001F4CA",
".xlsx": "\U0001F4CA",
".doc": "\U0001F4DD",
".docx": "\U0001F4DD",
".txt": "\U0001F4C4",
".exe": "\u2699",
".bin": "\u2699",
}
DEFAULT_FILE_ICON = "\U0001F4CE"
def _get_setting(key, default): def _get_setting(key, default):
row = get_table("site_settings").find_one(key=key) row = get_table("site_settings").find_one(key=key)
return row["value"] if row else default return row["value"] if row else default
def _get_max_upload_bytes(): def _get_max_upload_bytes():
return int(_get_setting("max_upload_size_mb", "10")) * 1024 * 1024 return int(_get_setting("max_upload_size_mb", "10")) * 1024 * 1024
def _directory_for(uid): def _directory_for(uid):
return f"{uid[:2]}/{uid[2:4]}" return f"{uid[:2]}/{uid[2:4]}"
def _detect_mime(file_bytes, original_filename): def _detect_mime(file_bytes, original_filename):
ext = Path(original_filename).suffix.lower() ext = Path(original_filename).suffix.lower()
m = {".jpg":"image/jpeg",".jpeg":"image/jpeg",".png":"image/png",".gif":"image/gif",".webp":"image/webp",".svg":"image/svg+xml",".bmp":"image/bmp",".tiff":"image/tiff",".pdf":"application/pdf",".zip":"application/zip",".mp4":"video/mp4",".mp3":"audio/mpeg",".txt":"text/plain",".py":"text/x-python",".js":"text/javascript",".html":"text/html",".css":"text/css",".md":"text/markdown"} return ALLOWED_UPLOAD_TYPES.get(ext, "application/octet-stream")
return m.get(ext, "application/octet-stream")
def _image_dimensions(file_bytes):
try:
img = Image.open(BytesIO(file_bytes))
return img.width, img.height
except Exception as e:
logger.warning(f"Could not read image dimensions: {e}")
return None, None
def _generate_thumbnail(file_bytes, thumb_path): def _generate_thumbnail(file_bytes, thumb_path):
try: try:
@@ -45,95 +107,169 @@ def _generate_thumbnail(file_bytes, thumb_path):
logger.warning(f"Thumbnail generation failed: {e}") logger.warning(f"Thumbnail generation failed: {e}")
return None return None
def save_inline_image(file_bytes, original_filename):
if len(file_bytes) > _get_max_upload_bytes():
logger.warning(f"Inline image too large: {original_filename}")
return None
ext = Path(original_filename).suffix.lower()
if ext not in POST_IMAGE_EXTENSIONS:
logger.warning(f"Unsupported inline image type: {ext}")
return None
UPLOADS_DIR.mkdir(parents=True, exist_ok=True)
filename = f"{generate_uid()}{ext}"
(UPLOADS_DIR / filename).write_bytes(file_bytes)
logger.info(f"Inline image saved: {filename}")
return filename
def delete_inline_image(filename):
if not filename:
return
try:
(UPLOADS_DIR / filename).unlink(missing_ok=True)
except Exception as e:
logger.warning(f"Failed to delete inline image {filename}: {e}")
def store_attachment(file_bytes, original_filename, user_uid): def store_attachment(file_bytes, original_filename, user_uid):
if len(file_bytes) > _get_max_upload_bytes(): if len(file_bytes) > _get_max_upload_bytes():
return None return None
ext = Path(original_filename).suffix.lower()
if ext not in ALLOWED_UPLOAD_TYPES:
return None
uid = generate_uid() uid = generate_uid()
ext = Path(original_filename).suffix.lower() or ".bin"
stored_name = f"{uid}{ext}" stored_name = f"{uid}{ext}"
directory = _directory_for(uid) directory = _directory_for(uid)
file_dir = ATTACHMENTS_DIR / directory file_dir = ATTACHMENTS_DIR / directory
file_dir.mkdir(parents=True, exist_ok=True) file_dir.mkdir(parents=True, exist_ok=True)
(file_dir / stored_name).write_bytes(file_bytes) (file_dir / stored_name).write_bytes(file_bytes)
mime = _detect_mime(file_bytes, original_filename) mime = _detect_mime(file_bytes, original_filename)
is_img = mime.startswith("image/") is_image = mime.startswith("image/")
img_w, img_h = None, None image_width, image_height = None, None
thumb = None thumbnail = None
if is_img: if is_image:
try: image_width, image_height = _image_dimensions(file_bytes)
img = Image.open(BytesIO(file_bytes)) if ext not in (".gif",):
img_w, img_h = img.width, img.height thumbnail = _generate_thumbnail(file_bytes, file_dir / f"{uid}_thumb.jpg")
if ext not in (".gif",):
thumb_name = f"{uid}_thumb.jpg"
r = _generate_thumbnail(file_bytes, file_dir / thumb_name)
if r: thumb = r
except: pass
get_table("attachments").insert({ get_table("attachments").insert({
"uid": uid, "target_type": "", "target_uid": "", "user_uid": user_uid, "uid": uid,
"original_filename": original_filename, "stored_name": stored_name, "target_type": "",
"directory": directory, "file_size": len(file_bytes), "mime_type": mime, "target_uid": "",
"image_width": img_w, "image_height": img_h, "user_uid": user_uid,
"has_thumbnail": 1 if thumb else 0, "original_filename": original_filename,
"created_at": datetime.utcnow().isoformat(), "stored_name": stored_name,
"directory": directory,
"file_size": len(file_bytes),
"mime_type": mime,
"image_width": image_width,
"image_height": image_height,
"has_thumbnail": 1 if thumbnail else 0,
"created_at": datetime.now(timezone.utc).isoformat(),
}) })
return {"uid": uid, "original_filename": original_filename, "file_size": len(file_bytes), "mime_type": mime, "url": f"/static/uploads/attachments/{directory}/{stored_name}", "thumbnail_url": f"/static/uploads/attachments/{directory}/{thumb}" if thumb else None, "has_thumbnail": thumb is not None, "is_image": is_img} return {
"uid": uid,
"original_filename": original_filename,
"file_size": len(file_bytes),
"mime_type": mime,
"url": f"/static/uploads/attachments/{directory}/{stored_name}",
"thumbnail_url": f"/static/uploads/attachments/{directory}/{thumbnail}" if thumbnail else None,
"has_thumbnail": thumbnail is not None,
"is_image": is_image,
}
def link_attachments(uids, target_type, target_uid): def link_attachments(uids, target_type, target_uid):
if not uids: return if not uids:
atts = get_table("attachments") return
for auid in uids: attachments = get_table("attachments")
auid = auid.strip() for uid in uids:
if not auid: continue uid = uid.strip()
e = atts.find_one(uid=auid) if not uid:
if e: atts.update({"id": e["id"], "uid": auid, "target_type": target_type, "target_uid": target_uid}, ["id"]) continue
existing = attachments.find_one(uid=uid)
if existing:
attachments.update({"id": existing["id"], "uid": uid, "target_type": target_type, "target_uid": target_uid}, ["id"])
def delete_attachment(uid): def delete_attachment(uid):
atts = get_table("attachments") attachments = get_table("attachments")
att = atts.find_one(uid=uid) attachment = attachments.find_one(uid=uid)
if not att: return if not attachment:
sn, d = att.get("stored_name",""), att.get("directory","") return
if sn and d: stored_name = attachment.get("stored_name", "")
fp = ATTACHMENTS_DIR / d / sn directory = attachment.get("directory", "")
try: fp.unlink(missing_ok=True) if stored_name and directory:
except: pass file_path = ATTACHMENTS_DIR / directory / stored_name
for tp in (ATTACHMENTS_DIR / d).glob(f"{Path(sn).stem}_thumb.*"): try:
try: tp.unlink(missing_ok=True) file_path.unlink(missing_ok=True)
except: pass except Exception as e:
atts.delete(id=att["id"]) logger.warning(f"Failed to delete attachment file {file_path}: {e}")
for thumb_path in (ATTACHMENTS_DIR / directory).glob(f"{Path(stored_name).stem}_thumb.*"):
try:
thumb_path.unlink(missing_ok=True)
except Exception as e:
logger.warning(f"Failed to delete thumbnail {thumb_path}: {e}")
attachments.delete(id=attachment["id"])
def delete_target_attachments(tt, tu):
for a in get_table("attachments").find(target_type=tt, target_uid=tu):
delete_attachment(a["uid"])
def get_attachments(tt, tu): def delete_target_attachments(target_type, target_uid):
try: rows = list(get_table("attachments").find(target_type=tt, target_uid=tu, order_by=["created_at"])) for attachment in get_table("attachments").find(target_type=target_type, target_uid=target_uid):
except: return [] delete_attachment(attachment["uid"])
return [_r2a(r) for r in rows]
def get_attachments_batch(tt, uids):
if not uids: return {} def get_attachments(target_type, target_uid):
from devplacepy.database import db if "attachments" not in db.tables:
if "attachments" not in db.tables: return {u:[] for u in uids} return []
ph = ",".join(f":p{i}" for i in range(len(uids))) rows = list(get_table("attachments").find(target_type=target_type, target_uid=target_uid, order_by=["created_at"]))
try: return [_row_to_attachment(r) for r in rows]
rows = db.query(f"SELECT * FROM attachments WHERE target_type=:tt AND target_uid IN ({ph}) ORDER BY created_at", tt=tt, **{f"p{i}":u for i,u in enumerate(uids)})
except: return {u:[] for u in uids}
r = {u:[] for u in uids} def get_attachments_batch(target_type, uids):
if not uids:
return {}
if "attachments" not in db.tables:
return {uid: [] for uid in uids}
placeholders = ",".join(f":p{i}" for i in range(len(uids)))
params = {f"p{i}": uid for i, uid in enumerate(uids)}
rows = db.query(
f"SELECT * FROM attachments WHERE target_type=:tt AND target_uid IN ({placeholders}) ORDER BY created_at",
tt=target_type, **params,
)
result = {uid: [] for uid in uids}
for row in rows: for row in rows:
if row["target_uid"] in r: r[row["target_uid"]].append(_r2a(row)) if row["target_uid"] in result:
return r result[row["target_uid"]].append(_row_to_attachment(row))
return result
def _r2a(r):
sn, d = r.get("stored_name",""), r.get("directory","")
ts = f"{Path(sn).stem}_thumb.jpg" if r.get("has_thumbnail") else None
return {"uid":r["uid"],"original_filename":r.get("original_filename",""),"file_size":r.get("file_size",0),"mime_type":r.get("mime_type",""),"url":f"/static/uploads/attachments/{d}/{sn}","thumbnail_url":f"/static/uploads/attachments/{d}/{ts}" if ts else None,"has_thumbnail":bool(r.get("has_thumbnail")),"is_image":r.get("mime_type","").startswith("image/")}
def format_file_size(b): def _row_to_attachment(row):
if b < 1024: return f"{b} B" stored_name = row.get("stored_name", "")
if b < 1048576: return f"{b/1024:.1f} KB" directory = row.get("directory", "")
return f"{b/1048576:.1f} MB" thumb_name = f"{Path(stored_name).stem}_thumb.jpg" if row.get("has_thumbnail") else None
return {
"uid": row["uid"],
"original_filename": row.get("original_filename", ""),
"file_size": row.get("file_size", 0),
"mime_type": row.get("mime_type", ""),
"url": f"/static/uploads/attachments/{directory}/{stored_name}",
"thumbnail_url": f"/static/uploads/attachments/{directory}/{thumb_name}" if thumb_name else None,
"has_thumbnail": bool(row.get("has_thumbnail")),
"is_image": row.get("mime_type", "").startswith("image/"),
}
def file_icon_emoji(fn):
ext = Path(fn).suffix.lower() def format_file_size(size):
m = {".pdf":"\U0001F4C4",".zip":"\U0001F4E6",".gz":"\U0001F4E6",".tar":"\U0001F4E6",".rar":"\U0001F4E6",".7z":"\U0001F4E6",".mp4":"\U0001F3AC",".mp3":"\U0001F3B5",".py":"\U0001F4BB",".js":"\U0001F4BB",".ts":"\U0001F4BB",".html":"\U0001F4BB",".css":"\U0001F4BB",".json":"\U0001F4BB",".md":"\U0001F4BB",".csv":"\U0001F4CA",".xls":"\U0001F4CA",".xlsx":"\U0001F4CA",".doc":"\U0001F4DD",".docx":"\U0001F4DD",".txt":"\U0001F4C4",".exe":"\u2699",".bin":"\u2699"} if size < 1024:
return m.get(ext, "\U0001F4CE") return f"{size} B"
if size < 1048576:
return f"{size / 1024:.1f} KB"
return f"{size / 1048576:.1f} MB"
def file_icon_emoji(filename):
ext = Path(filename).suffix.lower()
return FILE_ICONS.get(ext, DEFAULT_FILE_ICON)
+3 -2
View File
@@ -9,11 +9,12 @@ def avatar_url(style: str, seed: str, size: int = 128) -> str:
def generate_avatar_svg(seed: str) -> str: def generate_avatar_svg(seed: str) -> str:
try: try:
from multiavatar import multiavatar from multiavatar.multiavatar import multiavatar
svg = multiavatar(seed) svg = multiavatar(seed, None, None)
if svg and svg.strip().startswith("<svg"): if svg and svg.strip().startswith("<svg"):
return svg return svg
except Exception as e: except Exception as e:
logger.exception(e)
logger.warning(f"Avatar generation failed for {seed}: {e}") logger.warning(f"Avatar generation failed for {seed}: {e}")
initial = seed[:1].upper() if seed else "?" initial = seed[:1].upper() if seed else "?"
return ( return (
+36
View File
@@ -0,0 +1,36 @@
import time
from collections import OrderedDict
class TTLCache:
def __init__(self, ttl: int, max_size: int = 0):
self.ttl = ttl
self.max_size = max_size
self._store = OrderedDict()
def get(self, key):
entry = self._store.get(key)
if entry is None:
return None
value, expiry = entry
if time.time() >= expiry:
self._store.pop(key, None)
return None
self._store.move_to_end(key)
return value
def set(self, key, value):
self._store[key] = (value, time.time() + self.ttl)
self._store.move_to_end(key)
if self.max_size and len(self._store) > self.max_size:
self._store.popitem(last=False)
def pop(self, key):
self._store.pop(key, None)
def clear(self):
self._store.clear()
def items(self):
now = time.time()
return [(key, value) for key, (value, expiry) in self._store.items() if now < expiry]
+20 -1
View File
@@ -1,6 +1,7 @@
import argparse import argparse
import sys import sys
from devplacepy.database import get_table from devplacepy.database import get_table
from devplacepy.utils import strip_html
def cmd_role_get(args): def cmd_role_get(args):
@@ -32,7 +33,7 @@ def cmd_news_clear(args):
from devplacepy.database import db from devplacepy.database import db
for table in ("news", "news_images", "news_sync"): for table in ("news", "news_images", "news_sync"):
if table in db.tables: if table in db.tables:
count = len(list(db[table].all())) count = db[table].count()
db[table].delete() db[table].delete()
print(f"Deleted {count} rows from '{table}'") print(f"Deleted {count} rows from '{table}'")
else: else:
@@ -40,6 +41,22 @@ def cmd_news_clear(args):
print("News data cleared") print("News data cleared")
def cmd_news_sanitize(args):
from devplacepy.database import db
if "news" not in db.tables:
print("News table does not exist")
return
news_table = db["news"]
updated = 0
for row in news_table.all():
desc = (strip_html(row.get("description", "") or ""))[:5000]
content = (strip_html(row.get("content", "") or ""))[:10000]
if desc != row.get("description", "") or content != row.get("content", ""):
news_table.update({"id": row["id"], "description": desc, "content": content}, ["id"])
updated += 1
print(f"Sanitized {updated} news article(s)")
def cmd_attachments_prune(args): def cmd_attachments_prune(args):
from devplacepy.database import db from devplacepy.database import db
from devplacepy.config import STATIC_DIR from devplacepy.config import STATIC_DIR
@@ -101,6 +118,8 @@ def main():
news_sub = news.add_subparsers(title="action", dest="action") news_sub = news.add_subparsers(title="action", dest="action")
news_clear = news_sub.add_parser("clear", help="Delete all news from local database") news_clear = news_sub.add_parser("clear", help="Delete all news from local database")
news_clear.set_defaults(func=cmd_news_clear) news_clear.set_defaults(func=cmd_news_clear)
news_sanitize = news_sub.add_parser("sanitize", help="Strip HTML from all existing news descriptions and content")
news_sanitize.set_defaults(func=cmd_news_sanitize)
attachments = sub.add_parser("attachments", help="Attachment management") attachments = sub.add_parser("attachments", help="Attachment management")
att_sub = attachments.add_subparsers(title="action", dest="action") att_sub = attachments.add_subparsers(title="action", dest="action")
+1
View File
@@ -11,3 +11,4 @@ DATABASE_URL = environ.get("DEVPLACE_DATABASE_URL", f"sqlite:///{BASE_DIR / 'dev
SECRET_KEY = environ.get("SECRET_KEY", "devplace-secret-key-change-in-production") SECRET_KEY = environ.get("SECRET_KEY", "devplace-secret-key-change-in-production")
SESSION_MAX_AGE = 86400 * 7 SESSION_MAX_AGE = 86400 * 7
PORT = 10500 PORT = 10500
SITE_URL = environ.get("DEVPLACE_SITE_URL", "").rstrip("/")
+68 -14
View File
@@ -1,6 +1,7 @@
import dataset import dataset
import logging import logging
from datetime import datetime from datetime import datetime, timezone
from devplacepy.cache import TTLCache
from devplacepy.config import DATABASE_URL from devplacepy.config import DATABASE_URL
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -43,6 +44,7 @@ def init_db():
_index(db, "comments", "idx_comments_post_uid", ["post_uid"]) _index(db, "comments", "idx_comments_post_uid", ["post_uid"])
_index(db, "comments", "idx_comments_target", ["target_type", "target_uid"]) _index(db, "comments", "idx_comments_target", ["target_type", "target_uid"])
_index(db, "comments", "idx_comments_user_uid", ["user_uid"]) _index(db, "comments", "idx_comments_user_uid", ["user_uid"])
_index(db, "comments", "idx_comments_created_at", ["created_at"])
_index(db, "votes", "idx_votes_target", ["target_uid", "target_type"]) _index(db, "votes", "idx_votes_target", ["target_uid", "target_type"])
_index(db, "messages", "idx_messages_sender", ["sender_uid"]) _index(db, "messages", "idx_messages_sender", ["sender_uid"])
_index(db, "messages", "idx_messages_receiver", ["receiver_uid"]) _index(db, "messages", "idx_messages_receiver", ["receiver_uid"])
@@ -56,6 +58,7 @@ def init_db():
_index(db, "password_resets", "idx_password_resets_token", ["token"]) _index(db, "password_resets", "idx_password_resets_token", ["token"])
_index(db, "gists", "idx_gists_user_uid", ["user_uid"]) _index(db, "gists", "idx_gists_user_uid", ["user_uid"])
_index(db, "attachments", "idx_attachments_resource", ["resource_type", "resource_uid"]) _index(db, "attachments", "idx_attachments_resource", ["resource_type", "resource_uid"])
_index(db, "attachments", "idx_attachments_target", ["target_type", "target_uid"])
if "site_settings" in tables: if "site_settings" in tables:
defaults = {"site_name": "DevPlace", "site_description": "The Developer Social Network", "site_tagline": "Track industry shifts. Discover bold releases. Share what you are building in an open, uncensored environment."} defaults = {"site_name": "DevPlace", "site_description": "The Developer Social Network", "site_tagline": "Track industry shifts. Discover bold releases. Share what you are building in an open, uncensored environment."}
@@ -141,8 +144,17 @@ def get_comment_counts_by_post_uids(post_uids):
return {} return {}
placeholders = ", ".join(f":p{i}" for i in range(len(post_uids))) placeholders = ", ".join(f":p{i}" for i in range(len(post_uids)))
params = {f"p{i}": u for i, u in enumerate(post_uids)} params = {f"p{i}": u for i, u in enumerate(post_uids)}
rows = db.query(f"SELECT post_uid, COUNT(*) as c FROM comments WHERE post_uid IN ({placeholders}) GROUP BY post_uid", **params) rows = db.query(f"SELECT target_uid, COUNT(*) as c FROM comments WHERE target_type='post' AND target_uid IN ({placeholders}) GROUP BY target_uid", **params)
return {r["post_uid"]: r["c"] for r in rows} return {r["target_uid"]: r["c"] for r in rows}
def get_post_counts_by_user_uids(user_uids):
if not user_uids or "posts" not in db.tables:
return {}
placeholders = ", ".join(f":p{i}" for i in range(len(user_uids)))
params = {f"p{i}": u for i, u in enumerate(user_uids)}
rows = db.query(f"SELECT user_uid, COUNT(*) as c FROM posts WHERE user_uid IN ({placeholders}) GROUP BY user_uid", **params)
return {r["user_uid"]: r["c"] for r in rows}
def get_vote_counts(target_uids): def get_vote_counts(target_uids):
@@ -175,7 +187,8 @@ def load_comments(target_type, target_uid):
users = get_users_by_uids(uids) users = get_users_by_uids(uids)
ups, downs = get_vote_counts(cids) ups, downs = get_vote_counts(cids)
from devplacepy.utils import time_ago from devplacepy.utils import time_ago
atts_map = get_attachments_by_type("comment", cids) if "attachments" in db.tables else {} # noqa: F811 from devplacepy.attachments import get_attachments_batch as _gab
atts_map = _gab("comment", cids) if "attachments" in db.tables else {}
cmap = {} cmap = {}
for c in raw: for c in raw:
cmap[c["uid"]] = { cmap[c["uid"]] = {
@@ -215,6 +228,17 @@ def get_attachments_by_type(resource_type: str, resource_uids: list) -> dict:
return result return result
def get_news_images_by_uids(news_uids: list) -> dict:
if not news_uids or "news_images" not in db.tables:
return {}
images_table = db["news_images"]
rows = images_table.find(images_table.table.columns.news_uid.in_(news_uids), order_by=["uid"])
result = {}
for r in rows:
result.setdefault(r["news_uid"], r["url"])
return result
def delete_attachment_record(uid: str) -> None: def delete_attachment_record(uid: str) -> None:
if "attachments" not in db.tables: if "attachments" not in db.tables:
return return
@@ -249,11 +273,50 @@ def _delete_attachment_file(storage_path: str) -> None:
logger.warning(f"Failed to delete attachment file {storage_path}: {e}") logger.warning(f"Failed to delete attachment file {storage_path}: {e}")
_settings_cache = TTLCache(ttl=60)
def get_setting(key: str, default: str = "") -> str: def get_setting(key: str, default: str = "") -> str:
cached = _settings_cache.get(key)
if cached is not None:
return cached
if "site_settings" not in db.tables: if "site_settings" not in db.tables:
return default return default
entry = db["site_settings"].find_one(key=key) entry = db["site_settings"].find_one(key=key)
return entry["value"] if entry else default if entry is None:
return default
_settings_cache.set(key, entry["value"])
return entry["value"]
def get_int_setting(key: str, default: int) -> int:
raw = get_setting(key, str(default))
try:
return int(raw)
except (TypeError, ValueError):
return default
def clear_settings_cache() -> None:
_settings_cache.clear()
_stats_cache = TTLCache(ttl=30)
def get_site_stats() -> dict:
cached = _stats_cache.get("site")
if cached is not None:
return cached
today_start = datetime.now(timezone.utc).replace(hour=0, minute=0, second=0, microsecond=0).isoformat()
stats = {
"total_members": db["users"].count() if "users" in db.tables else 0,
"posts_today": db["posts"].count(created_at={">=": today_start}) if "posts" in db.tables else 0,
"total_projects": db["projects"].count() if "projects" in db.tables else 0,
"total_gists": db["gists"].count() if "gists" in db.tables else 0,
}
_stats_cache.set("site", stats)
return stats
def resolve_by_slug(table, slug): def resolve_by_slug(table, slug):
@@ -263,15 +326,6 @@ def resolve_by_slug(table, slug):
return entry return entry
def pagination_params(page, per_page=25):
total = None
total_pages = None
return {
"page": page,
"per_page": per_page,
}
def build_pagination(page, total, per_page=25): def build_pagination(page, total, per_page=25):
total_pages = max(1, __import__("math").ceil(total / per_page)) total_pages = max(1, __import__("math").ceil(total / per_page))
page = max(1, min(page, total_pages)) page = max(1, min(page, total_pages))
+67 -17
View File
@@ -6,8 +6,9 @@ from collections import defaultdict
from fastapi import FastAPI, Request from fastapi import FastAPI, Request
from fastapi.responses import HTMLResponse, RedirectResponse from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from fastapi.exceptions import RequestValidationError
from devplacepy.config import STATIC_DIR, PORT from devplacepy.config import STATIC_DIR, PORT
from devplacepy.database import init_db, get_table, db, get_users_by_uids, get_comment_counts_by_post_uids, get_vote_counts from devplacepy.database import init_db, get_table, db, get_users_by_uids, get_comment_counts_by_post_uids, get_vote_counts, get_news_images_by_uids
from devplacepy.templating import templates from devplacepy.templating import templates
from devplacepy.utils import get_current_user, time_ago from devplacepy.utils import get_current_user, time_ago
from devplacepy.seo import base_seo_context, site_url, website_schema, breadcrumb_schema, combine from devplacepy.seo import base_seo_context, site_url, website_schema, breadcrumb_schema, combine
@@ -22,23 +23,77 @@ logging.basicConfig(
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
_rate_limit_store = defaultdict(list) _rate_limit_store = defaultdict(list)
RATE_LIMIT = 60 RATE_LIMIT = int(os.environ.get("DEVPLACE_RATE_LIMIT", "60"))
RATE_WINDOW = 60 RATE_WINDOW = 60
class UploadStaticFiles(StaticFiles):
async def get_response(self, path, scope):
response = await super().get_response(path, scope)
response.headers["Content-Disposition"] = "attachment"
return response
app = FastAPI(title="DevPlace") app = FastAPI(title="DevPlace")
app.mount("/static/uploads", UploadStaticFiles(directory=str(STATIC_DIR / "uploads"), check_dir=False), name="uploads")
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static") app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
@app.exception_handler(404) @app.exception_handler(404)
async def not_found(request: Request, exc): async def not_found(request: Request, exc):
seo_ctx = base_seo_context(request, title="Not Found DevPlace", description="The page you requested does not exist.", robots="noindex") seo_ctx = base_seo_context(request, title="Not Found - DevPlace", description="The page you requested does not exist.", robots="noindex")
return templates.TemplateResponse("error.html", {**seo_ctx, "request": request, "error_code": 404, "error_message": "Page not found"}, status_code=404) return templates.TemplateResponse(request, "error.html", {**seo_ctx, "request": request, "error_code": 404, "error_message": "Page not found"}, status_code=404)
@app.exception_handler(500) @app.exception_handler(500)
async def server_error(request: Request, exc): async def server_error(request: Request, exc):
seo_ctx = base_seo_context(request, title="Server Error — DevPlace", description="Something went wrong.", robots="noindex") logger.exception("500 error on %s %s", request.method, request.url.path)
return templates.TemplateResponse("error.html", {**seo_ctx, "request": request, "error_code": 500, "error_message": "Internal server error"}, status_code=500) seo_ctx = base_seo_context(request, title="Server Error - DevPlace", description="Something went wrong.", robots="noindex")
return templates.TemplateResponse(request, "error.html", {**seo_ctx, "request": request, "error_code": 500, "error_message": "Internal server error"}, status_code=500)
_AUTH_FORM_PAGES = {
"/auth/signup": ("signup.html", "Join DevPlace"),
"/auth/login": ("login.html", "Sign In"),
"/auth/forgot-password": ("forgot_password.html", "Reset Password"),
}
_FRIENDLY_ERRORS = {
("username", "too_short"): "Username must be between 3 and 32 characters",
("username", "too_long"): "Username must be between 3 and 32 characters",
("password", "too_short"): "Password must be at least 6 characters",
}
def _friendly_error(err):
field = err["loc"][-1] if err.get("loc") else ""
key = (field, err.get("type", "").replace("string_", ""))
if key in _FRIENDLY_ERRORS:
return _FRIENDLY_ERRORS[key]
msg = err.get("msg", "Invalid input")
prefix = "Value error, "
return msg[len(prefix):] if msg.startswith(prefix) else msg
@app.exception_handler(RequestValidationError)
async def on_validation_error(request: Request, exc: RequestValidationError):
errors = [_friendly_error(e) for e in exc.errors()]
path = request.url.path
page = _AUTH_FORM_PAGES.get(path)
if page is None and path.startswith("/auth/reset-password/"):
page = ("reset_password.html", "Set New Password")
if page:
template_name, title = page
context = {**base_seo_context(request, title=title, robots="noindex,nofollow"), "request": request, "errors": errors}
try:
form = await request.form()
context.update({k: v for k, v in form.items() if isinstance(v, str)})
except Exception:
pass
if "token" in request.path_params:
context["token"] = request.path_params["token"]
return templates.TemplateResponse(request, template_name, context, status_code=400)
referer = request.headers.get("referer") or "/feed"
return RedirectResponse(url=referer, status_code=303)
app.include_router(auth.router, prefix="/auth") app.include_router(auth.router, prefix="/auth")
app.include_router(feed.router, prefix="/feed") app.include_router(feed.router, prefix="/feed")
@@ -72,7 +127,7 @@ async def add_security_headers(request: Request, call_next):
@app.middleware("http") @app.middleware("http")
async def rate_limit_middleware(request: Request, call_next): async def rate_limit_middleware(request: Request, call_next):
if request.method in ("POST", "PUT", "DELETE", "PATCH"): if request.method in ("POST", "PUT", "DELETE", "PATCH"):
ip = request.client.host if request.client else "unknown" ip = request.headers.get("X-Real-IP") or (request.client.host if request.client else "unknown")
now = time.time() now = time.time()
window_start = now - RATE_WINDOW window_start = now - RATE_WINDOW
_rate_limit_store[ip] = [t for t in _rate_limit_store[ip] if t > window_start] _rate_limit_store[ip] = [t for t in _rate_limit_store[ip] if t > window_start]
@@ -107,14 +162,9 @@ async def landing(request: Request):
landing_articles = [] landing_articles = []
if "news" in db.tables: if "news" in db.tables:
news_table = get_table("news") news_table = get_table("news")
raw = list(news_table.find(order_by=["-synced_at"], _limit=6)) raw = list(news_table.find(show_on_landing=1, order_by=["-synced_at"], _limit=6))
images_table = get_table("news_images") if "news_images" in db.tables else None images_by_news = get_news_images_by_uids([a["uid"] for a in raw])
for a in raw: for a in raw:
image_url = ""
if images_table:
img = images_table.find_one(news_uid=a["uid"])
if img:
image_url = img["url"]
landing_articles.append({ landing_articles.append({
"uid": a["uid"], "uid": a["uid"],
"slug": a.get("slug", ""), "slug": a.get("slug", ""),
@@ -125,7 +175,7 @@ async def landing(request: Request):
"grade": a.get("grade", 0), "grade": a.get("grade", 0),
"synced_at": a.get("synced_at", "") or "", "synced_at": a.get("synced_at", "") or "",
"time_ago": time_ago(a["synced_at"]), "time_ago": time_ago(a["synced_at"]),
"image_url": image_url, "image_url": images_by_news.get(a["uid"], ""),
}) })
landing_posts = [] landing_posts = []
@@ -151,12 +201,12 @@ async def landing(request: Request):
base = site_url(request) base = site_url(request)
seo_ctx = base_seo_context( seo_ctx = base_seo_context(
request, request,
title="DevPlace The Developer Social Network", title="DevPlace - The Developer Social Network",
description="Track industry shifts. Discover bold releases. Share what you're building in an open, uncensored environment.", description="Track industry shifts. Discover bold releases. Share what you're building in an open, uncensored environment.",
breadcrumbs=[], breadcrumbs=[],
schemas=[website_schema(base)], schemas=[website_schema(base)],
) )
return templates.TemplateResponse("landing.html", { return templates.TemplateResponse(request, "landing.html", {
**seo_ctx, "request": request, **seo_ctx, "request": request,
"landing_articles": landing_articles, "landing_articles": landing_articles,
"landing_posts": landing_posts, "landing_posts": landing_posts,
+155 -29
View File
@@ -1,49 +1,175 @@
from pydantic import BaseModel, Field from typing import Literal
from typing import Optional from pydantic import BaseModel, Field, field_validator, model_validator
from datetime import date from devplacepy.constants import TOPICS
class SignupRequest(BaseModel): class SignupForm(BaseModel):
username: str = Field(min_length=3, max_length=32, pattern=r"^[a-zA-Z0-9_-]+$") username: str = Field(min_length=3, max_length=32)
email: str = Field(min_length=5, max_length=255) email: str = Field(min_length=1, max_length=255)
password: str = Field(min_length=6, max_length=128) password: str = Field(min_length=6, max_length=128)
confirm_password: str = Field(min_length=6, max_length=128) confirm_password: str = Field(min_length=1, max_length=128)
@field_validator("username")
@classmethod
def username_chars(cls, value):
if not value.isascii() or not all(c.isalnum() or c in ("-", "_") for c in value):
raise ValueError("Username can only contain letters, numbers, hyphens, and underscores")
return value
@field_validator("email")
@classmethod
def email_has_at(cls, value):
if "@" not in value:
raise ValueError("Valid email is required")
return value
@model_validator(mode="after")
def passwords_match(self):
if self.password != self.confirm_password:
raise ValueError("Passwords do not match")
return self
class LoginRequest(BaseModel): class LoginForm(BaseModel):
email: str = Field(min_length=5, max_length=255) email: str = Field(min_length=1, max_length=255)
password: str = Field(min_length=1, max_length=128)
remember_me: str = ""
class ForgotPasswordForm(BaseModel):
email: str = Field(min_length=1, max_length=255)
@field_validator("email")
@classmethod
def email_has_at(cls, value):
if "@" not in value:
raise ValueError("Valid email is required")
return value
class ResetPasswordForm(BaseModel):
password: str = Field(min_length=6, max_length=128) password: str = Field(min_length=6, max_length=128)
remember_me: bool = False confirm_password: str = Field(min_length=1, max_length=128)
@model_validator(mode="after")
def passwords_match(self):
if self.password != self.confirm_password:
raise ValueError("Passwords do not match")
return self
class PostCreate(BaseModel): class PostForm(BaseModel):
content: str = Field(min_length=1, max_length=2000) content: str = Field(min_length=10, max_length=2000)
title: Optional[str] = Field(default=None, max_length=500) title: str = Field(default="", max_length=500)
topic: str = Field(pattern=r"^(devlog|showcase|question|rant|fun|random)$") topic: str = "random"
project_uid: Optional[str] = None project_uid: str = ""
attachment_uids: list[str] = []
@field_validator("topic")
@classmethod
def valid_topic(cls, value):
return value if value in TOPICS else "random"
class CommentCreate(BaseModel): class PostEditForm(BaseModel):
content: str = Field(min_length=1, max_length=1000) content: str = Field(min_length=10, max_length=2000)
parent_uid: Optional[str] = None title: str = Field(default="", max_length=500)
topic: str = "random"
@field_validator("topic")
@classmethod
def valid_topic(cls, value):
return value if value in TOPICS else "random"
class ProjectCreate(BaseModel): class CommentForm(BaseModel):
content: str = Field(min_length=3, max_length=1000)
target_uid: str = ""
post_uid: str = ""
target_type: Literal["post", "project", "news", "bug", "gist"] = "post"
parent_uid: str = ""
attachment_uids: list[str] = []
@model_validator(mode="after")
def require_target(self):
if not (self.target_uid or self.post_uid):
raise ValueError("A target is required")
return self
class ProjectForm(BaseModel):
title: str = Field(min_length=1, max_length=200) title: str = Field(min_length=1, max_length=200)
description: str = Field(min_length=1, max_length=5000) description: str = Field(min_length=1, max_length=5000)
release_date: Optional[date] = None release_date: str = ""
demo_date: Optional[date] = None demo_date: str = ""
project_type: str = Field(pattern=r"^(game|game_asset|software|mobile_app|website)$") project_type: Literal["game", "game_asset", "software", "mobile_app", "website"] = "software"
platforms: str = Field(default="", max_length=500) platforms: str = Field(default="", max_length=500)
status: str = Field(default="In Development", max_length=100)
attachment_uids: list[str] = []
class MessageCreate(BaseModel): class MessageForm(BaseModel):
content: str = Field(min_length=1, max_length=2000) content: str = Field(min_length=1, max_length=2000)
receiver_uid: str receiver_uid: str = Field(min_length=1)
attachment_uids: list[str] = []
class ProfileUpdate(BaseModel): class ProfileForm(BaseModel):
bio: Optional[str] = Field(default=None, max_length=500) bio: str = Field(default="", max_length=500)
location: Optional[str] = Field(default=None, max_length=200) location: str = Field(default="", max_length=200)
git_link: Optional[str] = Field(default=None, max_length=500) git_link: str = Field(default="", max_length=500)
website: Optional[str] = Field(default=None, max_length=500) website: str = Field(default="", max_length=500)
class GistForm(BaseModel):
title: str = Field(min_length=1, max_length=200)
description: str = Field(default="", max_length=5000)
source_code: str = Field(min_length=1, max_length=50000)
language: str = Field(default="plaintext", max_length=50)
attachment_uids: list[str] = []
class GistEditForm(BaseModel):
title: str = Field(min_length=1, max_length=200)
description: str = Field(default="", max_length=5000)
source_code: str = Field(min_length=1, max_length=50000)
language: str = Field(default="plaintext", max_length=50)
class BugForm(BaseModel):
title: str = Field(min_length=1, max_length=200)
description: str = Field(min_length=1, max_length=5000)
attachment_uids: list[str] = []
class VoteForm(BaseModel):
value: int
@field_validator("value")
@classmethod
def valid_value(cls, value):
if value not in (1, -1):
raise ValueError("value must be 1 or -1")
return value
class AdminRoleForm(BaseModel):
role: Literal["member", "admin"]
class AdminPasswordForm(BaseModel):
password: str = Field(min_length=6, max_length=128)
class AdminSettingsForm(BaseModel):
site_name: str = Field(default="", max_length=200)
site_description: str = Field(default="", max_length=500)
site_tagline: str = Field(default="", max_length=500)
news_grade_threshold: str = Field(default="", max_length=10)
news_api_url: str = Field(default="", max_length=500)
news_ai_url: str = Field(default="", max_length=500)
news_ai_model: str = Field(default="", max_length=200)
news_ai_key: str = Field(default="", max_length=500)
max_upload_size_mb: str = Field(default="", max_length=10)
allowed_file_types: str = Field(default="", max_length=1000)
max_attachments_per_resource: str = Field(default="", max_length=10)
+28 -32
View File
@@ -1,10 +1,12 @@
import logging import logging
from typing import Annotated
from datetime import datetime from datetime import datetime
from fastapi import APIRouter, Request from fastapi import APIRouter, Request, Form
from devplacepy.models import AdminRoleForm, AdminPasswordForm, AdminSettingsForm
from fastapi.responses import HTMLResponse, RedirectResponse from fastapi.responses import HTMLResponse, RedirectResponse
from devplacepy.database import get_table, db, build_pagination from devplacepy.database import get_table, db, build_pagination, get_post_counts_by_user_uids, get_news_images_by_uids, clear_settings_cache
from devplacepy.templating import templates from devplacepy.templating import templates
from devplacepy.utils import require_admin, hash_password, generate_uid, time_ago from devplacepy.utils import require_admin, hash_password, generate_uid, time_ago, clear_user_cache
from devplacepy.seo import base_seo_context, site_url, website_schema from devplacepy.seo import base_seo_context, site_url, website_schema
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -21,17 +23,17 @@ async def admin_index(request: Request):
async def admin_users(request: Request, page: int = 1): async def admin_users(request: Request, page: int = 1):
admin = require_admin(request) admin = require_admin(request)
users_table = get_table("users") users_table = get_table("users")
total = len(list(users_table.all())) total = users_table.count()
pagination = build_pagination(page, total) pagination = build_pagination(page, total)
offset = (pagination["page"] - 1) * pagination["per_page"] offset = (pagination["page"] - 1) * pagination["per_page"]
page_users = list(users_table.find(order_by=["-created_at"], _limit=pagination["per_page"], _offset=offset)) page_users = list(users_table.find(order_by=["-created_at"], _limit=pagination["per_page"], _offset=offset))
post_counts = get_post_counts_by_user_uids([u["uid"] for u in page_users])
for u in page_users: for u in page_users:
posts_count = len(list(get_table("posts").find(user_uid=u["uid"]))) u["posts_count"] = post_counts.get(u["uid"], 0)
u["posts_count"] = posts_count
base = site_url(request) base = site_url(request)
seo_ctx = base_seo_context( seo_ctx = base_seo_context(
request, request,
title="Users Admin", title="Users - Admin",
description="Manage DevPlace users.", description="Manage DevPlace users.",
breadcrumbs=[ breadcrumbs=[
{"name": "Home", "url": "/feed"}, {"name": "Home", "url": "/feed"},
@@ -40,7 +42,7 @@ async def admin_users(request: Request, page: int = 1):
], ],
schemas=[website_schema(base)], schemas=[website_schema(base)],
) )
return templates.TemplateResponse("admin_users.html", { return templates.TemplateResponse(request, "admin_users.html", {
**seo_ctx, **seo_ctx,
"request": request, "request": request,
"user": admin, "user": admin,
@@ -51,29 +53,23 @@ async def admin_users(request: Request, page: int = 1):
@router.post("/users/{uid}/role") @router.post("/users/{uid}/role")
async def admin_user_role(request: Request, uid: str): async def admin_user_role(request: Request, uid: str, data: Annotated[AdminRoleForm, Form()]):
admin = require_admin(request) admin = require_admin(request)
form = await request.form() role = data.role.capitalize()
role = form.get("role", "").strip().capitalize()
if role not in ("Member", "Admin"):
return RedirectResponse(url="/admin/users", status_code=302)
if uid == admin["uid"]: if uid == admin["uid"]:
return RedirectResponse(url="/admin/users", status_code=302) return RedirectResponse(url="/admin/users", status_code=302)
users = get_table("users") users = get_table("users")
users.update({"uid": uid, "role": role}, ["uid"]) users.update({"uid": uid, "role": role}, ["uid"])
clear_user_cache(uid)
logger.info(f"Admin {admin['username']} set user {uid} role to {role}") logger.info(f"Admin {admin['username']} set user {uid} role to {role}")
return RedirectResponse(url="/admin/users", status_code=302) return RedirectResponse(url="/admin/users", status_code=302)
@router.post("/users/{uid}/password") @router.post("/users/{uid}/password")
async def admin_user_password(request: Request, uid: str): async def admin_user_password(request: Request, uid: str, data: Annotated[AdminPasswordForm, Form()]):
admin = require_admin(request) admin = require_admin(request)
form = await request.form()
password = form.get("password", "")
if len(password) < 6:
return RedirectResponse(url="/admin/users", status_code=302)
users = get_table("users") users = get_table("users")
users.update({"uid": uid, "password_hash": hash_password(password)}, ["uid"]) users.update({"uid": uid, "password_hash": hash_password(data.password)}, ["uid"])
logger.info(f"Admin {admin['username']} changed password for user {uid}") logger.info(f"Admin {admin['username']} changed password for user {uid}")
return RedirectResponse(url="/admin/users", status_code=302) return RedirectResponse(url="/admin/users", status_code=302)
@@ -88,6 +84,7 @@ async def admin_user_toggle(request: Request, uid: str):
if user: if user:
new_state = not user.get("is_active", True) new_state = not user.get("is_active", True)
users.update({"uid": uid, "is_active": new_state}, ["uid"]) users.update({"uid": uid, "is_active": new_state}, ["uid"])
clear_user_cache(uid)
logger.info(f"Admin {admin['username']} {'disabled' if not new_state else 'enabled'} user {uid}") logger.info(f"Admin {admin['username']} {'disabled' if not new_state else 'enabled'} user {uid}")
return RedirectResponse(url="/admin/users", status_code=302) return RedirectResponse(url="/admin/users", status_code=302)
@@ -100,7 +97,7 @@ async def admin_settings(request: Request):
base = site_url(request) base = site_url(request)
seo_ctx = base_seo_context( seo_ctx = base_seo_context(
request, request,
title="Settings Admin", title="Settings - Admin",
description="Manage DevPlace site settings.", description="Manage DevPlace site settings.",
breadcrumbs=[ breadcrumbs=[
{"name": "Home", "url": "/feed"}, {"name": "Home", "url": "/feed"},
@@ -109,7 +106,7 @@ async def admin_settings(request: Request):
], ],
schemas=[website_schema(base)], schemas=[website_schema(base)],
) )
return templates.TemplateResponse("admin_settings.html", { return templates.TemplateResponse(request, "admin_settings.html", {
**seo_ctx, **seo_ctx,
"request": request, "request": request,
"user": admin, "user": admin,
@@ -122,29 +119,26 @@ async def admin_settings(request: Request):
async def admin_news(request: Request, page: int = 1): async def admin_news(request: Request, page: int = 1):
admin = require_admin(request) admin = require_admin(request)
news_table = get_table("news") news_table = get_table("news")
total = len(list(news_table.all())) total = news_table.count()
pagination = build_pagination(page, total) pagination = build_pagination(page, total)
offset = (pagination["page"] - 1) * pagination["per_page"] offset = (pagination["page"] - 1) * pagination["per_page"]
page_articles = list(news_table.find(order_by=["-synced_at"], _limit=pagination["per_page"], _offset=offset)) page_articles = list(news_table.find(order_by=["-synced_at"], _limit=pagination["per_page"], _offset=offset))
images_table = get_table("news_images") images_by_news = get_news_images_by_uids([a["uid"] for a in page_articles])
enriched = [] enriched = []
for a in page_articles: for a in page_articles:
has_image = False
if "news_images" in db.tables:
has_image = images_table.find_one(news_uid=a["uid"]) is not None
enriched.append({ enriched.append({
"article": a, "article": a,
"time_ago": time_ago(a["synced_at"]), "time_ago": time_ago(a["synced_at"]),
"synced_at": a.get("synced_at", ""), "synced_at": a.get("synced_at", ""),
"grade": a.get("grade", 0), "grade": a.get("grade", 0),
"has_image": has_image, "has_image": a["uid"] in images_by_news,
}) })
base = site_url(request) base = site_url(request)
seo_ctx = base_seo_context( seo_ctx = base_seo_context(
request, request,
title="News Admin", title="News - Admin",
description="Manage DevPlace news articles.", description="Manage DevPlace news articles.",
breadcrumbs=[ breadcrumbs=[
{"name": "Home", "url": "/feed"}, {"name": "Home", "url": "/feed"},
@@ -153,7 +147,7 @@ async def admin_news(request: Request, page: int = 1):
], ],
schemas=[website_schema(base)], schemas=[website_schema(base)],
) )
return templates.TemplateResponse("admin_news.html", { return templates.TemplateResponse(request, "admin_news.html", {
**seo_ctx, **seo_ctx,
"request": request, "request": request,
"user": admin, "user": admin,
@@ -215,15 +209,17 @@ async def admin_news_delete(request: Request, uid: str):
@router.post("/settings") @router.post("/settings")
async def admin_settings_save(request: Request): async def admin_settings_save(request: Request, data: Annotated[AdminSettingsForm, Form()]):
admin = require_admin(request) admin = require_admin(request)
form = await request.form()
settings = get_table("site_settings") settings = get_table("site_settings")
for key, value in form.multi_items(): for key, value in data.model_dump().items():
existing = settings.find_one(key=key) existing = settings.find_one(key=key)
if existing: if existing:
if value == "":
continue
settings.update({"id": existing["id"], "key": key, "value": value}, ["id"]) settings.update({"id": existing["id"], "key": key, "value": value}, ["id"])
else: else:
settings.insert({"uid": generate_uid(), "key": key, "value": value}) settings.insert({"uid": generate_uid(), "key": key, "value": value})
clear_settings_cache()
logger.info(f"Admin {admin['username']} updated settings") logger.info(f"Admin {admin['username']} updated settings")
return RedirectResponse(url="/admin/settings", status_code=302) return RedirectResponse(url="/admin/settings", status_code=302)
+39 -68
View File
@@ -1,13 +1,15 @@
import hashlib import hashlib
import secrets import secrets
import logging import logging
from datetime import datetime, timedelta from typing import Annotated
from fastapi import APIRouter, Request from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Request, Form
from fastapi.responses import RedirectResponse, HTMLResponse from fastapi.responses import RedirectResponse, HTMLResponse
from devplacepy.database import get_table from devplacepy.database import get_table
from devplacepy.templating import templates from devplacepy.templating import templates
from devplacepy.utils import hash_password, verify_password, create_session, generate_uid, get_current_user from devplacepy.utils import hash_password, verify_password, create_session, generate_uid, get_current_user
from devplacepy.seo import base_seo_context from devplacepy.seo import base_seo_context
from devplacepy.models import SignupForm, LoginForm, ForgotPasswordForm, ResetPasswordForm
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter() router = APIRouter()
@@ -24,7 +26,7 @@ async def signup_page(request: Request):
description="Create your DevPlace account and start connecting with developers.", description="Create your DevPlace account and start connecting with developers.",
robots="noindex,nofollow", robots="noindex,nofollow",
) )
return templates.TemplateResponse("signup.html", {**seo_ctx, "request": request}) return templates.TemplateResponse(request, "signup.html", {**seo_ctx, "request": request})
@router.get("/login", response_class=HTMLResponse) @router.get("/login", response_class=HTMLResponse)
@@ -38,29 +40,16 @@ async def login_page(request: Request):
description="Sign in to DevPlace to connect with developers.", description="Sign in to DevPlace to connect with developers.",
robots="noindex,nofollow", robots="noindex,nofollow",
) )
return templates.TemplateResponse("login.html", {**seo_ctx, "request": request}) return templates.TemplateResponse(request, "login.html", {**seo_ctx, "request": request})
@router.post("/signup") @router.post("/signup")
async def signup(request: Request): async def signup(request: Request, data: Annotated[SignupForm, Form()]):
form = await request.form() username = data.username
username = form.get("username", "").strip() email = data.email.strip().lower()
email = form.get("email", "").strip().lower() password = data.password
password = form.get("password", "")
confirm_password = form.get("confirm_password", "")
errors = [] errors = []
if len(username) < 3 or len(username) > 32:
errors.append("Username must be between 3 and 32 characters")
if not username.isascii() or not all(c.isalnum() or c in ("-", "_") for c in username):
errors.append("Username can only contain letters, numbers, hyphens, and underscores")
if not email or "@" not in email or len(email) > 255:
errors.append("Valid email is required")
if len(password) < 6:
errors.append("Password must be at least 6 characters")
if password != confirm_password:
errors.append("Passwords do not match")
users = get_table("users") users = get_table("users")
if users.find_one(username=username): if users.find_one(username=username):
errors.append("Username already taken") errors.append("Username already taken")
@@ -70,12 +59,12 @@ async def signup(request: Request):
if errors: if errors:
seo_ctx = base_seo_context(request, title="Join DevPlace", robots="noindex,nofollow") seo_ctx = base_seo_context(request, title="Join DevPlace", robots="noindex,nofollow")
return templates.TemplateResponse( return templates.TemplateResponse(
"signup.html", request, "signup.html",
{**seo_ctx, "request": request, "errors": errors, "username": username, "email": email}, {**seo_ctx, "request": request, "errors": errors, "username": username, "email": email},
) )
uid = generate_uid() uid = generate_uid()
is_first = len(list(users.all())) == 0 is_first = users.count() == 0
users.insert({ users.insert({
"uid": uid, "uid": uid,
"username": username, "username": username,
@@ -90,7 +79,7 @@ async def signup(request: Request):
"level": 1, "level": 1,
"xp": 0, "xp": 0,
"stars": 0, "stars": 0,
"created_at": datetime.utcnow().isoformat(), "created_at": datetime.now(timezone.utc).isoformat(),
}) })
badges = get_table("badges") badges = get_table("badges")
@@ -98,7 +87,7 @@ async def signup(request: Request):
"uid": generate_uid(), "uid": generate_uid(),
"user_uid": uid, "user_uid": uid,
"badge_name": "Member", "badge_name": "Member",
"created_at": datetime.utcnow().isoformat(), "created_at": datetime.now(timezone.utc).isoformat(),
}) })
token = create_session(uid) token = create_session(uid)
@@ -109,18 +98,14 @@ async def signup(request: Request):
@router.post("/login") @router.post("/login")
async def login(request: Request): async def login(request: Request, data: Annotated[LoginForm, Form()]):
form = await request.form() email = data.email.strip().lower()
email = form.get("email", "").strip().lower() password = data.password
password = form.get("password", "") remember_me = data.remember_me == "on"
remember_me = form.get("remember_me") == "on"
errors = [] errors = []
if not email or not password:
errors.append("Email and password are required")
users = get_table("users") users = get_table("users")
user = users.find_one(email=email) if not errors else None user = users.find_one(email=email)
if not user or not verify_password(password, user["password_hash"]): if not user or not verify_password(password, user["password_hash"]):
errors.append("Invalid email or password") errors.append("Invalid email or password")
@@ -128,7 +113,7 @@ async def login(request: Request):
if errors: if errors:
seo_ctx = base_seo_context(request, title="Sign In", robots="noindex,nofollow") seo_ctx = base_seo_context(request, title="Sign In", robots="noindex,nofollow")
return templates.TemplateResponse( return templates.TemplateResponse(
"login.html", request, "login.html",
{**seo_ctx, "request": request, "errors": errors, "email": email}, {**seo_ctx, "request": request, "errors": errors, "email": email},
) )
@@ -147,22 +132,13 @@ async def forgot_password_page(request: Request):
title="Reset Password", title="Reset Password",
robots="noindex,nofollow", robots="noindex,nofollow",
) )
return templates.TemplateResponse("forgot_password.html", {**seo_ctx, "request": request}) return templates.TemplateResponse(request, "forgot_password.html", {**seo_ctx, "request": request})
@router.post("/forgot-password") @router.post("/forgot-password")
async def forgot_password(request: Request): async def forgot_password(request: Request, data: Annotated[ForgotPasswordForm, Form()]):
form = await request.form() email = data.email.strip().lower()
email = form.get("email", "").strip().lower()
errors = []
if not email or "@" not in email:
errors.append("Valid email is required")
seo_ctx = base_seo_context(request, title="Reset Password", robots="noindex,nofollow") seo_ctx = base_seo_context(request, title="Reset Password", robots="noindex,nofollow")
if errors:
return templates.TemplateResponse("forgot_password.html", {
**seo_ctx, "request": request, "errors": errors,
})
users = get_table("users") users = get_table("users")
user = users.find_one(email=email) user = users.find_one(email=email)
@@ -174,13 +150,13 @@ async def forgot_password(request: Request):
"uid": generate_uid(), "uid": generate_uid(),
"user_uid": user["uid"], "user_uid": user["uid"],
"token": token_hash, "token": token_hash,
"expires_at": (datetime.utcnow() + timedelta(hours=1)).isoformat(), "expires_at": (datetime.now(timezone.utc) + timedelta(hours=1)).isoformat(),
"used": False, "used": False,
"created_at": datetime.utcnow().isoformat(), "created_at": datetime.now(timezone.utc).isoformat(),
}) })
logger.info(f"Password reset requested for {email}") logger.info(f"Password reset requested for {email}")
return templates.TemplateResponse("forgot_password.html", { return templates.TemplateResponse(request, "forgot_password.html", {
**seo_ctx, "request": request, "sent": True, **seo_ctx, "request": request, "sent": True,
}) })
@@ -192,37 +168,32 @@ async def reset_password_page(request: Request, token: str):
title="Set New Password", title="Set New Password",
robots="noindex,nofollow", robots="noindex,nofollow",
) )
return templates.TemplateResponse("reset_password.html", { return templates.TemplateResponse(request, "reset_password.html", {
**seo_ctx, "request": request, "token": token, **seo_ctx, "request": request, "token": token,
}) })
@router.post("/reset-password/{token}") @router.post("/reset-password/{token}")
async def reset_password(request: Request, token: str): async def reset_password(request: Request, token: str, data: Annotated[ResetPasswordForm, Form()]):
form = await request.form() password = data.password
password = form.get("password", "")
confirm = form.get("confirm_password", "")
errors = [] errors = []
if len(password) < 6:
errors.append("Password must be at least 6 characters")
if password != confirm:
errors.append("Passwords do not match")
if errors:
seo_ctx = base_seo_context(request, title="Set New Password", robots="noindex,nofollow")
return templates.TemplateResponse("reset_password.html", {
**seo_ctx, "request": request, "token": token, "errors": errors,
})
resets = get_table("password_resets") resets = get_table("password_resets")
users = get_table("users") users = get_table("users")
token_hash = hashlib.sha256(token.encode()).hexdigest() token_hash = hashlib.sha256(token.encode()).hexdigest()
matched = resets.find_one(token=token_hash, used=False) matched = resets.find_one(token=token_hash, used=False)
if not matched or datetime.fromisoformat(matched["expires_at"]) < datetime.utcnow(): if not matched:
errors.append("Invalid or expired reset token") errors.append("Invalid or expired reset token")
return templates.TemplateResponse("reset_password.html", { return templates.TemplateResponse(request, "reset_password.html", {
"request": request, "token": token, "errors": errors,
})
expires = datetime.fromisoformat(matched["expires_at"])
if expires.tzinfo is None:
expires = expires.replace(tzinfo=timezone.utc)
if expires < datetime.now(timezone.utc):
errors.append("Invalid or expired reset token")
return templates.TemplateResponse(request, "reset_password.html", {
"request": request, "token": token, "errors": errors, "request": request, "token": token, "errors": errors,
}) })
+14 -6
View File
@@ -1,20 +1,28 @@
import hashlib
import logging import logging
from fastapi import APIRouter, Request from fastapi import APIRouter, Request
from fastapi.responses import Response from fastapi.responses import Response
from devplacepy.avatar import generate_avatar_svg from devplacepy.avatar import generate_avatar_svg
from devplacepy.cache import TTLCache
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter() router = APIRouter()
_cache = {} _cache = TTLCache(ttl=86400, max_size=4096)
_CACHE_CONTROL = "public, max-age=86400, immutable"
@router.get("/{style}/{seed}") @router.get("/{style}/{seed}")
async def avatar_proxy(request: Request, style: str, seed: str, size: int = 128): async def avatar_proxy(request: Request, style: str, seed: str, size: int = 128):
cache_key = f"{seed}:{size}" cache_key = f"{seed}:{size}"
if cache_key in _cache: etag = '"' + hashlib.md5(cache_key.encode("utf-8")).hexdigest() + '"'
return Response(content=_cache[cache_key], media_type="image/svg+xml") headers = {"ETag": etag, "Cache-Control": _CACHE_CONTROL}
svg = generate_avatar_svg(seed) if request.headers.get("if-none-match") == etag:
_cache[cache_key] = svg return Response(status_code=304, headers=headers)
return Response(content=svg, media_type="image/svg+xml")
svg = _cache.get(cache_key)
if svg is None:
svg = generate_avatar_svg(seed)
_cache.set(cache_key, svg)
return Response(content=svg, media_type="image/svg+xml", headers=headers)
+14 -14
View File
@@ -1,8 +1,11 @@
import logging import logging
from datetime import datetime from typing import Annotated
from fastapi import APIRouter, Request from datetime import datetime, timezone
from fastapi import APIRouter, Request, Form
from fastapi.responses import HTMLResponse, RedirectResponse from fastapi.responses import HTMLResponse, RedirectResponse
from devplacepy.database import get_table, load_comments, get_attachments_by_type from devplacepy.models import BugForm
from devplacepy.database import get_table, load_comments
from devplacepy.attachments import get_attachments_batch, link_attachments
from devplacepy.templating import templates from devplacepy.templating import templates
from devplacepy.utils import generate_uid, require_user, time_ago, get_current_user, create_mention_notifications from devplacepy.utils import generate_uid, require_user, time_ago, get_current_user, create_mention_notifications
from devplacepy.seo import base_seo_context from devplacepy.seo import base_seo_context
@@ -23,7 +26,7 @@ async def bugs_page(request: Request):
bug_list = [] bug_list = []
bug_uids = [b["uid"] for b in all_bugs] bug_uids = [b["uid"] for b in all_bugs]
attachments_map = get_attachments_by_type("bug", bug_uids) if bug_uids else {} attachments_map = get_attachments_batch("bug", bug_uids) if bug_uids else {}
for b in all_bugs: for b in all_bugs:
bug_list.append({ bug_list.append({
"bug": b, "bug": b,
@@ -42,7 +45,7 @@ async def bugs_page(request: Request):
{"name": "Bug Reports", "url": "/bugs"}, {"name": "Bug Reports", "url": "/bugs"},
], ],
) )
return templates.TemplateResponse("bugs.html", { return templates.TemplateResponse(request, "bugs.html", {
**seo_ctx, **seo_ctx,
"request": request, "request": request,
"user": user, "user": user,
@@ -51,14 +54,10 @@ async def bugs_page(request: Request):
@router.post("/create") @router.post("/create")
async def create_bug(request: Request): async def create_bug(request: Request, data: Annotated[BugForm, Form()]):
user = require_user(request) user = require_user(request)
form = await request.form() title = data.title.strip()
title = form.get("title", "").strip() description = data.description.strip()
description = form.get("description", "").strip()
if not title or not description:
return RedirectResponse(url="/bugs", status_code=302)
bugs_table = get_table("bug_reports") bugs_table = get_table("bug_reports")
bug_uid = generate_uid() bug_uid = generate_uid()
@@ -68,10 +67,11 @@ async def create_bug(request: Request):
"title": title, "title": title,
"description": description, "description": description,
"status": "open", "status": "open",
"created_at": datetime.utcnow().isoformat(), "created_at": datetime.now(timezone.utc).isoformat(),
}) })
attachment_uids = form.getlist("attachment_uids") if hasattr(form, "getlist") else [] if data.attachment_uids:
link_attachments(data.attachment_uids, "bug", bug_uid)
create_mention_notifications(description, user["uid"], f"/bugs?highlight={bug_uid}") create_mention_notifications(description, user["uid"], f"/bugs?highlight={bug_uid}")
logger.info(f"Bug report created by {user['username']}: {title}") logger.info(f"Bug report created by {user['username']}: {title}")
+21 -25
View File
@@ -1,9 +1,13 @@
import logging import logging
from datetime import datetime from typing import Annotated
from fastapi import APIRouter, Request from datetime import datetime, timezone
from fastapi import APIRouter, Request, Form
from fastapi.responses import RedirectResponse from fastapi.responses import RedirectResponse
from devplacepy.database import get_table, delete_attachments from devplacepy.database import get_table, resolve_by_slug
from devplacepy.attachments import link_attachments, delete_target_attachments
from devplacepy.templating import clear_unread_cache
from devplacepy.utils import generate_uid, require_user, create_mention_notifications from devplacepy.utils import generate_uid, require_user, create_mention_notifications
from devplacepy.models import CommentForm
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter() router = APIRouter()
@@ -30,23 +34,15 @@ def resolve_target_redirect(target_type, target_uid):
@router.post("/create") @router.post("/create")
async def create_comment(request: Request): async def create_comment(request: Request, data: Annotated[CommentForm, Form()]):
user = require_user(request) user = require_user(request)
form = await request.form() content = data.content.strip()
content = form.get("content", "").strip() target_uid = data.target_uid or data.post_uid
target_uid = form.get("target_uid") or form.get("post_uid", "") target_type = data.target_type
target_type = form.get("target_type", "post") parent_uid = data.parent_uid
parent_uid = form.get("parent_uid", "")
redirect_url = resolve_target_redirect(target_type, target_uid) redirect_url = resolve_target_redirect(target_type, target_uid)
if not content or not target_uid:
return RedirectResponse(url=redirect_url, status_code=302)
if len(content) < 3:
return RedirectResponse(url=redirect_url, status_code=302)
if len(content) > 1000:
return RedirectResponse(url=redirect_url, status_code=302)
comment_uid = generate_uid() comment_uid = generate_uid()
insert = { insert = {
"uid": comment_uid, "uid": comment_uid,
@@ -55,16 +51,14 @@ async def create_comment(request: Request):
"user_uid": user["uid"], "user_uid": user["uid"],
"content": content, "content": content,
"parent_uid": parent_uid or None, "parent_uid": parent_uid or None,
"created_at": datetime.utcnow().isoformat(), "created_at": datetime.now(timezone.utc).isoformat(),
} }
if target_type == "post": if target_type == "post":
insert["post_uid"] = target_uid insert["post_uid"] = target_uid
get_table("comments").insert(insert) get_table("comments").insert(insert)
attachment_uids = form.getlist("attachment_uids") if hasattr(form, "getlist") else [] if data.attachment_uids:
for auid in attachment_uids: link_attachments(data.attachment_uids, "comment", comment_uid)
if auid.strip():
get_table("attachments").update({"uid": auid.strip(), "resource_uid": insert["uid"], "resource_type": "comment"}, ["uid"])
badges = get_table("badges") badges = get_table("badges")
existing = badges.find_one(user_uid=user["uid"], badge_name="First Comment") existing = badges.find_one(user_uid=user["uid"], badge_name="First Comment")
@@ -73,7 +67,7 @@ async def create_comment(request: Request):
"uid": generate_uid(), "uid": generate_uid(),
"user_uid": user["uid"], "user_uid": user["uid"],
"badge_name": "First Comment", "badge_name": "First Comment",
"created_at": datetime.utcnow().isoformat(), "created_at": datetime.now(timezone.utc).isoformat(),
}) })
if target_type == "post": if target_type == "post":
@@ -89,8 +83,9 @@ async def create_comment(request: Request):
"message": f"{user['username']} replied to your comment", "message": f"{user['username']} replied to your comment",
"related_uid": user["uid"], "related_uid": user["uid"],
"read": False, "read": False,
"created_at": datetime.utcnow().isoformat(), "created_at": datetime.now(timezone.utc).isoformat(),
}) })
clear_unread_cache(parent["user_uid"])
else: else:
posts = get_table("posts") posts = get_table("posts")
post = posts.find_one(uid=target_uid) post = posts.find_one(uid=target_uid)
@@ -105,8 +100,9 @@ async def create_comment(request: Request):
"message": f"{user['username']} commented on your post", "message": f"{user['username']} commented on your post",
"related_uid": user["uid"], "related_uid": user["uid"],
"read": False, "read": False,
"created_at": datetime.utcnow().isoformat(), "created_at": datetime.now(timezone.utc).isoformat(),
}) })
clear_unread_cache(post["user_uid"])
create_mention_notifications(content, user["uid"], redirect_url) create_mention_notifications(content, user["uid"], redirect_url)
logger.info(f"Comment by {user['username']} on {target_type} {target_uid}") logger.info(f"Comment by {user['username']} on {target_type} {target_uid}")
@@ -124,7 +120,7 @@ async def delete_comment(request: Request, comment_uid: str):
return RedirectResponse(url="/feed", status_code=302) return RedirectResponse(url="/feed", status_code=302)
target_type = comment.get("target_type", "post") target_type = comment.get("target_type", "post")
target_uid = comment.get("target_uid") or comment.get("post_uid", "") target_uid = comment.get("target_uid") or comment.get("post_uid", "")
delete_attachments("comment", comment_uid) delete_target_attachments("comment", comment_uid)
get_table("votes").delete(target_uid=comment_uid, target_type="comment") get_table("votes").delete(target_uid=comment_uid, target_type="comment")
comments.delete(id=comment["id"]) comments.delete(id=comment["id"])
logger.info(f"Comment {comment_uid} deleted by {user['username']}") logger.info(f"Comment {comment_uid} deleted by {user['username']}")
+7 -13
View File
@@ -1,8 +1,7 @@
import logging import logging
from datetime import datetime
from fastapi import APIRouter, Request from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse from fastapi.responses import HTMLResponse
from devplacepy.database import get_table, get_daily_topic, get_users_by_uids, get_comment_counts_by_post_uids from devplacepy.database import get_table, get_daily_topic, get_users_by_uids, get_comment_counts_by_post_uids, get_site_stats
from devplacepy.attachments import get_attachments_batch from devplacepy.attachments import get_attachments_batch
from devplacepy.templating import templates from devplacepy.templating import templates
from devplacepy.utils import get_current_user, time_ago from devplacepy.utils import get_current_user, time_ago
@@ -70,12 +69,7 @@ async def feed_page(request: Request, tab: str = "all", topic: str = None, befor
user = get_current_user(request) user = get_current_user(request)
posts, next_cursor = get_feed_posts(user, tab, topic, before) posts, next_cursor = get_feed_posts(user, tab, topic, before)
users_table = get_table("users") users_table = get_table("users")
total_members = len(list(users_table.all())) stats = get_site_stats()
posts_table = get_table("posts")
today_start = datetime.utcnow().replace(hour=0, minute=0, second=0, microsecond=0).isoformat()
posts_today = len(list(posts_table.find(created_at={">=": today_start})))
total_projects = len(list(get_table("projects").all()))
total_gists = len(list(get_table("gists").all()))
top_authors = list(users_table.find(stars={">": 0}, order_by=["-stars"], _limit=5)) top_authors = list(users_table.find(stars={">": 0}, order_by=["-stars"], _limit=5))
daily_topic = get_daily_topic() daily_topic = get_daily_topic()
@@ -92,17 +86,17 @@ async def feed_page(request: Request, tab: str = "all", topic: str = None, befor
breadcrumbs=[{"name": "Home", "url": "/feed"}, {"name": "Feed", "url": "/feed"}], breadcrumbs=[{"name": "Home", "url": "/feed"}, {"name": "Feed", "url": "/feed"}],
schemas=[website_schema(base)], schemas=[website_schema(base)],
) )
return templates.TemplateResponse("feed.html", { return templates.TemplateResponse(request, "feed.html", {
**seo_ctx, **seo_ctx,
"request": request, "request": request,
"user": user, "user": user,
"posts": posts, "posts": posts,
"current_tab": tab, "current_tab": tab,
"current_topic": topic, "current_topic": topic,
"total_members": total_members, "total_members": stats["total_members"],
"posts_today": posts_today, "posts_today": stats["posts_today"],
"total_projects": total_projects, "total_projects": stats["total_projects"],
"total_gists": total_gists, "total_gists": stats["total_gists"],
"top_authors": top_authors, "top_authors": top_authors,
"daily_topic": daily_topic, "daily_topic": daily_topic,
"next_cursor": next_cursor, "next_cursor": next_cursor,
+5 -3
View File
@@ -1,8 +1,9 @@
import logging import logging
from datetime import datetime from datetime import datetime, timezone
from fastapi import APIRouter, Request from fastapi import APIRouter, Request
from fastapi.responses import RedirectResponse from fastapi.responses import RedirectResponse
from devplacepy.database import get_table from devplacepy.database import get_table
from devplacepy.templating import clear_unread_cache
from devplacepy.utils import generate_uid, require_user from devplacepy.utils import generate_uid, require_user
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -26,7 +27,7 @@ async def follow_user(request: Request, username: str):
"uid": generate_uid(), "uid": generate_uid(),
"follower_uid": user["uid"], "follower_uid": user["uid"],
"following_uid": target["uid"], "following_uid": target["uid"],
"created_at": datetime.utcnow().isoformat(), "created_at": datetime.now(timezone.utc).isoformat(),
}) })
notifications = get_table("notifications") notifications = get_table("notifications")
@@ -37,8 +38,9 @@ async def follow_user(request: Request, username: str):
"message": f"{user['username']} started following you", "message": f"{user['username']} started following you",
"related_uid": user["uid"], "related_uid": user["uid"],
"read": False, "read": False,
"created_at": datetime.utcnow().isoformat(), "created_at": datetime.now(timezone.utc).isoformat(),
}) })
clear_unread_cache(target["uid"])
logger.info(f"{user['username']} followed {username}") logger.info(f"{user['username']} followed {username}")
return RedirectResponse(url=f"/profile/{username}", status_code=302) return RedirectResponse(url=f"/profile/{username}", status_code=302)
+98 -25
View File
@@ -1,11 +1,14 @@
import logging import logging
from datetime import datetime from typing import Annotated
from fastapi import APIRouter, Request, HTTPException from datetime import datetime, timezone
from fastapi import APIRouter, Request, HTTPException, Form
from devplacepy.models import GistForm, GistEditForm
from fastapi.responses import HTMLResponse, RedirectResponse from fastapi.responses import HTMLResponse, RedirectResponse
from devplacepy.database import get_table, load_comments, get_vote_counts, get_attachments, get_attachments_by_type, delete_attachments, resolve_by_slug from devplacepy.database import get_table, load_comments, get_vote_counts, resolve_by_slug, db
from devplacepy.attachments import get_attachments, delete_target_attachments
from devplacepy.templating import templates from devplacepy.templating import templates
from devplacepy.utils import generate_uid, get_current_user, require_user, time_ago, make_combined_slug, create_mention_notifications from devplacepy.utils import generate_uid, get_current_user, require_user, time_ago, make_combined_slug, create_mention_notifications
from devplacepy.seo import base_seo_context, site_url, website_schema, breadcrumb_schema, combine from devplacepy.seo import base_seo_context, site_url, website_schema, breadcrumb_schema, combine, software_source_code_schema
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter() router = APIRouter()
@@ -49,6 +52,33 @@ def get_gists_list(user_uid=None, language=None):
return result return result
@router.get("", response_class=HTMLResponse)
async def gists_page(request: Request, language: str = None, user_uid: str = None):
user = get_current_user(request)
gists_data = get_gists_list(user_uid, language)
total_count = len(gists_data)
base = site_url(request)
seo_ctx = base_seo_context(
request,
title="Gists",
description=f"Browse {total_count} code snippets on DevPlace.",
breadcrumbs=[
{"name": "Home", "url": "/feed"},
{"name": "Gists", "url": "/gists"},
],
schemas=[website_schema(base)],
)
return templates.TemplateResponse(request, "gists.html", {
**seo_ctx,
"request": request,
"user": user,
"gists": gists_data,
"total_count": total_count,
"current_language": language,
"languages": LANGUAGES,
})
@router.get("/{gist_slug}", response_class=HTMLResponse) @router.get("/{gist_slug}", response_class=HTMLResponse)
async def gist_detail(request: Request, gist_slug: str): async def gist_detail(request: Request, gist_slug: str):
user = get_current_user(request) user = get_current_user(request)
@@ -75,14 +105,15 @@ async def gist_detail(request: Request, gist_slug: str):
request, request,
title=gist.get("title", "Gist"), title=gist.get("title", "Gist"),
description=gist.get("description", "")[:160], description=gist.get("description", "")[:160],
og_type="article",
breadcrumbs=[ breadcrumbs=[
{"name": "Home", "url": "/feed"}, {"name": "Home", "url": "/feed"},
{"name": "Gists", "url": "/gists"}, {"name": "Gists", "url": "/gists"},
{"name": gist.get("title", "Gist"), "url": f"/gists/{gist['slug'] or gist['uid']}"}, {"name": gist.get("title", "Gist"), "url": f"/gists/{gist['slug'] or gist['uid']}"},
], ],
schemas=[website_schema(base)], schemas=[website_schema(base), software_source_code_schema(gist, base)],
) )
return templates.TemplateResponse("gist_detail.html", { return templates.TemplateResponse(request, "gist_detail.html", {
**seo_ctx, **seo_ctx,
"request": request, "request": request,
"user": user, "user": user,
@@ -98,24 +129,12 @@ async def gist_detail(request: Request, gist_slug: str):
@router.post("/create") @router.post("/create")
async def create_gist(request: Request): async def create_gist(request: Request, data: Annotated[GistForm, Form()]):
user = require_user(request) user = require_user(request)
form = await request.form() title = data.title.strip()
title = form.get("title", "").strip() description = data.description.strip()
description = form.get("description", "").strip() source_code = data.source_code.strip()
source_code = form.get("source_code", "").strip() language = data.language
language = form.get("language", "plaintext")
if not title:
return RedirectResponse(url="/gists", status_code=302)
if len(title) > 200:
return RedirectResponse(url="/gists", status_code=302)
if not source_code:
return RedirectResponse(url="/gists", status_code=302)
if len(source_code) > 50000:
return RedirectResponse(url="/gists", status_code=302)
if len(description) > 5000:
return RedirectResponse(url="/gists", status_code=302)
valid_languages = {l[0] for l in LANGUAGES} valid_languages = {l[0] for l in LANGUAGES}
if language not in valid_languages: if language not in valid_languages:
@@ -133,7 +152,61 @@ async def create_gist(request: Request):
"source_code": source_code, "source_code": source_code,
"language": language, "language": language,
"stars": 0, "stars": 0,
"created_at": datetime.utcnow().isoformat(), "created_at": datetime.now(timezone.utc).isoformat(),
}) })
attachment_uids = form.getlist("attachment_uids") if hasattr(form, "getlist") else [] from devplacepy.attachments import link_attachments
link_attachments(data.attachment_uids, "gist", uid)
create_mention_notifications(description or "", user["uid"], f"/gists/{gist_slug}")
logger.info(f"Gist {uid} created by {user['username']}")
return RedirectResponse(url=f"/gists/{gist_slug}", status_code=302)
@router.post("/edit/{gist_slug}")
async def edit_gist(request: Request, gist_slug: str, data: Annotated[GistEditForm, Form()]):
user = require_user(request)
gists = get_table("gists")
gist = resolve_by_slug(gists, gist_slug)
if not gist or gist["user_uid"] != user["uid"]:
return RedirectResponse(url="/gists", status_code=302)
title = data.title.strip()
description = data.description.strip()
source_code = data.source_code.strip()
language = data.language
valid_languages = {l[0] for l in LANGUAGES}
if language not in valid_languages:
language = "plaintext"
gists.update({
"uid": gist["uid"],
"title": title,
"description": description or None,
"source_code": source_code,
"language": language,
}, ["uid"])
logger.info(f"Gist {gist['uid']} edited by {user['username']}")
return RedirectResponse(url=f"/gists/{gist['slug'] or gist['uid']}", status_code=302)
@router.post("/delete/{gist_slug}")
async def delete_gist(request: Request, gist_slug: str):
user = require_user(request)
gists = get_table("gists")
gist = resolve_by_slug(gists, gist_slug)
if gist and gist["user_uid"] == user["uid"]:
from devplacepy.attachments import delete_target_attachments
delete_target_attachments("gist", gist["uid"])
if "comments" in db.tables:
for c in get_table("comments").find(target_type="gist", target_uid=gist["uid"]):
delete_target_attachments("comment", c["uid"])
get_table("comments").delete(target_type="gist", target_uid=gist["uid"])
if "votes" in db.tables:
get_table("votes").delete(target_type="gist", target_uid=gist["uid"])
gists.delete(id=gist["id"])
logger.info(f"Gist {gist['uid']} deleted by {user['username']}")
return RedirectResponse(url="/gists", status_code=302)
+42 -23
View File
@@ -1,9 +1,12 @@
import logging import logging
from datetime import datetime from typing import Annotated
from fastapi import APIRouter, Request from datetime import datetime, timezone
from fastapi import APIRouter, Request, Form
from devplacepy.models import MessageForm
from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse
from devplacepy.database import get_table, db, get_attachments_by_type from devplacepy.database import get_table, db
from devplacepy.templating import templates from devplacepy.attachments import get_attachments_batch
from devplacepy.templating import templates, clear_unread_cache
from devplacepy.utils import generate_uid, require_user, time_ago, create_mention_notifications from devplacepy.utils import generate_uid, require_user, time_ago, create_mention_notifications
from devplacepy.seo import base_seo_context from devplacepy.seo import base_seo_context
@@ -65,9 +68,8 @@ def get_conversation_messages(user_uid: str, other_uid: str):
msgs.append(m) msgs.append(m)
msgs.sort(key=lambda m: m["created_at"]) msgs.sort(key=lambda m: m["created_at"])
for msg in msgs: with db:
if msg["receiver_uid"] == user_uid and not msg["read"]: db.query("UPDATE messages SET read = 1 WHERE receiver_uid = :me AND sender_uid = :other AND read = 0", me=user_uid, other=other_uid)
messages_table.update({"id": msg["id"], "read": True}, ["id"])
from devplacepy.database import get_users_by_uids from devplacepy.database import get_users_by_uids
user_ids = list({m["sender_uid"] for m in msgs} | {other_uid}) user_ids = list({m["sender_uid"] for m in msgs} | {other_uid})
@@ -76,7 +78,7 @@ def get_conversation_messages(user_uid: str, other_uid: str):
result = [] result = []
msg_uids = [m["uid"] for m in msgs] msg_uids = [m["uid"] for m in msgs]
attachments_map = get_attachments_by_type("message", msg_uids) if msg_uids else {} attachments_map = get_attachments_batch("message", msg_uids) if msg_uids else {}
for m in msgs: for m in msgs:
result.append({ result.append({
"message": m, "message": m,
@@ -115,7 +117,7 @@ async def messages_page(request: Request, with_uid: str = None, search: str = ""
{"name": "Messages", "url": "/messages"}, {"name": "Messages", "url": "/messages"},
], ],
) )
return templates.TemplateResponse("messages.html", { return templates.TemplateResponse(request, "messages.html", {
**seo_ctx, **seo_ctx,
"request": request, "request": request,
"user": user, "user": user,
@@ -144,22 +146,39 @@ async def search_users(request: Request, q: str = ""):
@router.post("/send") @router.post("/send")
async def send_message(request: Request): async def send_message(request: Request, data: Annotated[MessageForm, Form()]):
user = require_user(request) user = require_user(request)
form = await request.form() content = data.content.strip()
content = form.get("content", "").strip() receiver_uid = data.receiver_uid
receiver_uid = form.get("receiver_uid", "")
if content and receiver_uid: messages_table = get_table("messages")
messages_table = get_table("messages") msg_uid = generate_uid()
msg_uid = generate_uid() messages_table.insert({
messages_table.insert({ "uid": msg_uid,
"uid": msg_uid, "sender_uid": user["uid"],
"sender_uid": user["uid"], "receiver_uid": receiver_uid,
"receiver_uid": receiver_uid, "content": content,
"content": content, "read": False,
"created_at": datetime.now(timezone.utc).isoformat(),
})
from devplacepy.attachments import link_attachments
link_attachments(data.attachment_uids, "message", msg_uid)
if user["uid"] != receiver_uid:
notifications = get_table("notifications")
notifications.insert({
"uid": generate_uid(),
"user_uid": receiver_uid,
"type": "message",
"message": f"{user['username']} sent you a message",
"related_uid": user["uid"],
"read": False, "read": False,
"created_at": datetime.utcnow().isoformat(), "created_at": datetime.now(timezone.utc).isoformat(),
}) })
clear_unread_cache(receiver_uid)
attachment_uids = form.getlist("attachment_uids") if hasattr(form, "getlist") else [] create_mention_notifications(content, user["uid"], f"/messages?with_uid={receiver_uid}")
logger.info(f"Message {msg_uid} sent from {user['username']} to {receiver_uid}")
return RedirectResponse(url=f"/messages?with_uid={receiver_uid}", status_code=302)
+10 -15
View File
@@ -1,11 +1,11 @@
import logging import logging
from datetime import datetime, timedelta from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Request from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse from fastapi.responses import HTMLResponse
from devplacepy.database import get_table, db, load_comments, resolve_by_slug from devplacepy.database import get_table, db, load_comments, resolve_by_slug, get_news_images_by_uids
from devplacepy.templating import templates from devplacepy.templating import templates
from devplacepy.utils import get_current_user, time_ago from devplacepy.utils import get_current_user, time_ago
from devplacepy.seo import base_seo_context, website_schema, site_url, discussion_forum_posting, combine from devplacepy.seo import base_seo_context, website_schema, site_url, discussion_forum_posting, combine, news_article_schema
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter() router = APIRouter()
@@ -16,7 +16,7 @@ NEWS_MAX_AGE_DAYS = 4
@router.get("", response_class=HTMLResponse) @router.get("", response_class=HTMLResponse)
async def news_page(request: Request): async def news_page(request: Request):
user = get_current_user(request) user = get_current_user(request)
cutoff = (datetime.utcnow() - timedelta(days=NEWS_MAX_AGE_DAYS)).isoformat() cutoff = (datetime.now(timezone.utc) - timedelta(days=NEWS_MAX_AGE_DAYS)).isoformat()
news_table = get_table("news") news_table = get_table("news")
articles = list(news_table.find( articles = list(news_table.find(
@@ -26,14 +26,7 @@ async def news_page(request: Request):
)) ))
article_uids = [a["uid"] for a in articles] article_uids = [a["uid"] for a in articles]
images_by_news = get_news_images_by_uids(article_uids)
images_by_news = {}
if article_uids and "news_images" in db.tables:
images_table = get_table("news_images")
for uid in article_uids:
img = images_table.find_one(news_uid=uid, order_by=["uid"])
if img:
images_by_news[uid] = img["url"]
enriched = [] enriched = []
for a in articles: for a in articles:
@@ -53,7 +46,7 @@ async def news_page(request: Request):
schemas=[website_schema(base)], schemas=[website_schema(base)],
) )
return templates.TemplateResponse("news.html", { return templates.TemplateResponse(request, "news.html", {
**seo_ctx, **seo_ctx,
"request": request, "request": request,
"user": user, "user": user,
@@ -84,11 +77,13 @@ async def news_detail_page(request: Request, news_slug: str):
request, request,
title=article.get("title", "News Article"), title=article.get("title", "News Article"),
description=(article.get("description", "") or "")[:200], description=(article.get("description", "") or "")[:200],
og_type="article",
og_image=image_url or None,
breadcrumbs=[{"name": "Home", "url": "/feed"}, {"name": "News", "url": "/news"}, {"name": article.get("title", "")[:60], "url": page_url}], breadcrumbs=[{"name": "Home", "url": "/feed"}, {"name": "News", "url": "/news"}, {"name": article.get("title", "")[:60], "url": page_url}],
schemas=[website_schema(base)], schemas=[website_schema(base), news_article_schema(article, base, image_url)],
) )
return templates.TemplateResponse("news_detail.html", { return templates.TemplateResponse(request, "news_detail.html", {
**seo_ctx, **seo_ctx,
"request": request, "request": request,
"user": user, "user": user,
+9 -8
View File
@@ -1,9 +1,9 @@
import logging import logging
from datetime import datetime, timedelta from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Request from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse, RedirectResponse from fastapi.responses import HTMLResponse, RedirectResponse
from devplacepy.database import get_table from devplacepy.database import get_table, db
from devplacepy.templating import templates from devplacepy.templating import templates, clear_unread_cache
from devplacepy.utils import require_user, time_ago from devplacepy.utils import require_user, time_ago
from devplacepy.seo import base_seo_context from devplacepy.seo import base_seo_context
@@ -14,7 +14,7 @@ router = APIRouter()
def _group_label(created_at: str) -> str: def _group_label(created_at: str) -> str:
try: try:
dt = datetime.fromisoformat(created_at) dt = datetime.fromisoformat(created_at)
now = datetime.utcnow() now = datetime.now(timezone.utc)
today = now.date() today = now.date()
date = dt.date() date = dt.date()
if date == today: if date == today:
@@ -78,7 +78,7 @@ async def notifications_page(request: Request):
{"name": "Notifications", "url": "/notifications"}, {"name": "Notifications", "url": "/notifications"},
], ],
) )
return templates.TemplateResponse("notifications.html", { return templates.TemplateResponse(request, "notifications.html", {
**seo_ctx, **seo_ctx,
"request": request, "request": request,
"user": user, "user": user,
@@ -93,13 +93,14 @@ async def mark_read(request: Request, notification_uid: str):
n = notifications_table.find_one(uid=notification_uid) n = notifications_table.find_one(uid=notification_uid)
if n and n["user_uid"] == user["uid"]: if n and n["user_uid"] == user["uid"]:
notifications_table.update({"id": n["id"], "read": True}, ["id"]) notifications_table.update({"id": n["id"], "read": True}, ["id"])
clear_unread_cache(user["uid"])
return RedirectResponse(url="/notifications", status_code=302) return RedirectResponse(url="/notifications", status_code=302)
@router.post("/mark-all-read") @router.post("/mark-all-read")
async def mark_all_read(request: Request): async def mark_all_read(request: Request):
user = require_user(request) user = require_user(request)
notifications_table = get_table("notifications") with db:
for n in notifications_table.find(user_uid=user["uid"], read=False): db.query("UPDATE notifications SET read = 1 WHERE user_uid = :u AND read = 0", u=user["uid"])
notifications_table.update({"id": n["id"], "read": True}, ["id"]) clear_unread_cache(user["uid"])
return RedirectResponse(url="/notifications", status_code=302) return RedirectResponse(url="/notifications", status_code=302)
+27 -84
View File
@@ -1,65 +1,35 @@
import logging import logging
import aiofiles from typing import Annotated
from datetime import datetime from datetime import datetime, timezone
from pathlib import Path from fastapi import APIRouter, Request, HTTPException, Form
from fastapi import APIRouter, Request, HTTPException
from fastapi.responses import RedirectResponse, HTMLResponse from fastapi.responses import RedirectResponse, HTMLResponse
from devplacepy.database import get_table, get_comment_counts_by_post_uids, load_comments, db from devplacepy.constants import TOPICS
from devplacepy.database import get_table, get_comment_counts_by_post_uids, load_comments, db, resolve_by_slug
from devplacepy.templating import templates from devplacepy.templating import templates
from devplacepy.config import STATIC_DIR
from devplacepy.utils import generate_uid, get_current_user, require_user, time_ago, make_combined_slug, create_mention_notifications from devplacepy.utils import generate_uid, get_current_user, require_user, time_ago, make_combined_slug, create_mention_notifications
from devplacepy.seo import base_seo_context, site_url, website_schema, breadcrumb_schema, discussion_forum_posting, combine, truncate from devplacepy.seo import base_seo_context, site_url, website_schema, breadcrumb_schema, discussion_forum_posting, combine, truncate
from devplacepy.attachments import get_attachments, link_attachments, delete_target_attachments, save_inline_image, delete_inline_image
from devplacepy.models import PostForm, PostEditForm
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter() router = APIRouter()
@router.post("/create") @router.post("/create")
async def create_post(request: Request): async def create_post(request: Request, data: Annotated[PostForm, Form()]):
user = require_user(request) user = require_user(request)
form = await request.form() content = data.content.strip()
content = form.get("content", "").strip() title = data.title.strip()
title = form.get("title", "").strip() topic = data.topic
topic = form.get("topic", "random") project_uid = data.project_uid
project_uid = form.get("project_uid", "")
errors = []
if not content:
errors.append("Content is required")
if content and len(content) < 10:
errors.append("Content must be at least 10 characters")
if len(content) > 2000:
errors.append("Content too long (max 2000 characters)")
if topic not in TOPICS:
topic = "random"
if errors:
return RedirectResponse(url="/feed", status_code=302)
image_filename = None image_filename = None
form = await request.form()
image_file = form.get("image") image_file = form.get("image")
if image_file and hasattr(image_file, "filename") and image_file.filename: if image_file is not None and hasattr(image_file, "filename") and image_file.filename:
try: image_filename = save_inline_image(await image_file.read(), image_file.filename)
content_bytes = await image_file.read() if image_filename:
if len(content_bytes) > 5 * 1024 * 1024: content += f"\n\n![](/static/uploads/{image_filename})"
logger.warning(f"Image too large: {image_file.filename}")
else:
import imghdr
ext = Path(image_file.filename).suffix.lower()
allowed = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg"}
if ext not in allowed:
logger.warning(f"Unsupported image type: {ext}")
else:
upload_dir = STATIC_DIR / "uploads"
upload_dir.mkdir(parents=True, exist_ok=True)
image_filename = f"{generate_uid()}{ext}"
file_path = upload_dir / image_filename
async with aiofiles.open(str(file_path), "wb") as f:
await f.write(content_bytes)
logger.info(f"Image saved: {image_filename}")
content += f"\n\n![](/static/uploads/{image_filename})"
except Exception as e:
logger.warning(f"Image upload failed: {e}")
posts = get_table("posts") posts = get_table("posts")
uid = generate_uid() uid = generate_uid()
@@ -75,7 +45,7 @@ async def create_post(request: Request):
"project_uid": project_uid or None, "project_uid": project_uid or None,
"image": image_filename, "image": image_filename,
"stars": 0, "stars": 0,
"created_at": datetime.utcnow().isoformat(), "created_at": datetime.now(timezone.utc).isoformat(),
}) })
badges = get_table("badges") badges = get_table("badges")
@@ -85,14 +55,11 @@ async def create_post(request: Request):
"uid": generate_uid(), "uid": generate_uid(),
"user_uid": user["uid"], "user_uid": user["uid"],
"badge_name": "First Post", "badge_name": "First Post",
"created_at": datetime.utcnow().isoformat(), "created_at": datetime.now(timezone.utc).isoformat(),
}) })
attachment_uids = form.getlist("attachment_uids") if data.attachment_uids:
if attachment_uids: link_attachments(data.attachment_uids, "post", uid)
link_attachments(attachment_uids, "post", uid)
if attachment_uids:
link_attachments(attachment_uids, "post", uid)
create_mention_notifications(content, user["uid"], f"/posts/{post_slug}") create_mention_notifications(content, user["uid"], f"/posts/{post_slug}")
logger.info(f"Post {uid} created by {user['username']}") logger.info(f"Post {uid} created by {user['username']}")
@@ -151,7 +118,7 @@ async def view_post(request: Request, post_slug: str):
post_attachments = get_attachments("post", post["uid"]) post_attachments = get_attachments("post", post["uid"])
return templates.TemplateResponse("post.html", { return templates.TemplateResponse(request, "post.html", {
**seo_ctx, **seo_ctx,
"request": request, "request": request,
"user": user, "user": user,
@@ -167,36 +134,18 @@ async def view_post(request: Request, post_slug: str):
@router.post("/edit/{post_slug}") @router.post("/edit/{post_slug}")
async def edit_post(request: Request, post_slug: str): async def edit_post(request: Request, post_slug: str, data: Annotated[PostEditForm, Form()]):
user = require_user(request) user = require_user(request)
form = await request.form()
content = form.get("content", "").strip()
title = form.get("title", "").strip()
topic = form.get("topic", "random")
errors = []
if not content:
errors.append("Content is required")
if content and len(content) < 10:
errors.append("Content must be at least 10 characters")
if len(content) > 2000:
errors.append("Content too long (max 2000 characters)")
if topic not in TOPICS:
topic = "random"
posts = get_table("posts") posts = get_table("posts")
post = resolve_by_slug(posts, post_slug) post = resolve_by_slug(posts, post_slug)
if not post or post["user_uid"] != user["uid"]: if not post or post["user_uid"] != user["uid"]:
return RedirectResponse(url="/feed", status_code=302) return RedirectResponse(url="/feed", status_code=302)
if errors:
return RedirectResponse(url=f"/posts/{post['slug'] or post['uid']}", status_code=302)
posts.update({ posts.update({
"uid": post["uid"], "uid": post["uid"],
"content": content, "content": data.content.strip(),
"title": title or None, "title": data.title.strip() or None,
"topic": topic, "topic": data.topic,
}, ["uid"]) }, ["uid"])
logger.info(f"Post {post['uid']} edited by {user['username']}") logger.info(f"Post {post['uid']} edited by {user['username']}")
@@ -212,13 +161,7 @@ async def delete_post(request: Request, post_slug: str):
delete_target_attachments("post", post["uid"]) delete_target_attachments("post", post["uid"])
get_table("comments").delete(post_uid=post["uid"]) get_table("comments").delete(post_uid=post["uid"])
get_table("votes").delete(target_uid=post["uid"]) get_table("votes").delete(target_uid=post["uid"])
image = post.get("image") delete_inline_image(post.get("image"))
if image:
try:
(STATIC_DIR / "uploads" / image).unlink(missing_ok=True)
except Exception as e:
logger.warning(f"Failed to delete image {image}: {e}")
posts.delete(id=post["id"]) posts.delete(id=post["id"])
logger.info(f"Post {post['uid']} deleted by {user['username']}") logger.info(f"Post {post['uid']} deleted by {user['username']}")
return RedirectResponse(url="/feed", status_code=302) return RedirectResponse(url="/feed", status_code=302)
from devplacepy.attachments import get_attachments, link_attachments, delete_target_attachments
+36 -32
View File
@@ -1,15 +1,33 @@
import logging import logging
from fastapi import APIRouter, Request from typing import Annotated
from fastapi import APIRouter, Request, Form
from devplacepy.models import ProfileForm
from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse
from devplacepy.database import get_table, db from devplacepy.database import get_table, db
from devplacepy.templating import templates from devplacepy.templating import templates
from devplacepy.utils import get_current_user, require_user, time_ago from devplacepy.utils import get_current_user, require_user, time_ago, clear_user_cache
from devplacepy.seo import base_seo_context, site_url, website_schema, breadcrumb_schema, profile_page_schema, combine from devplacepy.seo import base_seo_context, site_url, website_schema, breadcrumb_schema, profile_page_schema, combine
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter() router = APIRouter()
@router.get("/search")
async def search_users(request: Request, q: str = ""):
require_user(request)
if not q or len(q) < 1:
return JSONResponse({"results": []})
if "users" in db.tables:
rows = db.query(
"SELECT uid, username FROM users WHERE username LIKE :q LIMIT 10",
q=f"%{q}%",
)
results = [{"uid": r["uid"], "username": r["username"]} for r in rows]
else:
results = []
return JSONResponse({"results": results})
@router.get("/{username}", response_class=HTMLResponse) @router.get("/{username}", response_class=HTMLResponse)
async def profile_page(request: Request, username: str, tab: str = "posts"): async def profile_page(request: Request, username: str, tab: str = "posts"):
current_user = get_current_user(request) current_user = get_current_user(request)
@@ -36,8 +54,11 @@ async def profile_page(request: Request, username: str, tab: str = "posts"):
badges = list(get_table("badges").find(user_uid=profile_user["uid"])) badges = list(get_table("badges").find(user_uid=profile_user["uid"]))
projects = list(get_table("projects").find(user_uid=profile_user["uid"])) projects = list(get_table("projects").find(user_uid=profile_user["uid"]))
gists = list(get_table("gists").find(user_uid=profile_user["uid"])) gists_raw = list(get_table("gists").find(user_uid=profile_user["uid"]))
posts_count = len(posts) or len(list(get_table("posts").find(user_uid=profile_user["uid"]))) gists = []
for g in gists_raw:
gists.append({"gist": g, "time_ago": time_ago(g["created_at"])})
posts_count = len(posts) or get_table("posts").count(user_uid=profile_user["uid"])
activities = [] activities = []
if tab == "activity": if tab == "activity":
@@ -46,7 +67,9 @@ async def profile_page(request: Request, username: str, tab: str = "posts"):
activities.append({"type": "post", "content": p.get("title") or p["content"][:80], "time_ago": time_ago(p["created_at"]), "created_at": p["created_at"], "uid": p["uid"]}) activities.append({"type": "post", "content": p.get("title") or p["content"][:80], "time_ago": time_ago(p["created_at"]), "created_at": p["created_at"], "uid": p["uid"]})
comments_table = get_table("comments") comments_table = get_table("comments")
for c in comments_table.find(user_uid=profile_user["uid"], order_by=["-created_at"], _limit=10): for c in comments_table.find(user_uid=profile_user["uid"], order_by=["-created_at"], _limit=10):
activities.append({"type": "comment", "content": c["content"][:80], "time_ago": time_ago(c["created_at"]), "created_at": c["created_at"], "uid": c["post_uid"]}) target_uid = c.get("target_uid", c.get("post_uid", ""))
target_type = c.get("target_type", "post")
activities.append({"type": "comment", "content": c["content"][:80], "time_ago": time_ago(c["created_at"]), "created_at": c["created_at"], "uid": target_uid, "target_type": target_type})
activities.sort(key=lambda a: a["created_at"], reverse=True) activities.sort(key=lambda a: a["created_at"], reverse=True)
is_following = False is_following = False
@@ -63,6 +86,7 @@ async def profile_page(request: Request, username: str, tab: str = "posts"):
title=f"{profile_user['username']} (@{profile_user['username']})", title=f"{profile_user['username']} (@{profile_user['username']})",
description=desc, description=desc,
robots=robots, robots=robots,
og_type="profile",
breadcrumbs=[ breadcrumbs=[
{"name": "Home", "url": "/feed"}, {"name": "Home", "url": "/feed"},
{"name": profile_user['username'], "url": f"/profile/{profile_user['username']}"}, {"name": profile_user['username'], "url": f"/profile/{profile_user['username']}"},
@@ -73,7 +97,7 @@ async def profile_page(request: Request, username: str, tab: str = "posts"):
], ],
) )
return templates.TemplateResponse("profile.html", { return templates.TemplateResponse(request, "profile.html", {
**seo_ctx, **seo_ctx,
"request": request, "request": request,
"user": current_user, "user": current_user,
@@ -89,38 +113,18 @@ async def profile_page(request: Request, username: str, tab: str = "posts"):
}) })
@router.get("/search")
async def search_users(request: Request, q: str = ""):
require_user(request)
if not q or len(q) < 1:
return JSONResponse({"results": []})
if "users" in db.tables:
rows = db.query(
"SELECT uid, username FROM users WHERE username LIKE :q LIMIT 10",
q=f"%{q}%",
)
results = [{"uid": r["uid"], "username": r["username"]} for r in rows]
else:
results = []
return JSONResponse({"results": results})
@router.post("/update") @router.post("/update")
async def update_profile(request: Request): async def update_profile(request: Request, data: Annotated[ProfileForm, Form()]):
user = require_user(request) user = require_user(request)
form = await request.form()
bio = form.get("bio", "").strip()
location = form.get("location", "").strip()
git_link = form.get("git_link", "").strip()
website = form.get("website", "").strip()
users = get_table("users") users = get_table("users")
users.update({ users.update({
"uid": user["uid"], "uid": user["uid"],
"bio": bio, "bio": data.bio.strip(),
"location": location, "location": data.location.strip(),
"git_link": git_link, "git_link": data.git_link.strip(),
"website": website, "website": data.website.strip(),
}, ["uid"]) }, ["uid"])
clear_user_cache(user["uid"])
logger.info(f"Profile updated for {user['username']}") logger.info(f"Profile updated for {user['username']}")
return RedirectResponse(url=f"/profile/{user['username']}", status_code=302) return RedirectResponse(url=f"/profile/{user['username']}", status_code=302)
+39 -51
View File
@@ -1,8 +1,12 @@
import logging import logging
from datetime import datetime from typing import Annotated
from fastapi import APIRouter, Request, HTTPException from datetime import datetime, timezone
from sqlalchemy import or_
from fastapi import APIRouter, Request, HTTPException, Form
from devplacepy.models import ProjectForm
from fastapi.responses import HTMLResponse, RedirectResponse from fastapi.responses import HTMLResponse, RedirectResponse
from devplacepy.database import get_table, get_vote_counts, load_comments, get_attachments, delete_attachments from devplacepy.database import get_table, get_vote_counts, load_comments, resolve_by_slug, get_users_by_uids, get_site_stats
from devplacepy.attachments import link_attachments, get_attachments, delete_target_attachments
from devplacepy.templating import templates from devplacepy.templating import templates
from devplacepy.utils import generate_uid, get_current_user, require_user, time_ago, make_combined_slug, create_mention_notifications from devplacepy.utils import generate_uid, get_current_user, require_user, time_ago, make_combined_slug, create_mention_notifications
from devplacepy.seo import base_seo_context, site_url, website_schema, breadcrumb_schema, combine, software_application_schema from devplacepy.seo import base_seo_context, site_url, website_schema, breadcrumb_schema, combine, software_application_schema
@@ -13,39 +17,29 @@ router = APIRouter()
def get_projects_list(tab: str = "recent", search: str = "", user_uid: str = None, project_type: str = None): def get_projects_list(tab: str = "recent", search: str = "", user_uid: str = None, project_type: str = None):
projects = get_table("projects") projects = get_table("projects")
all_projects = list(projects.all())
filters = {}
if user_uid: if user_uid:
all_projects = [p for p in all_projects if p["user_uid"] == user_uid] filters["user_uid"] = user_uid
if project_type: if project_type:
all_projects = [p for p in all_projects if p.get("project_type") == project_type] filters["project_type"] = project_type
if tab == "released":
filters["status"] = "Released"
if search: clauses = []
search_lower = search.lower() if search and projects.exists:
all_projects = [ like = f"%{search}%"
p for p in all_projects clauses.append(or_(projects.table.columns.title.ilike(like), projects.table.columns.description.ilike(like)))
if search_lower in p.get("title", "").lower()
or search_lower in p.get("description", "").lower() order = ["-stars", "-created_at"] if tab == "popular" else ["-created_at"]
] all_projects = list(projects.find(*clauses, **filters, order_by=order))
if all_projects: if all_projects:
from devplacepy.database import get_users_by_uids users_map = get_users_by_uids([p["user_uid"] for p in all_projects])
uids = [p["user_uid"] for p in all_projects]
users_map = get_users_by_uids(uids)
for p in all_projects: for p in all_projects:
author = users_map.get(p["user_uid"]) author = users_map.get(p["user_uid"])
p["author_name"] = author["username"] if author else "Unknown" p["author_name"] = author["username"] if author else "Unknown"
if tab == "released":
all_projects = [p for p in all_projects if p.get("status") == "Released"]
elif tab == "popular":
all_projects.sort(key=lambda p: int(p.get("stars", 0)), reverse=True)
elif tab == "new":
all_projects.sort(key=lambda p: p.get("created_at", ""), reverse=True)
else:
all_projects.sort(key=lambda p: p.get("created_at", ""), reverse=True)
return all_projects return all_projects
@@ -59,8 +53,7 @@ async def projects_page(
): ):
user = get_current_user(request) user = get_current_user(request)
projects = get_projects_list(tab, search, user_uid, project_type) projects = get_projects_list(tab, search, user_uid, project_type)
users = get_table("users") total_members = get_site_stats()["total_members"]
total_members = len(list(users.all()))
base = site_url(request) base = site_url(request)
seo_ctx = base_seo_context( seo_ctx = base_seo_context(
@@ -73,7 +66,7 @@ async def projects_page(
], ],
schemas=[website_schema(base)], schemas=[website_schema(base)],
) )
return templates.TemplateResponse("projects.html", { return templates.TemplateResponse(request, "projects.html", {
**seo_ctx, **seo_ctx,
"request": request, "request": request,
"user": user, "user": user,
@@ -94,7 +87,6 @@ async def project_detail(request: Request, project_slug: str):
if not project: if not project:
raise HTTPException(status_code=404, detail="Project not found") raise HTTPException(status_code=404, detail="Project not found")
from devplacepy.database import get_users_by_uids
users_map = get_users_by_uids([project["user_uid"]]) users_map = get_users_by_uids([project["user_uid"]])
author = users_map.get(project["user_uid"]) author = users_map.get(project["user_uid"])
@@ -119,7 +111,7 @@ async def project_detail(request: Request, project_slug: str):
], ],
schemas=[website_schema(base), software_application_schema(project, base)], schemas=[website_schema(base), software_application_schema(project, base)],
) )
return templates.TemplateResponse("project_detail.html", { return templates.TemplateResponse(request, "project_detail.html", {
**seo_ctx, **seo_ctx,
"request": request, "request": request,
"user": user, "user": user,
@@ -139,26 +131,17 @@ async def delete_project(request: Request, project_slug: str):
projects = get_table("projects") projects = get_table("projects")
project = resolve_by_slug(projects, project_slug) project = resolve_by_slug(projects, project_slug)
if project and project["user_uid"] == user["uid"]: if project and project["user_uid"] == user["uid"]:
delete_attachments("project", project["uid"]) delete_target_attachments("project", project["uid"])
projects.delete(id=project["id"]) projects.delete(id=project["id"])
logger.info(f"Project {project['uid']} deleted by {user['username']}") logger.info(f"Project {project['uid']} deleted by {user['username']}")
return RedirectResponse(url="/projects", status_code=302) return RedirectResponse(url="/projects", status_code=302)
@router.post("/create") @router.post("/create")
async def create_project(request: Request): async def create_project(request: Request, data: Annotated[ProjectForm, Form()]):
user = require_user(request) user = require_user(request)
form = await request.form() title = data.title.strip()
title = form.get("title", "").strip() description = data.description.strip()
description = form.get("description", "").strip()
release_date = form.get("release_date", "")
demo_date = form.get("demo_date", "")
project_type = form.get("project_type", "software")
platforms = form.get("platforms", "").strip()
status = form.get("status", "In Development")
if not title or not description:
return RedirectResponse(url="/projects", status_code=302)
projects = get_table("projects") projects = get_table("projects")
uid = generate_uid() uid = generate_uid()
@@ -169,13 +152,18 @@ async def create_project(request: Request):
"title": title, "title": title,
"slug": project_slug, "slug": project_slug,
"description": description, "description": description,
"release_date": release_date or None, "release_date": data.release_date or None,
"demo_date": demo_date or None, "demo_date": data.demo_date or None,
"project_type": project_type, "project_type": data.project_type,
"platforms": platforms, "platforms": data.platforms.strip(),
"status": status, "status": data.status,
"stars": 0, "stars": 0,
"created_at": datetime.utcnow().isoformat(), "created_at": datetime.now(timezone.utc).isoformat(),
}) })
attachment_uids = form.getlist("attachment_uids") if hasattr(form, "getlist") else [] if data.attachment_uids:
link_attachments(data.attachment_uids, "project", uid)
create_mention_notifications(description, user["uid"], f"/projects/{project_slug}")
logger.info(f"Project {uid} created by {user['username']}")
return RedirectResponse(url=f"/projects/{project_slug}", status_code=302)
+6 -4
View File
@@ -1,6 +1,6 @@
import logging import logging
from fastapi import APIRouter, Request from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse, PlainTextResponse from fastapi.responses import PlainTextResponse, Response
from devplacepy.seo import make_sitemap, site_url from devplacepy.seo import make_sitemap, site_url
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -17,16 +17,18 @@ Disallow: /notifications/
Disallow: /votes/ Disallow: /votes/
Disallow: /avatar/ Disallow: /avatar/
Disallow: /follow/ Disallow: /follow/
Disallow: /admin/
Disallow: /uploads/
Disallow: /*?tab= Disallow: /*?tab=
Disallow: /*?sort= Disallow: /*?sort=
Allow: /static/ Allow: /static/
Sitemap: {base}/sitemap.xml Sitemap: {base}/sitemap.xml
""") """, headers={"Cache-Control": "public, max-age=3600"})
@router.get("/sitemap.xml", response_class=HTMLResponse) @router.get("/sitemap.xml")
async def sitemap_xml(request: Request): async def sitemap_xml(request: Request):
base = site_url(request) base = site_url(request)
xml = make_sitemap(base) xml = make_sitemap(base)
return HTMLResponse(content=xml, media_type="application/xml") return Response(content=xml, media_type="application/xml", headers={"Cache-Control": "public, max-age=3600"})
+2 -2
View File
@@ -17,7 +17,7 @@ async def services_page(request: Request):
base = site_url(request) base = site_url(request)
seo_ctx = base_seo_context( seo_ctx = base_seo_context(
request, request,
title="Services Admin", title="Services - Admin",
description="Monitor background services on DevPlace.", description="Monitor background services on DevPlace.",
breadcrumbs=[ breadcrumbs=[
{"name": "Home", "url": "/feed"}, {"name": "Home", "url": "/feed"},
@@ -26,7 +26,7 @@ async def services_page(request: Request):
], ],
schemas=[website_schema(base)], schemas=[website_schema(base)],
) )
return templates.TemplateResponse("services.html", { return templates.TemplateResponse(request, "services.html", {
**seo_ctx, **seo_ctx,
"request": request, "request": request,
"user": admin, "user": admin,
+22 -78
View File
@@ -1,33 +1,14 @@
import hashlib
import logging import logging
import aiofiles
from pathlib import Path from pathlib import Path
from fastapi import APIRouter, Request from fastapi import APIRouter, Request
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from devplacepy.database import get_table, get_setting from devplacepy.database import get_table, get_setting, get_int_setting
from devplacepy.config import STATIC_DIR from devplacepy.utils import require_user
from devplacepy.utils import generate_uid, require_user from devplacepy.attachments import store_attachment, delete_attachment as _delete_attachment, ALLOWED_UPLOAD_TYPES
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter() router = APIRouter()
IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".bmp"}
def _store_file(content: bytes, original_filename: str) -> tuple[str, str]:
uid = generate_uid()
ext = Path(original_filename).suffix.lower() or ""
stored_name = f"{uid}{ext}"
hash_str = hashlib.sha256(stored_name.encode()).hexdigest()
subdir = f"{hash_str[:2]}/{hash_str[2:4]}"
storage_path = f"{subdir}/{stored_name}"
full_dir = STATIC_DIR / "uploads" / subdir
full_dir.mkdir(parents=True, exist_ok=True)
full_path = full_dir / stored_name
with open(str(full_path), "wb") as f:
f.write(content)
return uid, storage_path
@router.post("/upload") @router.post("/upload")
async def upload_file(request: Request): async def upload_file(request: Request):
@@ -38,17 +19,18 @@ async def upload_file(request: Request):
if not file or not hasattr(file, "filename") or not file.filename: if not file or not hasattr(file, "filename") or not file.filename:
return JSONResponse({"error": "No file provided"}, status_code=400) return JSONResponse({"error": "No file provided"}, status_code=400)
max_size_mb = int(get_setting("max_upload_size_mb", "10")) ext = Path(file.filename).suffix.lower()
max_size_bytes = max_size_mb * 1024 * 1024 if ext not in ALLOWED_UPLOAD_TYPES:
return JSONResponse({"error": f"File type '{ext}' not allowed"}, status_code=415)
allowed_types_raw = get_setting("allowed_file_types", "").strip() allowed_types_raw = get_setting("allowed_file_types", "").strip()
allowed_extensions = set() allowed_extensions = set()
if allowed_types_raw: if allowed_types_raw:
for ext in allowed_types_raw.split(","): for allowed_ext in allowed_types_raw.split(","):
ext = ext.strip().lower() allowed_ext = allowed_ext.strip().lower()
if ext.startswith("."): allowed_extensions.add(allowed_ext if allowed_ext.startswith(".") else f".{allowed_ext}")
allowed_extensions.add(ext) if allowed_extensions and ext not in allowed_extensions:
else: return JSONResponse({"error": f"File type '{ext}' not allowed"}, status_code=415)
allowed_extensions.add(f".{ext}")
try: try:
content = await file.read() content = await file.read()
@@ -56,61 +38,23 @@ async def upload_file(request: Request):
logger.warning(f"Failed to read uploaded file: {e}") logger.warning(f"Failed to read uploaded file: {e}")
return JSONResponse({"error": "Failed to read file"}, status_code=400) return JSONResponse({"error": "Failed to read file"}, status_code=400)
if len(content) > max_size_bytes: result = store_attachment(content, file.filename, user["uid"])
if result is None:
max_size_mb = get_int_setting("max_upload_size_mb", 10)
return JSONResponse({"error": f"File exceeds {max_size_mb}MB limit"}, status_code=413) return JSONResponse({"error": f"File exceeds {max_size_mb}MB limit"}, status_code=413)
ext = Path(file.filename).suffix.lower() logger.info(f"File uploaded: {file.filename} ({len(content)} bytes) -> {result['url']}")
if allowed_extensions and ext not in allowed_extensions: return JSONResponse(result, status_code=201)
return JSONResponse({"error": f"File type '{ext}' not allowed"}, status_code=415)
uid, storage_path = _store_file(content, file.filename)
attachments = get_table("attachments")
attachments.insert({
"uid": uid,
"resource_uid": "",
"resource_type": "",
"original_filename": file.filename,
"stored_filename": f"{uid}{ext}",
"mime_type": file.content_type or "application/octet-stream",
"file_size": len(content),
"storage_path": storage_path,
"created_at": __import__("datetime").datetime.utcnow().isoformat(),
})
is_image = ext in IMAGE_EXTENSIONS
file_url = f"/static/uploads/{storage_path}"
logger.info(f"File uploaded: {file.filename} ({len(content)} bytes) -> {storage_path}")
return JSONResponse({
"uid": uid,
"original_filename": file.filename,
"url": file_url,
"mime_type": file.content_type or "application/octet-stream",
"file_size": len(content),
"is_image": is_image,
}, status_code=201)
@router.delete("/delete/{attachment_uid}") @router.delete("/delete/{attachment_uid}")
async def delete_attachment(request: Request, attachment_uid: str): async def delete_attachment_route(request: Request, attachment_uid: str):
user = require_user(request) user = require_user(request)
attachments = get_table("attachments") att = get_table("attachments").find_one(uid=attachment_uid)
att = attachments.find_one(uid=attachment_uid)
if not att: if not att:
return JSONResponse({"error": "Attachment not found"}, status_code=404) return JSONResponse({"error": "Attachment not found"}, status_code=404)
if att.get("resource_uid"): if att.get("user_uid") and att["user_uid"] != user["uid"]:
resource_type = att["resource_type"] return JSONResponse({"error": "Not authorized"}, status_code=403)
if resource_type == "post": _delete_attachment(attachment_uid)
post = get_table("posts").find_one(uid=att["resource_uid"])
if not post or post["user_uid"] != user["uid"]:
return JSONResponse({"error": "Not authorized"}, status_code=403)
elif resource_type == "comment":
comment = get_table("comments").find_one(uid=att["resource_uid"])
if not comment or comment["user_uid"] != user["uid"]:
return JSONResponse({"error": "Not authorized"}, status_code=403)
from devplacepy.database import _delete_attachment_file
_delete_attachment_file(att.get("storage_path", ""))
attachments.delete(id=att["id"])
logger.info(f"Attachment {attachment_uid} deleted by {user['username']}") logger.info(f"Attachment {attachment_uid} deleted by {user['username']}")
return JSONResponse({"status": "deleted"}) return JSONResponse({"status": "deleted"})
+13 -13
View File
@@ -1,22 +1,21 @@
import logging import logging
from datetime import datetime from typing import Annotated
from fastapi import APIRouter, Request from datetime import datetime, timezone
from fastapi import APIRouter, Request, Form
from fastapi.responses import RedirectResponse from fastapi.responses import RedirectResponse
from devplacepy.database import get_table from devplacepy.database import get_table
from devplacepy.templating import clear_unread_cache
from devplacepy.utils import generate_uid, require_user from devplacepy.utils import generate_uid, require_user
from devplacepy.models import VoteForm
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter() router = APIRouter()
@router.post("/{target_type}/{target_uid}") @router.post("/{target_type}/{target_uid}")
async def vote(request: Request, target_type: str, target_uid: str): async def vote(request: Request, target_type: str, target_uid: str, data: Annotated[VoteForm, Form()]):
user = require_user(request) user = require_user(request)
form = await request.form() value = data.value
value = int(form.get("value", "1"))
if value not in (1, -1):
return RedirectResponse(url="/feed", status_code=302)
votes = get_table("votes") votes = get_table("votes")
existing = votes.find_one(user_uid=user["uid"], target_uid=target_uid, target_type=target_type) existing = votes.find_one(user_uid=user["uid"], target_uid=target_uid, target_type=target_type)
@@ -33,11 +32,11 @@ async def vote(request: Request, target_type: str, target_uid: str):
"target_uid": target_uid, "target_uid": target_uid,
"target_type": target_type, "target_type": target_type,
"value": value, "value": value,
"created_at": datetime.utcnow().isoformat(), "created_at": datetime.now(timezone.utc).isoformat(),
}) })
up_count = len(list(votes.find(target_uid=target_uid, value=1))) up_count = votes.count(target_uid=target_uid, value=1)
down_count = len(list(votes.find(target_uid=target_uid, value=-1))) down_count = votes.count(target_uid=target_uid, value=-1)
net = up_count - down_count net = up_count - down_count
if target_type == "post": if target_type == "post":
@@ -68,7 +67,7 @@ async def vote(request: Request, target_type: str, target_uid: str):
target_owner_uid = target_gist["user_uid"] target_owner_uid = target_gist["user_uid"]
if target_owner_uid and target_owner_uid != user["uid"]: if target_owner_uid and target_owner_uid != user["uid"]:
label = "post" if target_type == "post" else "comment" label = {"post": "post", "comment": "comment", "gist": "gist"}.get(target_type, "gist")
notifications = get_table("notifications") notifications = get_table("notifications")
notifications.insert({ notifications.insert({
"uid": generate_uid(), "uid": generate_uid(),
@@ -77,8 +76,9 @@ async def vote(request: Request, target_type: str, target_uid: str):
"message": f"{user['username']} ++'d your {label}", "message": f"{user['username']} ++'d your {label}",
"related_uid": user["uid"], "related_uid": user["uid"],
"read": False, "read": False,
"created_at": datetime.utcnow().isoformat(), "created_at": datetime.now(timezone.utc).isoformat(),
}) })
clear_unread_cache(target_owner_uid)
referer = request.headers.get("Referer", "/feed") referer = request.headers.get("Referer", "/feed")
return RedirectResponse(url=referer, status_code=302) return RedirectResponse(url=referer, status_code=302)
+73 -13
View File
@@ -3,6 +3,8 @@ import logging
from datetime import datetime from datetime import datetime
from xml.etree.ElementTree import Element, tostring from xml.etree.ElementTree import Element, tostring
from xml.dom import minidom from xml.dom import minidom
from devplacepy.config import SITE_URL
from devplacepy.utils import strip_html
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -13,14 +15,15 @@ SITE_NAME = "DevPlace"
def truncate(text, max_len=160): def truncate(text, max_len=160):
if not text: if not text:
return "" return ""
text = " ".join(text.split())[:max_len] text = " ".join(text.split())
if len(text) >= max_len: if len(text) <= max_len:
text = text.rsplit(" ", 1)[0] + "..." return text
return text text = text[:max_len - 3].rsplit(" ", 1)[0]
return text + "..."
def site_url(request): def site_url(request):
return str(request.base_url).rstrip("/") return SITE_URL or str(request.base_url).rstrip("/")
def website_schema(base_url): def website_schema(base_url):
@@ -73,8 +76,6 @@ def discussion_forum_posting(post, author, comment_count, star_count, base_url):
{"@type": "InteractionCounter", "interactionType": "https://schema.org/CommentAction", "userInteractionCount": comment_count} {"@type": "InteractionCounter", "interactionType": "https://schema.org/CommentAction", "userInteractionCount": comment_count}
] ]
} }
if post.get("title"):
schema["headline"] = post["title"]
return schema return schema
@@ -98,7 +99,7 @@ def software_application_schema(project, base_url):
"@type": "SoftwareApplication", "@type": "SoftwareApplication",
"name": project.get("title", "Untitled"), "name": project.get("title", "Untitled"),
"description": truncate(project.get("description", ""), 300), "description": truncate(project.get("description", ""), 300),
"url": f"{base_url}/projects", "url": f"{base_url}/projects/{project.get('slug') or project['uid']}",
"applicationCategory": "DeveloperApplication", "applicationCategory": "DeveloperApplication",
"operatingSystem": project.get("platforms", "Cross-platform"), "operatingSystem": project.get("platforms", "Cross-platform"),
"author": { "author": {
@@ -114,6 +115,43 @@ def software_application_schema(project, base_url):
} }
def organization_schema(base_url):
return {
"@type": "Organization",
"name": SITE_NAME,
"url": base_url,
"logo": f"{base_url}{DEFAULT_OG_IMAGE}",
}
def news_article_schema(article, base_url, image_url=""):
url = f"{base_url}/news/{article.get('slug') or article['uid']}"
schema = {
"@type": "NewsArticle",
"headline": (article.get("title") or "Untitled")[:110],
"description": truncate(strip_html(article.get("description", "") or ""), 200),
"url": url,
"datePublished": article.get("synced_at", "") or article.get("created_at", ""),
"mainEntityOfPage": {"@type": "WebPage", "@id": url},
"author": {"@type": "Organization", "name": article.get("source_name") or SITE_NAME},
"publisher": organization_schema(base_url),
}
if image_url:
schema["image"] = image_url
return schema
def software_source_code_schema(gist, base_url):
return {
"@type": "SoftwareSourceCode",
"name": gist.get("title") or "Gist",
"description": truncate(strip_html(gist.get("description", "") or ""), 200),
"url": f"{base_url}/gists/{gist.get('slug') or gist['uid']}",
"programmingLanguage": gist.get("language", "") or "text",
"dateCreated": gist.get("created_at", ""),
}
def combine(schemas): def combine(schemas):
if not schemas: if not schemas:
return None return None
@@ -129,21 +167,25 @@ def combine(schemas):
return json.dumps({"@context": "https://schema.org", "@graph": cleaned}, ensure_ascii=False) return json.dumps({"@context": "https://schema.org", "@graph": cleaned}, ensure_ascii=False)
DEFAULT_OG_IMAGE = "/static/og-default.svg" DEFAULT_OG_IMAGE = "/static/og-default.png"
def base_seo_context(request, title="", description="", robots="index,follow", og_type="website", og_image=None, breadcrumbs=None, schemas=None): def base_seo_context(request, title="", description="", robots="index,follow", og_type="website", og_image=None, breadcrumbs=None, schemas=None):
base = site_url(request) base = site_url(request)
page_title = f"{title} {SITE_NAME}" if title else SITE_NAME page_title = f"{title} - {SITE_NAME}" if title else SITE_NAME
canonical = str(request.url).split("?")[0] canonical = f"{base}{request.url.path}"
page = request.query_params.get("page")
if page and page not in ("", "1"):
canonical = f"{canonical}?page={page}"
clean_description = truncate(strip_html(description), 160)
og_img = og_image or f"{base}{DEFAULT_OG_IMAGE}" og_img = og_image or f"{base}{DEFAULT_OG_IMAGE}"
return { return {
"page_title": page_title, "page_title": page_title,
"meta_description": description, "meta_description": clean_description,
"meta_robots": robots, "meta_robots": robots,
"canonical_url": canonical, "canonical_url": canonical,
"og_title": title or SITE_NAME, "og_title": title or SITE_NAME,
"og_description": description, "og_description": clean_description,
"og_image": og_img, "og_image": og_img,
"og_type": og_type, "og_type": og_type,
"breadcrumbs": breadcrumbs or [], "breadcrumbs": breadcrumbs or [],
@@ -180,6 +222,7 @@ def make_sitemap(base_url):
urlset.append(url_element(f"{base_url}/feed", changefreq="hourly", priority="0.9")) urlset.append(url_element(f"{base_url}/feed", changefreq="hourly", priority="0.9"))
urlset.append(url_element(f"{base_url}/news", changefreq="hourly", priority="0.9")) urlset.append(url_element(f"{base_url}/news", changefreq="hourly", priority="0.9"))
urlset.append(url_element(f"{base_url}/projects", changefreq="daily", priority="0.8")) urlset.append(url_element(f"{base_url}/projects", changefreq="daily", priority="0.8"))
urlset.append(url_element(f"{base_url}/gists", changefreq="daily", priority="0.8"))
if "posts" in db.tables: if "posts" in db.tables:
posts = list(get_table("posts").find(order_by=["-created_at"], _limit=500)) posts = list(get_table("posts").find(order_by=["-created_at"], _limit=500))
@@ -211,11 +254,28 @@ def make_sitemap(base_url):
priority="0.6" priority="0.6"
)) ))
if "news" in db.tables:
articles = list(get_table("news").find(status="published", order_by=["-synced_at"], _limit=500))
for a in articles:
urlset.append(url_element(
f"{base_url}/news/{a.get('slug') or a['uid']}",
lastmod=a.get("synced_at", "") or a.get("created_at", ""),
changefreq="weekly",
priority="0.7"
))
if "users" in db.tables: if "users" in db.tables:
post_counts = {}
if "posts" in db.tables:
for row in db.query("SELECT user_uid, COUNT(*) AS c FROM posts GROUP BY user_uid"):
post_counts[row["user_uid"]] = row["c"]
users = list(get_table("users").find(order_by=["-created_at"], _limit=200)) users = list(get_table("users").find(order_by=["-created_at"], _limit=200))
for u in users: for u in users:
if post_counts.get(u["uid"], 0) < 2:
continue
urlset.append(url_element( urlset.append(url_element(
f"{base_url}/profile/{u['username']}", f"{base_url}/profile/{u['username']}",
lastmod=u.get("created_at", ""),
changefreq="weekly", changefreq="weekly",
priority="0.4" priority="0.4"
)) ))
+6 -6
View File
@@ -2,7 +2,7 @@ import asyncio
import logging import logging
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from collections import deque from collections import deque
from datetime import datetime from datetime import datetime, timezone
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -34,11 +34,11 @@ class BaseService(ABC):
def uptime(self) -> str | None: def uptime(self) -> str | None:
if self._started_at is None: if self._started_at is None:
return None return None
delta = datetime.utcnow() - self._started_at delta = datetime.now(timezone.utc) - self._started_at
return str(delta).split(".")[0] return str(delta).split(".")[0]
def log(self, message: str) -> None: def log(self, message: str) -> None:
stamp = datetime.utcnow().strftime("%H:%M:%S") stamp = datetime.now(timezone.utc).strftime("%H:%M:%S")
entry = f"[{stamp}] {message}" entry = f"[{stamp}] {message}"
self.log_buffer.append(entry) self.log_buffer.append(entry)
logger.info(f"[{self.name}] {message}") logger.info(f"[{self.name}] {message}")
@@ -49,11 +49,11 @@ class BaseService(ABC):
async def _run_loop(self) -> None: async def _run_loop(self) -> None:
self._running = True self._running = True
self._started_at = datetime.utcnow() self._started_at = datetime.now(timezone.utc)
self.log(f"Service started (interval={self.interval_seconds}s)") self.log(f"Service started (interval={self.interval_seconds}s)")
while self._running: while self._running:
try: try:
self._last_run = datetime.utcnow().isoformat() self._last_run = datetime.now(timezone.utc).isoformat()
self._next_run = None self._next_run = None
await self.run_once() await self.run_once()
except asyncio.CancelledError: except asyncio.CancelledError:
@@ -63,7 +63,7 @@ class BaseService(ABC):
self.log(f"Error in run_once: {e}") self.log(f"Error in run_once: {e}")
if not self._running: if not self._running:
break break
self._next_run = datetime.utcnow().isoformat() self._next_run = datetime.now(timezone.utc).isoformat()
self.log(f"Sleeping for {self.interval_seconds}s") self.log(f"Sleeping for {self.interval_seconds}s")
try: try:
await asyncio.sleep(self.interval_seconds) await asyncio.sleep(self.interval_seconds)
+126 -137
View File
@@ -1,13 +1,13 @@
import logging import logging
import os import os
import re import re
from datetime import datetime from datetime import datetime, timezone
import httpx import httpx
from devplacepy.database import get_table from devplacepy.database import get_table, get_setting
from devplacepy.services.base import BaseService from devplacepy.services.base import BaseService
from devplacepy.utils import generate_uid, make_combined_slug from devplacepy.utils import generate_uid, make_combined_slug, strip_html
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -17,22 +17,13 @@ AI_MODEL_DEFAULT = "molodetz"
GRADE_THRESHOLD_DEFAULT = 7 GRADE_THRESHOLD_DEFAULT = 7
def _get_setting(key: str, default: str) -> str:
table = get_table("site_settings")
row = table.find_one(key=key)
if row is None:
return default
return row.get("value", default)
def _get_ai_key() -> str: def _get_ai_key() -> str:
key = os.environ.get("NEWS_AI_KEY") key = os.environ.get("NEWS_AI_KEY")
if key: if key:
return key return key
table = get_table("site_settings") key = get_setting("news_ai_key", "")
row = table.find_one(key="news_ai_key") if key:
if row: return key
return row.get("value", "")
key = os.environ.get("OPENROUTER_API_KEY") key = os.environ.get("OPENROUTER_API_KEY")
if key: if key:
return key return key
@@ -51,12 +42,11 @@ def _extract_grade(text: str) -> int | None:
return None return None
async def _get_article_images(url: str) -> list[dict]: async def _get_article_images(url: str, client: httpx.AsyncClient) -> list[dict]:
try: try:
async with httpx.AsyncClient(timeout=10.0) as client: resp = await client.get(url, timeout=10.0)
resp = await client.get(url) resp.raise_for_status()
resp.raise_for_status() html = resp.text
html = resp.text
pattern = re.compile(r'<img[^>]+src=["\']([^"\']+)["\']', re.IGNORECASE) pattern = re.compile(r'<img[^>]+src=["\']([^"\']+)["\']', re.IGNORECASE)
matches = pattern.findall(html) matches = pattern.findall(html)
images = [] images = []
@@ -77,10 +67,10 @@ class NewsService(BaseService):
super().__init__(name="news", interval_seconds=3600) super().__init__(name="news", interval_seconds=3600)
async def run_once(self) -> None: async def run_once(self) -> None:
api_url = _get_setting("news_api_url", NEWS_API_URL_DEFAULT) api_url = get_setting("news_api_url", NEWS_API_URL_DEFAULT)
ai_url = _get_setting("news_ai_url", AI_URL_DEFAULT) ai_url = get_setting("news_ai_url", AI_URL_DEFAULT)
ai_model = _get_setting("news_ai_model", AI_MODEL_DEFAULT) ai_model = get_setting("news_ai_model", AI_MODEL_DEFAULT)
threshold = int(_get_setting("news_grade_threshold", str(GRADE_THRESHOLD_DEFAULT))) threshold = int(get_setting("news_grade_threshold", str(GRADE_THRESHOLD_DEFAULT)))
self.log(f"Fetching news from {api_url}") self.log(f"Fetching news from {api_url}")
async with httpx.AsyncClient(timeout=30.0) as client: async with httpx.AsyncClient(timeout=30.0) as client:
@@ -92,128 +82,128 @@ class NewsService(BaseService):
self.log(f"Failed to fetch news API: {e}") self.log(f"Failed to fetch news API: {e}")
return return
articles = data.get("articles", []) articles = data.get("articles", [])
self.log(f"Received {len(articles)} articles") self.log(f"Received {len(articles)} articles")
news_table = get_table("news") news_table = get_table("news")
images_table = get_table("news_images") images_table = get_table("news_images")
sync_table = get_table("news_sync") sync_table = get_table("news_sync")
synced_ids = set() synced_ids = set()
for entry in sync_table.find(): for entry in sync_table.find():
synced_ids.add(entry["external_id"]) synced_ids.add(entry["external_id"])
new_count = 0 new_count = 0
updated_count = 0 updated_count = 0
draft_count = 0 draft_count = 0
failed_count = 0 failed_count = 0
skipped_count = 0 skipped_count = 0
for article in articles: for article in articles:
external_id = article.get("guid", "") external_id = article.get("guid", "")
if not external_id: if not external_id:
continue continue
if external_id in synced_ids: if external_id in synced_ids:
skipped_count += 1 skipped_count += 1
continue continue
grade = await self._grade_article(article, ai_url, ai_model) grade = await self._grade_article(article, ai_url, ai_model, client)
now = datetime.utcnow().isoformat() now = datetime.now(timezone.utc).isoformat()
if grade is None: if grade is None:
grade_val = 0 grade_val = 0
auto_published = False auto_published = False
failed_count += 1 failed_count += 1
sync_status = "grading_failed" sync_status = "grading_failed"
elif grade < threshold: elif grade < threshold:
grade_val = grade grade_val = grade
auto_published = False auto_published = False
draft_count += 1 draft_count += 1
sync_status = "graded" sync_status = "graded"
else: else:
grade_val = grade grade_val = grade
auto_published = True auto_published = True
sync_status = "graded" sync_status = "graded"
is_published = "published" if auto_published else "draft" is_published = "published" if auto_published else "draft"
existing = news_table.find_one(external_id=external_id) existing = news_table.find_one(external_id=external_id)
if existing: if existing:
existing_slug = existing.get("slug", "") existing_slug = existing.get("slug", "")
news_table.update({ news_table.update({
"id": existing["id"], "id": existing["id"],
"grade": grade_val, "grade": grade_val,
"status": is_published, "status": is_published,
"title": article.get("title", ""), "title": article.get("title", ""),
"slug": existing_slug or make_combined_slug(article.get("title", "") or "news", existing["uid"]), "slug": existing_slug or make_combined_slug(article.get("title", "") or "news", existing["uid"]),
"description": (article.get("description", "") or "")[:5000], "description": strip_html(article.get("description", "") or "")[:5000],
"url": article.get("link", ""), "url": article.get("link", ""),
"source_name": article.get("feed_name", ""), "source_name": article.get("feed_name", ""),
"content": (article.get("content", "") or "")[:10000], "content": strip_html(article.get("content", "") or "")[:10000],
"author": article.get("author", ""), "author": article.get("author", ""),
"article_published": article.get("published", ""), "article_published": article.get("published", ""),
"synced_at": now, "synced_at": now,
}, ["id"]) }, ["id"])
updated_count += 1 updated_count += 1
images_table.delete(news_uid=existing["uid"]) images_table.delete(news_uid=existing["uid"])
article_uid = existing["uid"] article_uid = existing["uid"]
else: else:
article_uid = generate_uid() article_uid = generate_uid()
article_slug = make_combined_slug(article.get("title", "") or "news", article_uid) article_slug = make_combined_slug(article.get("title", "") or "news", article_uid)
news_table.insert({ news_table.insert({
"uid": article_uid, "uid": article_uid,
"slug": article_slug, "slug": article_slug,
"external_id": external_id, "external_id": external_id,
"title": article.get("title", ""), "title": article.get("title", ""),
"description": (article.get("description", "") or "")[:5000], "description": strip_html(article.get("description", "") or "")[:5000],
"url": article.get("link", ""), "url": article.get("link", ""),
"image_url": "", "image_url": "",
"source_name": article.get("feed_name", ""), "source_name": article.get("feed_name", ""),
"grade": grade_val, "grade": grade_val,
"status": is_published, "status": is_published,
"content": (article.get("content", "") or "")[:10000], "content": strip_html(article.get("content", "") or "")[:10000],
"author": article.get("author", ""), "author": article.get("author", ""),
"article_published": article.get("published", ""), "article_published": article.get("published", ""),
"synced_at": now, "synced_at": now,
})
new_count += 1
existing_sync = sync_table.find_one(external_id=external_id)
if existing_sync:
sync_table.update({
"id": existing_sync["id"],
"status": sync_status,
"synced_at": now,
}, ["id"])
else:
sync_table.insert({
"uid": generate_uid(),
"external_id": external_id,
"status": sync_status,
"synced_at": now,
})
synced_ids.add(external_id)
link = article.get("link", "")
if link:
fresh_images = await _get_article_images(link)
for img in fresh_images:
images_table.insert({
"uid": generate_uid(),
"news_uid": article_uid,
"url": img["url"],
"alt_text": img.get("alt_text", ""),
}) })
new_count += 1
existing_sync = sync_table.find_one(external_id=external_id)
if existing_sync:
sync_table.update({
"id": existing_sync["id"],
"status": sync_status,
"synced_at": now,
}, ["id"])
else:
sync_table.insert({
"uid": generate_uid(),
"external_id": external_id,
"status": sync_status,
"synced_at": now,
})
synced_ids.add(external_id)
link = article.get("link", "")
if link:
fresh_images = await _get_article_images(link, client)
for img in fresh_images:
images_table.insert({
"uid": generate_uid(),
"news_uid": article_uid,
"url": img["url"],
"alt_text": img.get("alt_text", ""),
})
self.log(f"New {new_count}, updated {updated_count}, draft {draft_count}, " self.log(f"New {new_count}, updated {updated_count}, draft {draft_count}, "
f"grading failed {failed_count}, skipped {skipped_count}") f"grading failed {failed_count}, skipped {skipped_count}")
async def _grade_article(self, article: dict, ai_url: str, ai_model: str) -> int | None: async def _grade_article(self, article: dict, ai_url: str, ai_model: str, client: httpx.AsyncClient) -> int | None:
title = (article.get("title", "") or "")[:500] title = (article.get("title", "") or "")[:500]
description = (article.get("description", "") or "")[:1000] description = strip_html(article.get("description", "") or "")[:1000]
content = (article.get("content", "") or "")[:1500] content = strip_html(article.get("content", "") or "")[:1500]
prompt = ( prompt = (
"Rate this article's relevance to software developers on a scale of 1-10. " "Rate this article's relevance to software developers on a scale of 1-10. "
@@ -238,12 +228,11 @@ class NewsService(BaseService):
headers["Authorization"] = f"Bearer {ai_key}" headers["Authorization"] = f"Bearer {ai_key}"
try: try:
async with httpx.AsyncClient(timeout=15.0) as client: resp = await client.post(ai_url, json=payload, headers=headers, timeout=15.0)
resp = await client.post(ai_url, json=payload, headers=headers) if resp.status_code != 200:
if resp.status_code != 200: self.log(f"AI grading returned {resp.status_code}: {resp.text[:200]}")
self.log(f"AI grading returned {resp.status_code}: {resp.text[:200]}") resp.raise_for_status()
resp.raise_for_status() result = resp.json()
result = resp.json()
text = result.get("choices", [{}])[0].get("message", {}).get("content", "") text = result.get("choices", [{}])[0].get("message", {}).get("content", "")
if not text: if not text:
self.log(f"AI grading returned empty content for: {title[:60]}") self.log(f"AI grading returned empty content for: {title[:60]}")
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

+43
View File
@@ -371,3 +371,46 @@
color: var(--text-muted); color: var(--text-muted);
user-select: none; user-select: none;
} }
@media (max-width: 768px) {
.admin-layout {
grid-template-columns: 1fr;
}
}
@media (max-width: 480px) {
.admin-toolbar {
flex-direction: column;
align-items: flex-start;
gap: 0.5rem;
}
.admin-table th,
.admin-table td {
padding: 0.375rem 0.5rem;
}
.admin-table {
font-size: 0.75rem;
}
.admin-field input,
.admin-field textarea {
max-width: 100%;
}
.admin-settings-form {
max-width: 100%;
}
.pagination {
flex-direction: column;
align-items: center;
gap: 0.5rem;
}
.pagination-controls {
flex-wrap: wrap;
justify-content: center;
}
}
+24 -36
View File
@@ -1,48 +1,29 @@
.attachment-upload-zone { .attachment-upload-container button.attachment-upload-btn {
border: 2px dashed var(--border-light); display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.375rem 0.5rem;
border-radius: var(--radius); border-radius: var(--radius);
padding: 1.5rem 1rem; font-size: 1.125rem;
text-align: center; color: var(--text-muted);
cursor: pointer; cursor: pointer;
transition: all 0.2s; transition: all 0.2s;
background: var(--bg-input); background: none;
position: relative; border: 1px solid transparent;
line-height: 1;
} }
.attachment-upload-zone:hover { .attachment-upload-container button.attachment-upload-btn:hover {
background: var(--bg-card-hover);
color: var(--text-primary);
}
.attachment-upload-container button.attachment-upload-btn.dragover {
border-color: var(--accent); border-color: var(--accent);
background: var(--accent-light); background: var(--accent-light);
} }
.attachment-upload-zone.dragover { .attachment-upload-container button.attachment-upload-btn.uploading {
border-color: var(--accent);
background: var(--accent-light);
border-style: solid;
}
.attachment-upload-zone input[type="file"] {
display: none;
}
.attachment-upload-zone .upload-icon {
font-size: 1.5rem;
margin-bottom: 0.5rem;
display: block;
}
.attachment-upload-zone .upload-text {
font-size: 0.8125rem;
color: var(--text-muted);
}
.attachment-upload-zone .upload-hint {
font-size: 0.75rem;
color: var(--text-muted);
margin-top: 0.375rem;
opacity: 0.7;
}
.attachment-upload-zone.uploading {
pointer-events: none; pointer-events: none;
opacity: 0.6; opacity: 0.6;
} }
@@ -52,6 +33,13 @@
flex-wrap: wrap; flex-wrap: wrap;
gap: 0.5rem; gap: 0.5rem;
margin-top: 0.75rem; margin-top: 0.75rem;
flex: 0 0 100%;
width: 100%;
}
.attachment-preview-list:empty,
.attachment-upload-error:empty {
display: none;
} }
.attachment-preview { .attachment-preview {
+22
View File
@@ -148,3 +148,25 @@
color: var(--success); color: var(--success);
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
} }
@media (max-width: 480px) {
.auth-card {
padding: 1.5rem;
}
.auth-card h2 {
font-size: 1.25rem;
}
.auth-options {
flex-direction: column;
align-items: flex-start;
gap: 0.5rem;
}
}
@media (max-width: 360px) {
.auth-card {
padding: 1rem;
}
}
+210 -6
View File
@@ -516,6 +516,7 @@ img {
font-weight: 800; font-weight: 800;
color: var(--text-primary); color: var(--text-primary);
letter-spacing: -0.02em; letter-spacing: -0.02em;
flex-shrink: 0;
} }
.topnav-logo span { color: var(--accent); } .topnav-logo span { color: var(--accent); }
.topnav-links { display: flex; gap: 0.25rem; } .topnav-links { display: flex; gap: 0.25rem; }
@@ -529,7 +530,7 @@ img {
} }
.topnav-link:hover { color: var(--text-primary); background: var(--bg-card); } .topnav-link:hover { color: var(--text-primary); background: var(--bg-card); }
.topnav-link.active { color: var(--accent); background: var(--accent-light); } .topnav-link.active { color: var(--accent); background: var(--accent-light); }
.topnav-right { margin-left: auto; display: flex; align-items: center; gap: 1rem; } .topnav-right { margin-left: auto; display: flex; align-items: center; gap: 1rem; flex-shrink: 0; }
.topnav-icon { position: relative; padding: 0.375rem; color: var(--text-secondary); font-size: 1.25rem; transition: color 0.2s; } .topnav-icon { position: relative; padding: 0.375rem; color: var(--text-secondary); font-size: 1.25rem; transition: color 0.2s; }
.topnav-icon:hover { color: var(--text-primary); } .topnav-icon:hover { color: var(--text-primary); }
.nav-badge { .nav-badge {
@@ -567,6 +568,116 @@ img {
} }
.dropdown-item:hover { background: var(--bg-card); color: var(--text-primary); } .dropdown-item:hover { background: var(--bg-card); color: var(--text-primary); }
.topnav-hamburger {
display: none;
background: none;
border: none;
color: var(--text-primary);
font-size: 1.5rem;
padding: 0.375rem;
cursor: pointer;
line-height: 1;
}
.topnav-mobile-overlay {
display: none;
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 998;
}
.topnav-mobile-panel {
position: fixed;
top: var(--nav-height);
left: 0;
right: 0;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border);
z-index: 999;
max-height: 0;
overflow: hidden;
transition: max-height 0.3s ease;
}
.topnav-mobile-panel.open {
max-height: 500px;
overflow-y: auto;
}
.topnav-mobile-links {
display: flex;
flex-direction: column;
padding: 0.5rem;
gap: 0.125rem;
}
.topnav-mobile-link {
display: flex;
align-items: center;
gap: 0.625rem;
padding: 0.75rem;
border-radius: var(--radius);
font-size: 0.9375rem;
font-weight: 500;
color: var(--text-secondary);
transition: all 0.15s;
}
.topnav-mobile-link:hover {
background: var(--bg-card);
color: var(--text-primary);
text-decoration: none;
}
.topnav-mobile-link.active {
color: var(--accent);
background: var(--accent-light);
}
.topnav-mobile-divider {
height: 1px;
background: var(--border);
margin: 0.375rem 0.75rem;
}
.topnav-mobile-user {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem;
border-radius: var(--radius);
color: var(--text-primary);
font-weight: 600;
font-size: 0.875rem;
}
.topnav-mobile-user:hover {
background: var(--bg-card);
text-decoration: none;
}
.topnav-mobile-user-info {
display: flex;
flex-direction: column;
line-height: 1.2;
}
.topnav-mobile-user-role {
font-size: 0.75rem;
color: var(--text-muted);
font-weight: 400;
}
.topnav-mobile-section-label {
font-size: 0.6875rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--text-muted);
padding: 0.5rem 0.75rem 0.25rem;
}
.user-avatar-link { display: inline-flex; flex-shrink: 0; line-height: 0; } .user-avatar-link { display: inline-flex; flex-shrink: 0; line-height: 0; }
.user-avatar-link:hover { opacity: 0.85; } .user-avatar-link:hover { opacity: 0.85; }
.user-link { display: inline-flex; align-items: center; gap: 0.5rem; color: var(--text-primary); transition: color 0.2s; } .user-link { display: inline-flex; align-items: center; gap: 0.5rem; color: var(--text-primary); transition: color 0.2s; }
@@ -667,9 +778,6 @@ img {
padding-top: 0; padding-top: 0;
} }
.page-messages .messages-layout {
margin-top: calc(var(--nav-height) + 1rem);
}
.modal-overlay { .modal-overlay {
display: none; display: none;
@@ -685,6 +793,22 @@ img {
display: flex; display: flex;
} }
@media (max-width: 480px) {
.modal-card {
margin: 0.5rem;
}
}
@media (max-width: 360px) {
.page {
padding: 0.25rem;
}
.card {
padding: 0.75rem;
}
}
.breadcrumb { .breadcrumb {
max-width: var(--max-content); max-width: var(--max-content);
margin: 0 auto; margin: 0 auto;
@@ -762,7 +886,29 @@ img {
border: 0; border: 0;
} }
@media (max-width: 768px) {
@media (max-width: 1024px) {
.topnav-links {
display: none;
}
.topnav-hamburger {
display: inline-flex;
align-items: center;
justify-content: center;
}
.topnav-user-info {
display: none;
}
.topnav-inner {
gap: 0.5rem;
}
}
@media (max-width: 1024px) {
.breadcrumb { .breadcrumb {
padding: 0.375rem 0.5rem; padding: 0.375rem 0.5rem;
padding-top: calc(var(--nav-height) + 0.375rem); padding-top: calc(var(--nav-height) + 0.375rem);
@@ -770,8 +916,66 @@ img {
} }
} }
@media (max-width: 768px) {
@media (max-width: 1024px) {
.page { .page {
padding: 0.5rem; padding: 0.5rem;
} }
} }
@media (max-width: 480px) {
.topnav-inner {
padding: 0 0.5rem;
gap: 0.25rem;
}
.topnav-logo {
font-size: 1.125rem;
}
.topnav-right {
gap: 0.375rem;
}
}
@media (max-width: 360px) {
.topnav-logo {
font-size: 1rem;
}
.topnav-icon {
font-size: 1.125rem;
padding: 0.25rem;
}
}
@media (hover: none) and (pointer: coarse) {
.post-action-btn,
.feed-nav-btn,
.profile-tab,
.sidebar-link,
.topnav-link,
.topnav-mobile-link {
min-height: 44px;
}
.modal-card input,
.modal-card textarea,
.modal-card select {
font-size: 16px;
}
}
@media (max-width: 480px) {
.post-detail-title {
font-size: 1.25rem;
}
.post-detail-content {
font-size: 0.875rem;
}
.comment-text {
font-size: 0.8125rem;
}
}
+114 -4
View File
@@ -65,7 +65,7 @@
background: var(--bg-card); background: var(--bg-card);
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
padding: 1.25rem; padding: 1rem;
transition: all 0.2s; transition: all 0.2s;
} }
@@ -103,42 +103,62 @@
font-size: 0.6875rem; font-size: 0.6875rem;
color: var(--text-muted); color: var(--text-muted);
margin-left: auto; margin-left: auto;
padding-left: 0.75rem;
white-space: nowrap; white-space: nowrap;
line-height: 1.5;
} }
.post-topic { .post-topic {
margin-bottom: 0.75rem; margin-bottom: 0.75rem;
} }
.post-title-link {
text-decoration: none;
color: inherit;
display: block;
}
.post-title-link:hover .post-title {
color: var(--accent);
}
.post-title { .post-title {
font-size: 1.125rem; font-size: 1.125rem;
font-weight: 700; font-weight: 700;
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
color: var(--text-primary); color: var(--text-primary);
line-height: 1.3; line-height: 1.3;
transition: color 0.15s ease;
} }
.post-content { .post-content {
font-size: 0.875rem; font-size: 0.875rem;
color: var(--text-secondary); color: var(--text-secondary);
line-height: 1.65; line-height: 1.65;
margin-bottom: 0.75rem; margin-bottom: 1rem;
word-break: break-word; word-break: break-word;
display: -webkit-box; display: -webkit-box;
-webkit-line-clamp: 4; -webkit-line-clamp: 4;
-webkit-box-orient: vertical; -webkit-box-orient: vertical;
overflow: hidden; overflow: hidden;
cursor: pointer;
transition: color 0.15s ease;
}
.post-content:hover {
color: var(--accent);
} }
.post-actions { .post-actions {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.25rem; gap: 0.5rem;
padding-top: 0.75rem; padding-top: 1rem;
border-top: 1px solid var(--border); border-top: 1px solid var(--border);
} }
.post-action-btn { .post-action-btn {
line-height: 1;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 0.375rem; gap: 0.375rem;
@@ -163,6 +183,24 @@
color: var(--accent); color: var(--accent);
} }
.post-action-btn.vote-down {
color: var(--danger);
}
.post-votes {
display: inline-flex;
align-items: center;
gap: 0.125rem;
}
.post-vote-count {
font-weight: 600;
font-size: 0.8125rem;
color: var(--text-secondary);
min-width: 1.25rem;
text-align: center;
}
.post-action-btn.share { .post-action-btn.share {
margin-left: auto; margin-left: auto;
} }
@@ -318,3 +356,75 @@
display: none; display: none;
} }
} }
@media (max-width: 768px) {
.feed-nav {
gap: 0.125rem;
overflow-x: auto;
flex-wrap: nowrap;
-webkit-overflow-scrolling: touch;
}
.feed-nav-btn {
padding: 0.375rem 0.625rem;
font-size: 0.75rem;
flex-shrink: 0;
}
}
@media (max-width: 480px) {
.feed-nav-actions {
display: none;
}
.feed-nav-btn .icon {
width: auto;
margin-right: 0.125rem;
}
.feed-comment-form {
flex-wrap: wrap;
}
.feed-comment-form input {
min-width: 120px;
}
.post-card {
padding: 0.75rem;
}
.post-header {
gap: 0.5rem;
}
.post-time {
font-size: 0.625rem;
}
.post-title {
font-size: 1rem;
}
.post-actions {
flex-wrap: wrap;
gap: 0.25rem;
}
.post-action-btn {
padding: 0.375rem 0.5rem;
font-size: 0.75rem;
}
}
@media (max-width: 360px) {
.post-header {
flex-wrap: wrap;
}
.post-time {
margin-left: 0;
padding-left: 0;
width: 100%;
}
}
+31 -13
View File
@@ -24,7 +24,7 @@
.gists-grid { .gists-grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); grid-template-columns: 1fr;
gap: 1rem; gap: 1rem;
} }
@@ -34,12 +34,7 @@
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
padding: 1.25rem; padding: 1.25rem;
transition: all 0.2s; transition: all 0.2s;
} cursor: pointer;
a.gist-card {
display: block;
text-decoration: none;
color: inherit;
} }
.gist-card:hover { .gist-card:hover {
@@ -62,9 +57,6 @@ a.gist-card {
line-height: 1.3; line-height: 1.3;
flex: 1; flex: 1;
min-width: 0; min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
} }
.gist-card-meta { .gist-card-meta {
@@ -282,11 +274,37 @@ a.gist-card {
color: var(--text-muted); color: var(--text-muted);
} }
@media (max-width: 768px) { @media (max-width: 1024px) {
.gists-layout { .gists-layout {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.gists-grid { }
grid-template-columns: 1fr;
@media (max-width: 480px) {
.gists-header {
flex-direction: column;
align-items: flex-start;
gap: 0.375rem;
}
.gist-card {
padding: 1rem;
}
.gist-card-title {
font-size: 0.9375rem;
}
.gist-detail {
padding: 1rem;
}
.gist-detail-header {
flex-direction: column;
gap: 0.5rem;
}
.gist-detail-title {
font-size: 1.25rem;
} }
} }
+29
View File
@@ -319,3 +319,32 @@
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
} }
@media (max-width: 480px) {
.landing-features {
grid-template-columns: 1fr;
}
.landing-hero {
padding: 2rem 1rem;
}
.landing-hero h1 {
font-size: 1.75rem;
}
.landing-hero p {
font-size: 1rem;
}
.landing-cta {
width: 100%;
text-align: center;
}
.landing-section-header {
flex-direction: column;
align-items: flex-start;
gap: 0.375rem;
}
}
+52 -8
View File
@@ -169,24 +169,44 @@
} }
.messages-input-area { .messages-input-area {
padding: 1rem; padding: 0.75rem 1rem;
border-top: 1px solid var(--border); border-top: 1px solid var(--border);
display: flex; display: flex;
gap: 0.75rem; align-items: center;
gap: 0.5rem;
} }
.messages-input-area input { .messages-input-area input[type="text"] {
flex: 1; flex: 1;
min-width: 0;
}
.messages-input-area .attachment-upload-container {
flex-shrink: 0;
display: flex;
align-items: center;
}
.messages-input-area .attachment-preview-list {
flex: 0 0 100%;
order: -1;
margin-top: 0;
margin-bottom: 0.5rem;
} }
.messages-send-btn { .messages-send-btn {
padding: 0.5rem 1rem; flex-shrink: 0;
width: 36px;
height: 36px;
padding: 0;
background: var(--accent); background: var(--accent);
color: #fff; color: #fff;
border-radius: var(--radius); border-radius: 50%;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
font-size: 1rem;
transition: background 0.15s;
} }
.messages-send-btn:hover { .messages-send-btn:hover {
@@ -207,6 +227,22 @@
font-size: 0.875rem; font-size: 0.875rem;
} }
.messages-back-btn {
display: none;
background: none;
border: none;
color: var(--text-primary);
font-size: 1.25rem;
padding: 0.25rem;
cursor: pointer;
line-height: 1;
margin-right: 0.25rem;
}
.messages-back-btn:hover {
color: var(--accent);
}
@media (max-width: 768px) { @media (max-width: 768px) {
.messages-layout { .messages-layout {
grid-template-columns: 1fr; grid-template-columns: 1fr;
@@ -214,9 +250,17 @@
min-height: calc(100vh - var(--nav-height) - 2rem); min-height: calc(100vh - var(--nav-height) - 2rem);
} }
.messages-list { .messages-list {
display: none;
}
.messages-list.show {
display: flex; display: flex;
} }
.messages-list.hide {
display: none;
}
.messages-main.hide {
display: none;
}
.messages-back-btn {
display: inline-flex;
align-items: center;
justify-content: center;
}
} }
+24
View File
@@ -259,6 +259,11 @@
font-size: 0.875rem; font-size: 0.875rem;
font-weight: 600; font-weight: 600;
color: var(--text-secondary); color: var(--text-secondary);
background: none;
border: none;
padding: 0;
font-family: inherit;
cursor: pointer;
} }
.news-detail-back:hover { .news-detail-back:hover {
@@ -278,3 +283,22 @@
font-size: 1.25rem; font-size: 1.25rem;
} }
} }
@media (max-width: 480px) {
.news-card-body {
padding: 0.875rem;
}
.news-card-title {
font-size: 0.9375rem;
}
.news-detail-body {
padding: 1rem;
}
.news-detail-actions {
flex-direction: column;
align-items: flex-start;
}
}
+10
View File
@@ -87,4 +87,14 @@
padding: 0 0.25rem 0.5rem; padding: 0 0.25rem 0.5rem;
} }
@media (max-width: 480px) {
.notification-card {
padding: 0.75rem;
}
.notification-text {
font-size: 0.8125rem;
}
}
+93 -12
View File
@@ -7,8 +7,8 @@
background: var(--bg-card); background: var(--bg-card);
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
padding: 1.5rem; padding: 1rem;
margin-bottom: 1.5rem; margin-bottom: 1rem;
} }
.post-detail-header { .post-detail-header {
@@ -33,6 +33,8 @@
font-size: 0.8125rem; font-size: 0.8125rem;
color: var(--text-muted); color: var(--text-muted);
margin-left: auto; margin-left: auto;
padding-left: 0.75rem;
line-height: 1.5;
} }
.post-detail-title { .post-detail-title {
@@ -45,13 +47,14 @@
font-size: 0.9375rem; font-size: 0.9375rem;
color: var(--text-secondary); color: var(--text-secondary);
line-height: 1.7; line-height: 1.7;
margin-top: 0.625rem;
margin-bottom: 1rem; margin-bottom: 1rem;
} }
.post-detail-actions { .post-detail-actions {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.5rem; gap: 1rem;
padding-top: 1rem; padding-top: 1rem;
border-top: 1px solid var(--border); border-top: 1px solid var(--border);
} }
@@ -60,7 +63,7 @@
background: var(--bg-card); background: var(--bg-card);
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
padding: 1.5rem; padding: 1rem;
} }
.comments-section h3 { .comments-section h3 {
@@ -72,7 +75,7 @@
.comment { .comment {
display: flex; display: flex;
gap: 0.75rem; gap: 0.75rem;
padding: 0.75rem 0; padding: 1rem 0;
position: relative; position: relative;
} }
@@ -160,23 +163,37 @@
margin-top: 1rem; margin-top: 1rem;
padding-top: 1rem; padding-top: 1rem;
border-top: 1px solid var(--border); border-top: 1px solid var(--border);
align-items: center;
}
.comment-form > a {
flex-shrink: 0;
line-height: 0;
} }
.comment-form textarea { .comment-form textarea {
flex: 1; flex: 1;
min-width: 0;
min-height: 44px; min-height: 44px;
max-height: 120px; max-height: 120px;
} }
.comment-form-actions { .comment-form-actions {
display: flex; display: flex;
align-items: flex-end; flex-wrap: wrap;
gap: 0.25rem; align-items: center;
flex-shrink: 0;
} }
.comment-form-actions button { .comment-form-actions button {
padding: 0.5rem; padding: 0.375rem 0.5rem;
font-size: 1rem;
line-height: 1;
color: var(--text-muted); color: var(--text-muted);
background: none;
border: none;
border-radius: var(--radius);
cursor: pointer;
transition: color 0.2s; transition: color 0.2s;
} }
@@ -184,16 +201,16 @@
color: var(--text-primary); color: var(--text-primary);
} }
.comment-form-submit { button.comment-form-submit {
padding: 0.5rem 1rem; padding: 0.375rem 0.5rem;
background: var(--accent); background: var(--accent);
color: #fff; color: #fff;
border-radius: var(--radius);
font-weight: 600; font-weight: 600;
font-size: 0.8125rem; font-size: 0.8125rem;
line-height: 1.4;
} }
.comment-form-submit:hover { button.comment-form-submit:hover {
background: var(--accent-hover); background: var(--accent-hover);
} }
@@ -210,6 +227,7 @@
background: none; background: none;
border: none; border: none;
cursor: pointer; cursor: pointer;
line-height: 1;
} }
.post-action-btn:hover { .post-action-btn:hover {
@@ -221,6 +239,24 @@
color: var(--accent); color: var(--accent);
} }
.post-action-btn.vote-down {
color: var(--danger);
}
.post-votes {
display: inline-flex;
align-items: center;
gap: 0.125rem;
}
.post-vote-count {
font-weight: 600;
font-size: 0.8125rem;
color: var(--text-secondary);
min-width: 1.25rem;
text-align: center;
}
.comment-thread-line { .comment-thread-line {
position: absolute; position: absolute;
left: 16px; left: 16px;
@@ -229,3 +265,48 @@
width: 2px; width: 2px;
background: var(--border); background: var(--border);
} }
@media (max-width: 480px) {
.post-detail {
padding: 0.75rem;
}
.post-detail-header {
gap: 0.5rem;
}
.post-detail-actions {
flex-wrap: wrap;
gap: 0.375rem;
}
.comments-section {
padding: 0.75rem;
}
.comment {
gap: 0.5rem;
}
.comment-votes {
min-width: 28px;
}
.comment-replies {
padding-left: 0.25rem;
}
}
@media (max-width: 360px) {
.comment-form {
flex-wrap: wrap;
}
.comment-form > a {
display: none;
}
.comment-form textarea {
min-width: 100%;
}
}
+38
View File
@@ -193,3 +193,41 @@
position: static; position: static;
} }
} }
@media (max-width: 480px) {
.profile-tabs {
overflow-x: auto;
flex-wrap: nowrap;
-webkit-overflow-scrolling: touch;
gap: 0.125rem;
}
.profile-tab {
padding: 0.375rem 0.625rem;
font-size: 0.75rem;
flex-shrink: 0;
}
.profile-card {
padding: 1rem;
}
.profile-name {
font-size: 1.125rem;
}
.profile-stats {
gap: 0.75rem;
}
.profile-stat-value {
font-size: 1.125rem;
}
}
@media (max-width: 360px) {
.profile-stats {
flex-wrap: wrap;
gap: 0.5rem;
}
}
+34
View File
@@ -68,6 +68,7 @@
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
padding: 1.25rem; padding: 1.25rem;
transition: border-color 0.2s; transition: border-color 0.2s;
cursor: pointer;
} }
.project-card:hover { .project-card:hover {
@@ -167,4 +168,37 @@
.projects-grid { .projects-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.projects-header {
flex-direction: column;
align-items: flex-start;
gap: 0.375rem;
}
}
@media (max-width: 480px) {
.projects-tabs {
overflow-x: auto;
flex-wrap: nowrap;
-webkit-overflow-scrolling: touch;
gap: 0.125rem;
}
.projects-tab {
padding: 0.375rem 0.625rem;
font-size: 0.75rem;
flex-shrink: 0;
}
.projects-count {
font-size: 0.75rem;
}
.project-card {
padding: 1rem;
}
.project-card-title {
font-size: 1rem;
}
} }
+19
View File
@@ -100,3 +100,22 @@
text-align: center; text-align: center;
padding: 40px 0; padding: 40px 0;
} }
@media (max-width: 480px) {
.services-page {
padding: 16px 8px;
}
.service-header {
flex-wrap: wrap;
}
.service-meta {
flex-direction: column;
gap: 4px;
}
.meta-item {
white-space: normal;
}
}
+19 -446
View File
@@ -1,452 +1,25 @@
import { ModalManager } from "./ModalManager.js";
import { FormManager } from "./FormManager.js";
import { VoteManager } from "./VoteManager.js";
import { MessageSearch } from "./MessageSearch.js";
import { ProfileEditor } from "./ProfileEditor.js";
import { MobileNav } from "./MobileNav.js";
import { CommentManager } from "./CommentManager.js";
import { ContentEnhancer } from "./ContentEnhancer.js";
import { DomUtils } from "./DomUtils.js";
class Application { class Application {
constructor() { constructor() {
this.initPasswordToggles(); this.modals = new ModalManager();
this.initModals(); this.forms = new FormManager();
this.initPostForm(); this.votes = new VoteManager();
this.initCommentForms(); this.messageSearch = new MessageSearch();
this.initVoteButtons(); this.profile = new ProfileEditor();
this.initMessageSearch(); this.mobileNav = new MobileNav();
this.initNotificationDismiss(); this.comments = new CommentManager();
this.initProfileEdit(); this.content = new ContentEnhancer();
this.dom = new DomUtils();
this.initFormDisable();
this.initContentRenderer();
this.initCommentReply();
this.initEmojiPickers();
this.initMentionInputs();
this.initConfirmations();
this.initImageFallbacks();
this.initClipboardCopy();
this.initAutoSubmitSelects();
this.initTogglers();
this.initStopPropagation();
this.initMessageThread();
this.initPlatformTags();
this.initAttachmentUploaders();
} }
loadCSS(href) {
if (document.querySelector(`link[href="${href}"]`)) {
return;
}
const link = document.createElement("link");
link.rel = "stylesheet";
link.href = href;
document.head.appendChild(link);
}
initPasswordToggles() {
document.querySelectorAll(".auth-toggle-pw").forEach((btn) => {
btn.addEventListener("click", () => {
const input = btn.parentElement.querySelector("input");
if (!input) {
return;
}
const type = input.type === "password" ? "text" : "password";
input.type = type;
btn.textContent = type === "password" ? "\u{1F441}" : "\u{1F441}";
});
});
}
initModals() {
document.querySelectorAll("[data-modal]").forEach((trigger) => {
trigger.addEventListener("click", (e) => {
e.preventDefault();
const modalId = trigger.dataset.modal;
const modal = document.getElementById(modalId);
if (modal) {
modal.classList.add("visible");
}
});
});
document.querySelectorAll(".modal-overlay").forEach((modal) => {
modal.addEventListener("click", (e) => {
if (e.target === modal) {
modal.classList.remove("visible");
}
});
modal.querySelectorAll(".modal-close").forEach((closeBtn) => {
closeBtn.addEventListener("click", () => {
modal.classList.remove("visible");
});
});
});
}
initPostForm() {
const form = document.getElementById("create-post-form");
if (!form) {
return;
}
const content = form.querySelector("#post-content");
const title = form.querySelector("#post-title");
const contentCount = form.querySelector("#post-content-count");
const titleCount = form.querySelector("#post-title-count");
if (content && contentCount) {
content.addEventListener("input", () => {
contentCount.textContent = `${content.value.length}/2000`;
});
}
if (title && titleCount) {
title.addEventListener("input", () => {
titleCount.textContent = `${title.value.length}/500`;
});
}
form.addEventListener("submit", () => {
const btn = form.querySelector("button[type='submit']");
if (btn) {
btn.disabled = true;
btn.textContent = "Posting...";
}
});
}
initFormDisable() {
document.querySelectorAll("form").forEach((form) => {
form.addEventListener("submit", () => {
const btn = form.querySelector("button[type='submit']");
if (btn) {
btn.disabled = true;
}
});
});
}
initCommentForms() {
document.querySelectorAll(".comment-form").forEach((form) => {
const textarea = form.querySelector("textarea");
if (!textarea) {
return;
}
textarea.addEventListener("input", () => {
textarea.style.height = "auto";
textarea.style.height = `${Math.min(textarea.scrollHeight, 120)}px`;
});
});
}
initVoteButtons() {
document.querySelectorAll(".post-action-btn[data-vote]").forEach((btn) => {
btn.addEventListener("click", async () => {
const targetUid = btn.dataset.target;
const targetType = btn.dataset.type || "post";
const value = btn.dataset.vote;
const form = document.createElement("form");
form.method = "POST";
form.action = `/votes/${targetType}/${targetUid}`;
const input = document.createElement("input");
input.type = "hidden";
input.name = "value";
input.value = value;
form.appendChild(input);
document.body.appendChild(form);
form.submit();
});
});
}
initMessageSearch() {
const searchInput = document.getElementById("message-search");
if (!searchInput) {
return;
}
const wrap = searchInput.parentElement;
const dropdown = document.createElement("div");
dropdown.className = "search-dropdown";
wrap.appendChild(dropdown);
let debounceTimer = null;
searchInput.addEventListener("input", () => {
clearTimeout(debounceTimer);
const q = searchInput.value.trim();
if (q.length < 1) {
dropdown.innerHTML = "";
dropdown.style.display = "none";
return;
}
debounceTimer = setTimeout(async () => {
try {
const resp = await fetch(`/messages/search?q=${encodeURIComponent(q)}`);
const data = await resp.json();
const results = data.results || [];
if (results.length === 0) {
dropdown.style.display = "none";
return;
}
dropdown.innerHTML = "";
for (const r of results) {
const item = document.createElement("a");
item.className = "search-dropdown-item";
item.href = `/messages?with_uid=${r.uid}`;
item.innerHTML = `<img src="/avatar/multiavatar/${encodeURIComponent(r.username)}?size=24" class="avatar-img" style="width:24px;height:24px;border-radius:50%" alt="" loading="lazy"><span>${r.username}</span>`;
dropdown.appendChild(item);
}
dropdown.style.display = "block";
} catch (e) {
// silently fail — no suggestions
}
}, 200);
});
searchInput.addEventListener("keydown", (e) => {
if (e.key === "Enter") {
const first = dropdown.querySelector(".search-dropdown-item");
if (first) {
window.location.href = first.href;
}
}
});
document.addEventListener("click", (e) => {
if (!wrap.contains(e.target)) {
dropdown.style.display = "none";
}
});
}
initNotificationDismiss() {
document.querySelectorAll(".notification-dismiss").forEach((btn) => {
btn.addEventListener("click", async () => {
const uid = btn.dataset.uid;
if (!uid) {
return;
}
const form = document.createElement("form");
form.method = "POST";
form.action = `/notifications/mark-read/${uid}`;
document.body.appendChild(form);
form.submit();
});
});
}
initProfileEdit() {
document.querySelectorAll("[data-edit-field]").forEach((btn) => {
btn.addEventListener("click", () => {
const field = btn.dataset.editField;
const display = document.getElementById(`display-${field}`);
const input = document.getElementById(`input-${field}`);
if (!display || !input) {
return;
}
display.classList.toggle("hidden");
input.classList.toggle("hidden");
if (!input.classList.contains("hidden")) {
input.focus();
}
});
});
}
initContentRenderer() {
document.querySelectorAll("[data-render]").forEach((el) => {
window.contentRenderer.applyTo(el);
});
window.contentRenderer.highlightAll();
}
initCommentReply() {
document.querySelectorAll(".comment-action-btn").forEach((btn) => {
if (btn.textContent.trim() !== "Reply") return;
btn.addEventListener("click", (e) => {
e.preventDefault();
const comment = btn.closest(".comment");
const commentForm = document.querySelector(".comment-form");
if (!comment || !commentForm) return;
const textarea = commentForm.querySelector("textarea");
if (!textarea) return;
let parentInput = commentForm.querySelector('input[name="parent_uid"]');
if (!parentInput) {
parentInput = document.createElement("input");
parentInput.type = "hidden";
parentInput.name = "parent_uid";
commentForm.appendChild(parentInput);
}
const commentBody = comment.querySelector(".comment-body");
if (commentBody && commentBody.dataset.commentUid) {
parentInput.value = commentBody.dataset.commentUid;
}
textarea.focus();
textarea.scrollIntoView({ behavior: "smooth" });
});
});
}
initMentionInputs() {
if (!window.MentionInput) return;
document.querySelectorAll("[data-mention]").forEach((el) => {
if (el.dataset.mentionInitialized) return;
el.dataset.mentionInitialized = "true";
new window.MentionInput(el);
});
}
initConfirmations() {
document.querySelectorAll("[data-confirm]").forEach((btn) => {
btn.addEventListener("click", (e) => {
if (!confirm(btn.dataset.confirm)) {
e.preventDefault();
}
});
});
}
initImageFallbacks() {
document.querySelectorAll(".image-fallback").forEach((img) => {
img.addEventListener("error", () => {
const container = img.closest(".news-card-image, .news-detail-image") || img.parentElement;
if (container) container.style.display = "none";
});
});
}
initClipboardCopy() {
document.querySelectorAll("[data-copy]").forEach((btn) => {
btn.addEventListener("click", async () => {
const source = document.getElementById(btn.dataset.copy);
if (!source) return;
try {
await navigator.clipboard.writeText(source.textContent);
const original = btn.textContent;
btn.textContent = "Copied!";
setTimeout(() => { btn.textContent = original; }, 2000);
} catch {
// silently fail
}
});
});
}
initAutoSubmitSelects() {
document.querySelectorAll("[data-auto-submit]").forEach((select) => {
select.addEventListener("change", () => {
const form = select.closest("form");
if (form) form.submit();
});
});
}
initTogglers() {
document.querySelectorAll("[data-toggle]").forEach((btn) => {
btn.addEventListener("click", () => {
const target = document.getElementById(btn.dataset.toggle);
if (target) target.classList.toggle("hidden");
});
});
}
initStopPropagation() {
document.querySelectorAll("[data-stop-propagation]").forEach((el) => {
el.addEventListener("click", (e) => e.stopPropagation());
});
}
initMessageThread() {
const thread = document.querySelector(".messages-thread");
if (thread) thread.scrollTop = thread.scrollHeight;
}
initPlatformTags() {
const platformsInput = document.getElementById("platforms-input");
const hiddenInput = document.getElementById("platforms");
const tagsContainer = document.getElementById("platforms-tags");
if (!platformsInput || !hiddenInput || !tagsContainer) return;
const addPlatform = (val) => {
val = val.trim();
if (!val) return;
const tag = document.createElement("span");
tag.className = "platform-tag";
tag.textContent = val;
const remove = document.createElement("button");
remove.type = "button";
remove.textContent = "x";
remove.style.marginLeft = "4px";
remove.style.fontSize = "0.75rem";
remove.style.padding = "0";
remove.style.background = "none";
remove.style.border = "none";
remove.style.color = "inherit";
remove.style.cursor = "pointer";
remove.addEventListener("click", () => {
tag.remove();
updatePlatforms();
});
tag.appendChild(remove);
tagsContainer.appendChild(tag);
platformsInput.value = "";
updatePlatforms();
};
const updatePlatforms = () => {
const values = [];
tagsContainer.querySelectorAll(".platform-tag").forEach((t) => {
values.push(t.textContent.replace("x", "").trim());
});
hiddenInput.value = values.join(",");
};
platformsInput.addEventListener("keydown", (e) => {
if (e.key === "Enter") {
e.preventDefault();
addPlatform(platformsInput.value);
}
});
document.querySelectorAll(".platform-preset").forEach((btn) => {
btn.addEventListener("click", () => {
addPlatform(btn.dataset.platform);
});
});
}
initAttachmentManagers() {
if (!window.AttachmentUploader) return;
document.querySelectorAll(".attachment-upload-container").forEach((container) => {
if (container.dataset.attachmentInitialized) return;
container.dataset.attachmentInitialized = "true";
const form = container.closest("form");
if (form) {
form.dataset.maxSize = container.dataset.maxSize;
form.dataset.maxFiles = container.dataset.maxFiles;
form.dataset.allowedTypes = container.dataset.allowedTypes || "";
new window.AttachmentUploader(form);
}
});
}
initEmojiPickers() {
document.querySelectorAll(".comment-form textarea, .emoji-picker-target").forEach((textarea) => {
if (textarea.dataset.emojiInitialized) return;
textarea.dataset.emojiInitialized = "true";
const btn = document.createElement("button");
btn.type = "button";
btn.className = "emoji-toggle-btn";
btn.title = "Add emoji";
btn.innerHTML = "\u{1F600}";
const picker = new window.EmojiPicker(textarea);
btn.addEventListener("click", () => picker.toggle());
const parent = textarea.parentElement;
if (parent) {
const actions = parent.querySelector(".comment-form-actions");
if (actions) {
actions.insertBefore(btn, actions.firstChild);
}
}
});
}
} }
const app = new Application(); const app = new Application();
+180
View File
@@ -0,0 +1,180 @@
export class AttachmentUploader {
constructor(form) {
this.form = form;
this.attachments = [];
this.maxSize = parseInt(form.dataset.maxSize || "10", 10) * 1024 * 1024;
this.maxFiles = parseInt(form.dataset.maxFiles || "10", 10);
this.allowedTypes = (form.dataset.allowedTypes || "").split(",").map((t) => t.trim()).filter(Boolean);
this.container = form.querySelector(".attachment-upload-container");
if (!this.container) return;
this.uploadedUidsInput = null;
this.initZone();
}
initZone() {
this.button = document.createElement("button");
this.button.type = "button";
this.button.className = "attachment-upload-btn";
this.button.innerHTML = "&#x1F4CE;";
this.button.title = "Attach files";
const input = document.createElement("input");
input.type = "file";
input.multiple = true;
input.style.display = "none";
input.addEventListener("change", () => this.handleFiles(input.files));
this.button.addEventListener("click", (e) => {
e.preventDefault();
input.click();
});
this.button.addEventListener("dragover", (e) => {
e.preventDefault();
this.button.classList.add("dragover");
});
this.button.addEventListener("dragleave", () => {
this.button.classList.remove("dragover");
});
this.button.addEventListener("drop", (e) => {
e.preventDefault();
this.button.classList.remove("dragover");
this.handleFiles(e.dataTransfer.files);
});
this.previewList = document.createElement("div");
this.previewList.className = "attachment-preview-list";
this.errorEl = document.createElement("div");
this.errorEl.className = "attachment-upload-error";
this.container.appendChild(this.button);
this.container.appendChild(input);
this.container.after(this.errorEl);
this.container.after(this.previewList);
this.uploadedUidsInput = document.createElement("input");
this.uploadedUidsInput.type = "hidden";
this.uploadedUidsInput.name = "attachment_uids";
this.form.appendChild(this.uploadedUidsInput);
}
async handleFiles(files) {
this.errorEl.textContent = "";
const remaining = this.maxFiles - this.attachments.length;
if (files.length > remaining) {
this.showError(`You can only add ${remaining} more file(s).`);
return;
}
for (const file of files) {
if (file.size > this.maxSize) {
this.showError(`"${file.name}" exceeds the ${this.form.dataset.maxSize || 10}MB limit.`);
continue;
}
if (this.allowedTypes.length > 0) {
const ext = "." + file.name.split(".").pop().toLowerCase();
if (!this.allowedTypes.includes(ext)) {
this.showError(`"${file.name}" type is not allowed.`);
continue;
}
}
await this.uploadFile(file);
}
}
async uploadFile(file) {
this.button.classList.add("uploading");
const preview = this.createPreview(file);
this.previewList.appendChild(preview);
const progressBar = preview.querySelector(".attachment-progress-bar");
const formData = new FormData();
formData.append("file", file);
try {
const xhr = new XMLHttpRequest();
xhr.open("POST", "/uploads/upload");
xhr.upload.addEventListener("progress", (e) => {
if (e.lengthComputable && progressBar) {
const pct = Math.round((e.loaded / e.total) * 100);
progressBar.style.width = pct + "%";
}
});
const result = await new Promise((resolve, reject) => {
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
try { resolve(JSON.parse(xhr.responseText)); }
catch { reject(new Error("Invalid response")); }
} else {
reject(new Error(xhr.responseText || "Upload failed"));
}
};
xhr.onerror = () => reject(new Error("Network error"));
xhr.send(formData);
});
if (result && result.uid) {
this.attachments.push(result);
this.updateUids();
preview.dataset.uid = result.uid;
if (result.thumbnail_url) {
const img = preview.querySelector("img");
if (img) img.src = result.thumbnail_url;
}
}
} catch (err) {
this.showError(err.message);
preview.remove();
} finally {
this.button.classList.remove("uploading");
}
}
createPreview(file) {
const div = document.createElement("div");
div.className = "attachment-preview";
const isImage = file.type && file.type.startsWith("image/");
if (isImage) {
const img = document.createElement("img");
img.src = URL.createObjectURL(file);
img.alt = file.name;
div.appendChild(img);
} else {
const iconDiv = document.createElement("div");
iconDiv.className = "file-icon";
iconDiv.innerHTML = `<span class="icon">&#x1F4CE;</span><span>${file.name}</span>`;
div.appendChild(iconDiv);
}
const remove = document.createElement("button");
remove.type = "button";
remove.className = "attachment-remove";
remove.innerHTML = "&times;";
remove.addEventListener("click", () => {
const idx = this.attachments.findIndex((a) => a.uid === div.dataset.uid);
if (idx !== -1) {
this.attachments.splice(idx, 1);
this.updateUids();
}
div.remove();
});
div.appendChild(remove);
const progress = document.createElement("div");
progress.className = "attachment-progress";
progress.innerHTML = '<div class="attachment-progress-bar" style="width:0%"></div>';
div.appendChild(progress);
return div;
}
updateUids() {
const uids = this.attachments.map((a) => a.uid);
this.uploadedUidsInput.value = uids.join(",");
}
showError(msg) {
this.errorEl.textContent = msg;
setTimeout(() => { if (this.errorEl.textContent === msg) this.errorEl.textContent = ""; }, 5000);
}
}
window.AttachmentUploader = AttachmentUploader;
+31
View File
@@ -0,0 +1,31 @@
export class CommentManager {
constructor() {
this.initCommentReply();
}
initCommentReply() {
document.querySelectorAll("[data-action='reply']").forEach((btn) => {
btn.addEventListener("click", (e) => {
e.preventDefault();
const comment = btn.closest(".comment");
const commentForm = document.querySelector(".comment-form");
if (!comment || !commentForm) return;
const textarea = commentForm.querySelector("textarea");
if (!textarea) return;
let parentInput = commentForm.querySelector('input[name="parent_uid"]');
if (!parentInput) {
parentInput = document.createElement("input");
parentInput.type = "hidden";
parentInput.name = "parent_uid";
commentForm.appendChild(parentInput);
}
const commentBody = comment.querySelector(".comment-body");
if (commentBody && commentBody.dataset.commentUid) {
parentInput.value = commentBody.dataset.commentUid;
}
textarea.focus();
textarea.scrollIntoView({ behavior: "smooth" });
});
});
}
}
+78
View File
@@ -0,0 +1,78 @@
import { contentRenderer } from "./ContentRenderer.js";
import { MentionInput } from "./MentionInput.js";
import { EmojiPicker } from "./EmojiPicker.js";
import { AttachmentUploader } from "./AttachmentUploader.js";
export class ContentEnhancer {
constructor() {
this.initContentRenderer();
this.initEmojiPickers();
this.initMentionInputs();
this.initImageFallbacks();
this.initAttachmentManagers();
}
initContentRenderer() {
document.querySelectorAll("[data-render]").forEach((el) => {
contentRenderer.applyTo(el);
});
contentRenderer.highlightAll();
}
initEmojiPickers() {
document.querySelectorAll(".comment-form textarea, .emoji-picker-target").forEach((textarea) => {
if (textarea.dataset.emojiInitialized) return;
textarea.dataset.emojiInitialized = "true";
const btn = document.createElement("button");
btn.type = "button";
btn.className = "emoji-toggle-btn";
btn.title = "Add emoji";
btn.innerHTML = "\u{1F600}";
const picker = new EmojiPicker(textarea);
btn.addEventListener("click", () => picker.toggle());
const parent = textarea.parentElement;
if (parent) {
const actions = parent.querySelector(".comment-form-actions");
if (actions) {
actions.style.position = "relative";
actions.appendChild(picker.wrapper);
actions.insertBefore(btn, actions.firstChild);
}
}
});
}
initMentionInputs() {
document.querySelectorAll("[data-mention]").forEach((el) => {
if (el.dataset.mentionInitialized) return;
el.dataset.mentionInitialized = "true";
new MentionInput(el);
});
}
initImageFallbacks() {
document.querySelectorAll(".image-fallback").forEach((img) => {
img.addEventListener("error", () => {
const container = img.closest(".news-card-image, .news-detail-image") || img.parentElement;
if (container) container.style.display = "none";
});
});
}
initAttachmentManagers() {
document.querySelectorAll(".attachment-upload-container").forEach((container) => {
if (container.dataset.attachmentInitialized) return;
container.dataset.attachmentInitialized = "true";
const form = container.closest("form");
if (form) {
form.dataset.maxSize = container.dataset.maxSize;
form.dataset.maxFiles = container.dataset.maxFiles;
form.dataset.allowedTypes = container.dataset.allowedTypes || "";
new AttachmentUploader(form);
}
});
}
}
+5 -3
View File
@@ -1,4 +1,4 @@
class ContentRenderer { export class ContentRenderer {
constructor() { constructor() {
this.emojiMap = this.buildEmojiMap(); this.emojiMap = this.buildEmojiMap();
this.imageExtRe = /\.(jpg|jpeg|png|gif|webp|svg|bmp|webp)(\?.*)?$/i; this.imageExtRe = /\.(jpg|jpeg|png|gif|webp|svg|bmp|webp)(\?.*)?$/i;
@@ -125,7 +125,8 @@ class ContentRenderer {
if (m.index > last) { if (m.index > last) {
parts.push({ type: "text", value: text.substring(last, m.index) }); parts.push({ type: "text", value: text.substring(last, m.index) });
} }
const prefix = m[0][0]; const atIdx = m[0].lastIndexOf('@');
const prefix = m[0].slice(0, atIdx);
if (prefix) { if (prefix) {
parts.push({ type: "text", value: prefix }); parts.push({ type: "text", value: prefix });
} }
@@ -194,4 +195,5 @@ class ContentRenderer {
} }
} }
window.contentRenderer = new ContentRenderer(); export const contentRenderer = new ContentRenderer();
window.contentRenderer = contentRenderer;
+69
View File
@@ -0,0 +1,69 @@
export class DomUtils {
constructor() {
this.initClipboardCopy();
this.initShareButtons();
this.initTogglers();
this.initStopPropagation();
this.initCardLinks();
}
initClipboardCopy() {
document.querySelectorAll("[data-copy]").forEach((btn) => {
btn.addEventListener("click", async () => {
const source = document.getElementById(btn.dataset.copy);
if (!source) return;
try {
await navigator.clipboard.writeText(source.textContent);
const original = btn.textContent;
btn.textContent = "Copied!";
setTimeout(() => { btn.textContent = original; }, 2000);
} catch {
// silently fail
}
});
});
}
initShareButtons() {
document.querySelectorAll("[data-share]").forEach((btn) => {
btn.addEventListener("click", async (e) => {
e.preventDefault();
e.stopPropagation();
const url = new URL(btn.dataset.share || window.location.href, window.location.href).href;
try {
await navigator.clipboard.writeText(url);
const original = btn.textContent;
btn.textContent = "Copied!";
setTimeout(() => { btn.textContent = original; }, 1000);
} catch {
// silently fail
}
});
});
}
initTogglers() {
document.querySelectorAll("[data-toggle]").forEach((btn) => {
btn.addEventListener("click", () => {
const target = document.getElementById(btn.dataset.toggle);
if (target) target.classList.toggle("hidden");
});
});
}
initStopPropagation() {
document.querySelectorAll("[data-stop-propagation]").forEach((el) => {
el.addEventListener("click", (e) => e.stopPropagation());
});
}
initCardLinks() {
document.querySelectorAll("[data-href]").forEach((el) => {
el.addEventListener("click", (e) => {
if (e.target.closest("[data-stop-propagation]")) return;
const href = el.dataset.href;
if (href) window.location.href = href;
});
});
}
}
+1 -1
View File
@@ -1,4 +1,4 @@
class EmojiPicker { export class EmojiPicker {
constructor(textarea) { constructor(textarea) {
this.textarea = textarea; this.textarea = textarea;
this.picker = null; this.picker = null;
+71
View File
@@ -0,0 +1,71 @@
export class FormManager {
constructor() {
this.initPostForm();
this.initCommentForms();
this.initFormDisable();
this.initAutoSubmitSelects();
}
initPostForm() {
const form = document.getElementById("create-post-form");
if (!form) {
return;
}
const content = form.querySelector("#post-content");
const title = form.querySelector("#post-title");
const contentCount = form.querySelector("#post-content-count");
const titleCount = form.querySelector("#post-title-count");
if (content && contentCount) {
content.addEventListener("input", () => {
contentCount.textContent = `${content.value.length}/2000`;
});
}
if (title && titleCount) {
title.addEventListener("input", () => {
titleCount.textContent = `${title.value.length}/500`;
});
}
form.addEventListener("submit", () => {
const btn = form.querySelector("button[type='submit']");
if (btn) {
btn.disabled = true;
btn.textContent = "Posting...";
}
});
}
initCommentForms() {
document.querySelectorAll(".comment-form").forEach((form) => {
const textarea = form.querySelector("textarea");
if (!textarea) {
return;
}
textarea.addEventListener("input", () => {
textarea.style.height = "auto";
textarea.style.height = `${Math.min(textarea.scrollHeight, 120)}px`;
});
});
}
initFormDisable() {
document.querySelectorAll("form").forEach((form) => {
form.addEventListener("submit", () => {
const btn = form.querySelector("button[type='submit']");
if (btn) {
btn.disabled = true;
}
});
});
}
initAutoSubmitSelects() {
document.querySelectorAll("[data-auto-submit]").forEach((select) => {
select.addEventListener("change", () => {
const form = select.closest("form");
if (form) form.submit();
});
});
}
}
+9 -6
View File
@@ -1,15 +1,18 @@
class GistEditor { class GistEditor {
constructor() { constructor(textareaId, langSelectId) {
this.editor = null; this.editor = null;
this.init(); this.initialized = false;
this.textareaId = textareaId || "gist-source-editor";
this.langSelectId = langSelectId || "gist-language";
} }
init() { init() {
if (this.initialized) return;
if (typeof CodeMirror === "undefined") return; if (typeof CodeMirror === "undefined") return;
const textarea = document.getElementById("gist-source-editor"); const textarea = document.getElementById(this.textareaId);
if (!textarea || textarea.dataset.cminit) return; if (!textarea) return;
textarea.dataset.cminit = "1"; this.initialized = true;
this.editor = CodeMirror.fromTextArea(textarea, { this.editor = CodeMirror.fromTextArea(textarea, {
lineNumbers: true, lineNumbers: true,
@@ -31,7 +34,7 @@ class GistEditor {
this.editor.setSize(null, 400); this.editor.setSize(null, 400);
const langSelect = document.getElementById("gist-language"); const langSelect = document.getElementById(this.langSelectId);
if (langSelect) { if (langSelect) {
langSelect.addEventListener("change", () => { langSelect.addEventListener("change", () => {
let mode = langSelect.value; let mode = langSelect.value;
+1 -1
View File
@@ -1,4 +1,4 @@
class MentionInput { export class MentionInput {
constructor(element) { constructor(element) {
this.input = element; this.input = element;
this.dropdown = null; this.dropdown = null;
+66
View File
@@ -0,0 +1,66 @@
export class MessageSearch {
constructor() {
this.initMessageSearch();
}
initMessageSearch() {
const searchInput = document.getElementById("message-search");
if (!searchInput) {
return;
}
const wrap = searchInput.parentElement;
const dropdown = document.createElement("div");
dropdown.className = "search-dropdown";
wrap.appendChild(dropdown);
let debounceTimer = null;
searchInput.addEventListener("input", () => {
clearTimeout(debounceTimer);
const q = searchInput.value.trim();
if (q.length < 1) {
dropdown.innerHTML = "";
dropdown.style.display = "none";
return;
}
debounceTimer = setTimeout(async () => {
try {
const resp = await fetch(`/messages/search?q=${encodeURIComponent(q)}`);
const data = await resp.json();
const results = data.results || [];
if (results.length === 0) {
dropdown.style.display = "none";
return;
}
dropdown.innerHTML = "";
for (const r of results) {
const item = document.createElement("a");
item.className = "search-dropdown-item";
item.href = `/messages?with_uid=${r.uid}`;
item.innerHTML = `<img src="/avatar/multiavatar/${encodeURIComponent(r.username)}?size=24" class="avatar-img" style="width:24px;height:24px;border-radius:50%" alt="" loading="lazy"><span>${r.username}</span>`;
dropdown.appendChild(item);
}
dropdown.style.display = "block";
} catch (e) {
// silently fail - no suggestions
}
}, 200);
});
searchInput.addEventListener("keydown", (e) => {
if (e.key === "Enter") {
const first = dropdown.querySelector(".search-dropdown-item");
if (first) {
window.location.href = first.href;
}
}
});
document.addEventListener("click", (e) => {
if (!wrap.contains(e.target)) {
dropdown.style.display = "none";
}
});
}
}
+95
View File
@@ -0,0 +1,95 @@
export class MobileNav {
constructor() {
this.initMobileNav();
this.initMessagesResponsive();
this.initMessageThread();
}
initMobileNav() {
const btn = document.getElementById("hamburger-btn");
const panel = document.getElementById("mobile-panel");
const overlay = document.getElementById("mobile-overlay");
if (!btn || !panel || !overlay) return;
const close = () => {
panel.classList.remove("open");
overlay.style.display = "none";
btn.innerHTML = "☰";
};
const open = () => {
panel.classList.add("open");
overlay.style.display = "block";
btn.innerHTML = "✕";
};
btn.addEventListener("click", () => {
if (panel.classList.contains("open")) {
close();
} else {
open();
}
});
overlay.addEventListener("click", close);
panel.querySelectorAll("a").forEach((link) => {
link.addEventListener("click", close);
});
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && panel.classList.contains("open")) {
close();
}
});
}
initMessagesResponsive() {
const list = document.querySelector(".messages-list");
const main = document.querySelector(".messages-main");
const backBtn = document.getElementById("messages-back-btn");
if (!list || !main) return;
const isMobile = () => window.innerWidth <= 768;
if (isMobile()) {
if (window.location.search.includes("with_uid=")) {
list.classList.add("hide");
main.classList.remove("hide");
} else {
list.classList.remove("hide");
main.classList.add("hide");
}
}
if (backBtn) {
backBtn.addEventListener("click", () => {
if (!isMobile()) return;
list.classList.remove("hide");
main.classList.add("hide");
window.history.replaceState(null, "", "/messages");
});
}
list.querySelectorAll(".conversation-item").forEach((item) => {
item.addEventListener("click", (e) => {
if (!isMobile()) return;
list.classList.add("hide");
main.classList.remove("hide");
});
});
const mq = window.matchMedia("(max-width: 768px)");
mq.addEventListener("change", () => {
if (!isMobile()) {
list.classList.remove("hide");
main.classList.remove("hide");
}
});
}
initMessageThread() {
const thread = document.querySelector(".messages-thread");
if (thread) thread.scrollTop = thread.scrollHeight;
}
}
+57
View File
@@ -0,0 +1,57 @@
export class ModalManager {
constructor() {
this.initPasswordToggles();
this.initModals();
this.initConfirmations();
}
initPasswordToggles() {
document.querySelectorAll(".auth-toggle-pw").forEach((btn) => {
btn.addEventListener("click", () => {
const input = btn.parentElement.querySelector("input");
if (!input) {
return;
}
const type = input.type === "password" ? "text" : "password";
input.type = type;
btn.textContent = type === "password" ? "\u{1F441}" : "\u{1F441}";
});
});
}
initModals() {
document.querySelectorAll("[data-modal]").forEach((trigger) => {
trigger.addEventListener("click", (e) => {
e.preventDefault();
const modalId = trigger.dataset.modal;
const modal = document.getElementById(modalId);
if (modal) {
modal.classList.add("visible");
}
});
});
document.querySelectorAll(".modal-overlay").forEach((modal) => {
modal.addEventListener("click", (e) => {
if (e.target === modal) {
modal.classList.remove("visible");
}
});
modal.querySelectorAll(".modal-close").forEach((closeBtn) => {
closeBtn.addEventListener("click", () => {
modal.classList.remove("visible");
});
});
});
}
initConfirmations() {
document.querySelectorAll("[data-confirm]").forEach((btn) => {
btn.addEventListener("click", (e) => {
if (!confirm(btn.dataset.confirm)) {
e.preventDefault();
}
});
});
}
}
+78
View File
@@ -0,0 +1,78 @@
export class ProfileEditor {
constructor() {
this.initProfileEdit();
this.initPlatformTags();
}
initProfileEdit() {
document.querySelectorAll("[data-edit-field]").forEach((btn) => {
btn.addEventListener("click", () => {
const field = btn.dataset.editField;
const display = document.getElementById(`display-${field}`);
const input = document.getElementById(`input-${field}`);
if (!display || !input) {
return;
}
display.classList.toggle("hidden");
input.classList.toggle("hidden");
if (!input.classList.contains("hidden")) {
input.focus();
}
});
});
}
initPlatformTags() {
const platformsInput = document.getElementById("platforms-input");
const hiddenInput = document.getElementById("platforms");
const tagsContainer = document.getElementById("platforms-tags");
if (!platformsInput || !hiddenInput || !tagsContainer) return;
const addPlatform = (val) => {
val = val.trim();
if (!val) return;
const tag = document.createElement("span");
tag.className = "platform-tag";
tag.textContent = val;
const remove = document.createElement("button");
remove.type = "button";
remove.textContent = "x";
remove.style.marginLeft = "4px";
remove.style.fontSize = "0.75rem";
remove.style.padding = "0";
remove.style.background = "none";
remove.style.border = "none";
remove.style.color = "inherit";
remove.style.cursor = "pointer";
remove.addEventListener("click", () => {
tag.remove();
updatePlatforms();
});
tag.appendChild(remove);
tagsContainer.appendChild(tag);
platformsInput.value = "";
updatePlatforms();
};
const updatePlatforms = () => {
const values = [];
tagsContainer.querySelectorAll(".platform-tag").forEach((t) => {
values.push(t.textContent.replace("x", "").trim());
});
hiddenInput.value = values.join(",");
};
platformsInput.addEventListener("keydown", (e) => {
if (e.key === "Enter") {
e.preventDefault();
addPlatform(platformsInput.value);
}
});
document.querySelectorAll(".platform-preset").forEach((btn) => {
btn.addEventListener("click", () => {
addPlatform(btn.dataset.platform);
});
});
}
}
+43
View File
@@ -0,0 +1,43 @@
export class VoteManager {
constructor() {
this.initVoteButtons();
this.initNotificationDismiss();
}
initVoteButtons() {
document.querySelectorAll(".post-action-btn[data-vote]").forEach((btn) => {
btn.addEventListener("click", async () => {
const targetUid = btn.dataset.target;
const targetType = btn.dataset.type || "post";
const value = btn.dataset.vote;
const form = document.createElement("form");
form.method = "POST";
form.action = `/votes/${targetType}/${targetUid}`;
const input = document.createElement("input");
input.type = "hidden";
input.name = "value";
input.value = value;
form.appendChild(input);
document.body.appendChild(form);
form.submit();
});
});
}
initNotificationDismiss() {
document.querySelectorAll(".notification-dismiss").forEach((btn) => {
btn.addEventListener("click", async () => {
const uid = btn.dataset.uid;
if (!uid) {
return;
}
const form = document.createElement("form");
form.method = "POST";
form.action = `/notifications/mark-read/${uid}`;
document.body.appendChild(form);
form.submit();
});
});
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 297 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

+1
View File
@@ -0,0 +1 @@
pre code.hljs{display:block;overflow-x:auto;padding:1em}code.hljs{padding:3px 5px}.hljs{color:#abb2bf;background:#282c34}.hljs-comment,.hljs-quote{color:#5c6370;font-style:italic}.hljs-doctag,.hljs-formula,.hljs-keyword{color:#c678dd}.hljs-deletion,.hljs-name,.hljs-section,.hljs-selector-tag,.hljs-subst{color:#e06c75}.hljs-literal{color:#56b6c2}.hljs-addition,.hljs-attribute,.hljs-meta .hljs-string,.hljs-regexp,.hljs-string{color:#98c379}.hljs-attr,.hljs-number,.hljs-selector-attr,.hljs-selector-class,.hljs-selector-pseudo,.hljs-template-variable,.hljs-type,.hljs-variable{color:#d19a66}.hljs-bullet,.hljs-link,.hljs-meta,.hljs-selector-id,.hljs-symbol,.hljs-title{color:#61aeee}.hljs-built_in,.hljs-class .hljs-title,.hljs-title.class_{color:#e6c07b}.hljs-emphasis{font-style:italic}.hljs-strong{font-weight:700}.hljs-link{text-decoration:underline}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
!function(e){"object"==typeof exports&&"object"==typeof module?e(require("../../lib/codemirror"),require("../clike/clike")):"function"==typeof define&&define.amd?define(["../../lib/codemirror","../clike/clike"],e):e(CodeMirror)}(function(t){"use strict";var e="this super static final const abstract class extends external factory implements mixin get native set typedef with enum throw rethrow assert break case continue default in return new deferred async await covariant try catch finally do else for if switch while import library export part of show hide is as extension on yield late required sealed base interface when".split(" "),n="try catch finally do else for if switch while".split(" "),i="true false null".split(" "),r="void bool num int double dynamic var String Null Never".split(" ");function o(e){for(var t={},n=0;n<e.length;++n)t[e[n]]=!0;return t}function a(e){return(e.interpolationStack||(e.interpolationStack=[])).pop()}function l(r,e,t,o){var a=!1;if(e.eat(r)){if(!e.eat(r))return"string";a=!0}function n(e,t){for(var n=!1;!e.eol();){if(!o&&!n&&"$"==e.peek())return((i=t).interpolationStack||(i.interpolationStack=[])).push(i.tokenize),t.tokenize=c,"string";var i=e.next();if(i==r&&!n&&(!a||e.match(r+r))){t.tokenize=null;break}n=!o&&!n&&"\\"==i}return"string"}return(t.tokenize=n)(e,t)}function c(e,t){return e.eat("$"),e.eat("{")?t.tokenize=null:t.tokenize=u,null}function u(e,t){return e.eatWhile(/[\w_]/),t.tokenize=a(t),"variable"}t.defineMIME("application/dart",{name:"clike",keywords:o(e),blockKeywords:o(n),builtin:o(r),atoms:o(i),number:/^(?:0x[a-f\d_]+|(?:[\d_]+\.?[\d_]*|\.[\d_]+)(?:e[-+]?[\d_]+)?)/i,hooks:{"@":function(e){return e.eatWhile(/[\w\$_\.]/),"meta"},"'":function(e,t){return l("'",e,t,!1)},'"':function(e,t){return l('"',e,t,!1)},r:function(e,t){var n=e.peek();return("'"==n||'"'==n)&&l(e.next(),e,t,!0)},"}":function(e,t){return 0<((n=t).interpolationStack?n.interpolationStack.length:0)&&(t.tokenize=a(t),null);var n},"/":function(e,t){return!!e.eat("*")&&(t.tokenize=function i(r){return function(e,t){for(var n;n=e.next();){if("*"==n&&e.eat("/")){if(1!=r)return t.tokenize=i(r-1),t.tokenize(e,t);t.tokenize=null;break}if("/"==n&&e.eat("*"))return t.tokenize=i(r+1),t.tokenize(e,t)}return"comment"}}(1),t.tokenize(e,t))},token:function(e,t,n){if("variable"==n&&RegExp("^[_$]*[A-Z][a-zA-Z0-9_$]*$","g").test(e.current()))return"variable-2"}}}),t.registerHelper("hintWords","application/dart",e.concat(i).concat(r)),t.defineMode("dart",function(e){return t.getMode(e,"application/dart")},"clike")});
+1
View File
@@ -0,0 +1 @@
!function(e){"object"==typeof exports&&"object"==typeof module?e(require("../../lib/codemirror")):"function"==typeof define&&define.amd?define(["../../lib/codemirror"],e):e(CodeMirror)}(function(p){"use strict";p.defineMode("go",function(e){var i,o=e.indentUnit,r={break:!0,case:!0,chan:!0,const:!0,continue:!0,default:!0,defer:!0,else:!0,fallthrough:!0,for:!0,func:!0,go:!0,goto:!0,if:!0,import:!0,interface:!0,map:!0,package:!0,range:!0,return:!0,select:!0,struct:!0,switch:!0,type:!0,var:!0,bool:!0,byte:!0,complex64:!0,complex128:!0,float32:!0,float64:!0,int8:!0,int16:!0,int32:!0,int64:!0,string:!0,uint8:!0,uint16:!0,uint32:!0,uint64:!0,int:!0,uint:!0,uintptr:!0,error:!0,rune:!0,any:!0,comparable:!0},a={true:!0,false:!0,iota:!0,nil:!0,append:!0,cap:!0,close:!0,complex:!0,copy:!0,delete:!0,imag:!0,len:!0,make:!0,new:!0,panic:!0,print:!0,println:!0,real:!0,recover:!0},c=/[+\-*&^%:=<>!|\/]/;function u(e,t){var o,n=e.next();if('"'==n||"'"==n||"`"==n)return t.tokenize=(o=n,function(e,t){for(var n,r=!1,i=!1;null!=(n=e.next());){if(n==o&&!r){i=!0;break}r=!r&&"`"!=o&&"\\"==n}return(i||!r&&"`"!=o)&&(t.tokenize=u),"string"}),t.tokenize(e,t);if(/[\d\.]/.test(n))return"."==n?e.match(/^[0-9_]+([eE][\-+]?[0-9_]+)?/):"0"==n?e.match(/^[xX][0-9a-fA-F_]+/)||e.match(/^[0-7_]+/):e.match(/^[0-9_]*\.?[0-9_]*([eE][\-+]?[0-9_]+)?/),"number";if(/[\[\]{}\(\),;\:\.]/.test(n))return i=n,null;if("/"==n){if(e.eat("*"))return(t.tokenize=l)(e,t);if(e.eat("/"))return e.skipToEnd(),"comment"}if(c.test(n))return e.eatWhile(c),"operator";e.eatWhile(/[\w\$_\xa1-\uffff]/);t=e.current();return r.propertyIsEnumerable(t)?("case"!=t&&"default"!=t||(i="case"),"keyword"):a.propertyIsEnumerable(t)?"atom":"variable"}function l(e,t){for(var n,r=!1;n=e.next();){if("/"==n&&r){t.tokenize=u;break}r="*"==n}return"comment"}function f(e,t,n,r,i){this.indented=e,this.column=t,this.type=n,this.align=r,this.prev=i}function s(e,t,n){e.context=new f(e.indented,t,n,null,e.context)}function d(e){var t;e.context.prev&&(")"!=(t=e.context.type)&&"]"!=t&&"}"!=t||(e.indented=e.context.indented),e.context=e.context.prev)}return{startState:function(e){return{tokenize:null,context:new f((e||0)-o,0,"top",!1),indented:0,startOfLine:!0}},token:function(e,t){var n=t.context;if(e.sol()&&(null==n.align&&(n.align=!1),t.indented=e.indentation(),t.startOfLine=!0,"case"==n.type&&(n.type="}")),e.eatSpace())return null;i=null;var r=(t.tokenize||u)(e,t);return"comment"==r||(null==n.align&&(n.align=!0),"{"==i?s(t,e.column(),"}"):"["==i?s(t,e.column(),"]"):"("==i?s(t,e.column(),")"):"case"==i?n.type="case":("}"==i&&"}"==n.type||i==n.type)&&d(t),t.startOfLine=!1),r},indent:function(e,t){if(e.tokenize!=u&&null!=e.tokenize)return p.Pass;var n=e.context,r=t&&t.charAt(0);if("case"==n.type&&/^(?:case|default)\b/.test(t))return e.context.type="}",n.indented;t=r==n.type;return n.align?n.column+(t?0:1):n.indented+(t?0:o)},electricChars:"{}):",closeBrackets:"()[]{}''\"\"``",fold:"brace",blockCommentStart:"/*",blockCommentEnd:"*/",lineComment:"//"}}),p.defineMIME("text/x-go","go")});
@@ -0,0 +1 @@
!function(e){"object"==typeof exports&&"object"==typeof module?e(require("../../lib/codemirror")):"function"==typeof define&&define.amd?define(["../../lib/codemirror"],e):e(CodeMirror)}(function(e){"use strict";e.defineMode("haskell",function(e,i){function a(e,r,t){return r(t),t(e,r)}var o=/[a-z_]/,l=/[A-Z]/,u=/\d/,f=/[0-9A-Fa-f]/,s=/[0-7]/,c=/[a-z_A-Z0-9'\xa1-\uffff]/,d=/[-!#$%&*+.\/<=>?@\\^|~:]/,m=/[(),;[\]`{}]/,h=/[ \t\v\f]/;function p(e,r){if(e.eatWhile(h))return null;var t=e.next();if(m.test(t))return"{"==t&&e.eat("-")?(n="comment",e.eat("#")&&(n="meta"),a(e,r,function i(a,o){if(0==o)return p;return function(e,r){for(var t=o;!e.eol();){var n=e.next();if("{"==n&&e.eat("-"))++t;else if("-"==n&&e.eat("}")&&0==--t)return r(p),a}return r(i(a,t)),a}}(n,1))):null;if("'"==t)return e.eat("\\"),e.next(),e.eat("'")?"string":"string error";if('"'==t)return a(e,r,g);if(l.test(t))return e.eatWhile(c),e.eat(".")?"qualifier":"variable-2";if(o.test(t))return e.eatWhile(c),"variable";if(u.test(t)){if("0"==t){if(e.eat(/[xX]/))return e.eatWhile(f),"integer";if(e.eat(/[oO]/))return e.eatWhile(s),"number"}e.eatWhile(u);var n="number";return e.match(/^\.\d+/)&&(n="number"),e.eat(/[eE]/)&&(n="number",e.eat(/[-+]/),e.eatWhile(u)),n}if("."==t&&e.eat("."))return"keyword";if(d.test(t)){if("-"==t&&e.eat(/-/)&&(e.eatWhile(/-/),!e.eat(d)))return e.skipToEnd(),"comment";n=":"==t?"variable-2":"variable";return e.eatWhile(d),n}return"error"}function g(e,r){for(;!e.eol();){var t=e.next();if('"'==t)return r(p),"string";if("\\"==t){if(e.eol()||e.eat(h))return r(n),"string";e.eat("&")||e.next()}}return r(p),"string error"}function n(e,r){return e.eat("\\")?a(e,r,g):(e.next(),r(p),"error")}var v=function(){var t={};function e(r){return function(){for(var e=0;e<arguments.length;e++)t[arguments[e]]=r}}e("keyword")("case","class","data","default","deriving","do","else","foreign","if","import","in","infix","infixl","infixr","instance","let","module","newtype","of","then","type","where","_"),e("keyword")("..",":","::","=","\\","<-","->","@","~","=>"),e("builtin")("!!","$!","$","&&","+","++","-",".","/","/=","<","<*","<=","<$>","<*>","=<<","==",">",">=",">>",">>=","^","^^","||","*","*>","**"),e("builtin")("Applicative","Bool","Bounded","Char","Double","EQ","Either","Enum","Eq","False","FilePath","Float","Floating","Fractional","Functor","GT","IO","IOError","Int","Integer","Integral","Just","LT","Left","Maybe","Monad","Nothing","Num","Ord","Ordering","Rational","Read","ReadS","Real","RealFloat","RealFrac","Right","Show","ShowS","String","True"),e("builtin")("abs","acos","acosh","all","and","any","appendFile","asTypeOf","asin","asinh","atan","atan2","atanh","break","catch","ceiling","compare","concat","concatMap","const","cos","cosh","curry","cycle","decodeFloat","div","divMod","drop","dropWhile","either","elem","encodeFloat","enumFrom","enumFromThen","enumFromThenTo","enumFromTo","error","even","exp","exponent","fail","filter","flip","floatDigits","floatRadix","floatRange","floor","fmap","foldl","foldl1","foldr","foldr1","fromEnum","fromInteger","fromIntegral","fromRational","fst","gcd","getChar","getContents","getLine","head","id","init","interact","ioError","isDenormalized","isIEEE","isInfinite","isNaN","isNegativeZero","iterate","last","lcm","length","lex","lines","log","logBase","lookup","map","mapM","mapM_","max","maxBound","maximum","maybe","min","minBound","minimum","mod","negate","not","notElem","null","odd","or","otherwise","pi","pred","print","product","properFraction","pure","putChar","putStr","putStrLn","quot","quotRem","read","readFile","readIO","readList","readLn","readParen","reads","readsPrec","realToFrac","recip","rem","repeat","replicate","return","reverse","round","scaleFloat","scanl","scanl1","scanr","scanr1","seq","sequence","sequence_","show","showChar","showList","showParen","showString","shows","showsPrec","significand","signum","sin","sinh","snd","span","splitAt","sqrt","subtract","succ","sum","tail","take","takeWhile","tan","tanh","toEnum","toInteger","toRational","truncate","uncurry","undefined","unlines","until","unwords","unzip","unzip3","userError","words","writeFile","zip","zip3","zipWith","zipWith3");var r=i.overrideKeywords;if(r)for(var n in r)r.hasOwnProperty(n)&&(t[n]=r[n]);return t}();return{startState:function(){return{f:p}},copyState:function(e){return{f:e.f}},token:function(e,r){var t=r.f(e,function(e){r.f=e}),e=e.current();return v.hasOwnProperty(e)?v[e]:t},blockCommentStart:"{-",blockCommentEnd:"-}",lineComment:"--"}}),e.defineMIME("text/x-haskell","haskell")});
@@ -0,0 +1 @@
!function(t){"object"==typeof exports&&"object"==typeof module?t(require("../../lib/codemirror"),require("../xml/xml"),require("../javascript/javascript"),require("../css/css")):"function"==typeof define&&define.amd?define(["../../lib/codemirror","../xml/xml","../javascript/javascript","../css/css"],t):t(CodeMirror)}(function(m){"use strict";var l={script:[["lang",/(javascript|babel)/i,"javascript"],["type",/^(?:text|application)\/(?:x-)?(?:java|ecma)script$|^module$|^$/i,"javascript"],["type",/./,"text/plain"],[null,null,"javascript"]],style:[["lang",/^css$/i,"css"],["type",/^(text\/)?(x-)?(stylesheet|css)$/i,"css"],["type",/./,"text/plain"],[null,null,"css"]]};var a={};function d(t,e){e=t.match(a[t=e]||(a[t]=new RegExp("\\s+"+t+"\\s*=\\s*('|\")?([^'\"]+)('|\")?\\s*")));return e?/^\s*(.*?)\s*$/.exec(e[2])[1]:""}function g(t,e){return new RegExp((e?"^":"")+"</\\s*"+t+"\\s*>","i")}function o(t,e){for(var a in t)for(var n=e[a]||(e[a]=[]),l=t[a],o=l.length-1;0<=o;o--)n.unshift(l[o])}m.defineMode("htmlmixed",function(i,t){var c=m.getMode(i,{name:"xml",htmlMode:!0,multilineTagIndentFactor:t.multilineTagIndentFactor,multilineTagIndentPastTag:t.multilineTagIndentPastTag,allowMissingTagName:t.allowMissingTagName}),s={},e=t&&t.tags,a=t&&t.scriptTypes;if(o(l,s),e&&o(e,s),a)for(var n=a.length-1;0<=n;n--)s.script.unshift(["type",a[n].matches,a[n].mode]);function u(t,e){var a,o,r,n=c.token(t,e.htmlState),l=/\btag\b/.test(n);return l&&!/[<>\s\/]/.test(t.current())&&(a=e.htmlState.tagName&&e.htmlState.tagName.toLowerCase())&&s.hasOwnProperty(a)?e.inTag=a+" ":e.inTag&&l&&/>$/.test(t.current())?(a=/^([\S]+) (.*)/.exec(e.inTag),e.inTag=null,l=">"==t.current()&&function(t,e){for(var a=0;a<t.length;a++){var n=t[a];if(!n[0]||n[1].test(d(e,n[0])))return n[2]}}(s[a[1]],a[2]),l=m.getMode(i,l),o=g(a[1],!0),r=g(a[1],!1),e.token=function(t,e){return t.match(o,!1)?(e.token=u,e.localState=e.localMode=null):(a=t,n=r,t=e.localMode.token(t,e.localState),e=a.current(),-1<(l=e.search(n))?a.backUp(e.length-l):e.match(/<\/?$/)&&(a.backUp(e.length),a.match(n,!1)||a.match(e)),t);var a,n,l},e.localMode=l,e.localState=m.startState(l,c.indent(e.htmlState,"",""))):e.inTag&&(e.inTag+=t.current(),t.eol()&&(e.inTag+=" ")),n}return{startState:function(){return{token:u,inTag:null,localMode:null,localState:null,htmlState:m.startState(c)}},copyState:function(t){var e;return t.localState&&(e=m.copyState(t.localMode,t.localState)),{token:t.token,inTag:t.inTag,localMode:t.localMode,localState:e,htmlState:m.copyState(c,t.htmlState)}},token:function(t,e){return e.token(t,e)},indent:function(t,e,a){return!t.localMode||/^\s*<\//.test(e)?c.indent(t.htmlState,e,a):t.localMode.indent?t.localMode.indent(t.localState,e,a):m.Pass},innerMode:function(t){return{state:t.localState||t.htmlState,mode:t.localMode||c}}}},"xml","javascript","css"),m.defineMIME("text/html","htmlmixed")});
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
!function(e){"object"==typeof exports&&"object"==typeof module?e(require("../../lib/codemirror")):"function"==typeof define&&define.amd?define(["../../lib/codemirror"],e):e(CodeMirror)}(function(e){"use strict";e.defineMode("lua",function(e,t){var n=e.indentUnit;function a(e){return new RegExp("^(?:"+e.join("|")+")$","i")}var r=a(t.specials||[]),o=a(["_G","_VERSION","assert","collectgarbage","dofile","error","getfenv","getmetatable","ipairs","load","loadfile","loadstring","module","next","pairs","pcall","print","rawequal","rawget","rawset","require","select","setfenv","setmetatable","tonumber","tostring","type","unpack","xpcall","coroutine.create","coroutine.resume","coroutine.running","coroutine.status","coroutine.wrap","coroutine.yield","debug.debug","debug.getfenv","debug.gethook","debug.getinfo","debug.getlocal","debug.getmetatable","debug.getregistry","debug.getupvalue","debug.setfenv","debug.sethook","debug.setlocal","debug.setmetatable","debug.setupvalue","debug.traceback","close","flush","lines","read","seek","setvbuf","write","io.close","io.flush","io.input","io.lines","io.open","io.output","io.popen","io.read","io.stderr","io.stdin","io.stdout","io.tmpfile","io.type","io.write","math.abs","math.acos","math.asin","math.atan","math.atan2","math.ceil","math.cos","math.cosh","math.deg","math.exp","math.floor","math.fmod","math.frexp","math.huge","math.ldexp","math.log","math.log10","math.max","math.min","math.modf","math.pi","math.pow","math.rad","math.random","math.randomseed","math.sin","math.sinh","math.sqrt","math.tan","math.tanh","os.clock","os.date","os.difftime","os.execute","os.exit","os.getenv","os.remove","os.rename","os.setlocale","os.time","os.tmpname","package.cpath","package.loaded","package.loaders","package.loadlib","package.path","package.preload","package.seeall","string.byte","string.char","string.dump","string.find","string.format","string.gmatch","string.gsub","string.len","string.lower","string.match","string.rep","string.reverse","string.sub","string.upper","table.concat","table.insert","table.maxn","table.remove","table.sort"]),i=a(["and","break","elseif","false","nil","not","or","return","true","function","end","if","then","else","do","while","repeat","until","for","in","local"]),l=a(["function","if","repeat","do","\\(","{"]),s=a(["end","until","\\)","}"]),u=new RegExp("^(?:"+["end","until","\\)","}","else","elseif"].join("|")+")","i");function c(e){for(var t=0;e.eat("=");)++t;return e.eat("["),t}function m(e,t){var r,n=e.next();return"-"==n&&e.eat("-")?e.eat("[")&&e.eat("[")?(t.cur=d(c(e),"comment"))(e,t):(e.skipToEnd(),"comment"):'"'==n||"'"==n?(t.cur=(r=n,function(e,t){for(var n,a=!1;null!=(n=e.next())&&(n!=r||a);)a=!a&&"\\"==n;return a||(t.cur=m),"string"}))(e,t):"["==n&&/[\[=]/.test(e.peek())?(t.cur=d(c(e),"string"))(e,t):/\d/.test(n)?(e.eatWhile(/[\w.%]/),"number"):/[\w_]/.test(n)?(e.eatWhile(/[\w\\\-_.]/),"variable"):null}function d(r,o){return function(e,t){for(var n,a=null;null!=(n=e.next());)if(null==a)"]"==n&&(a=0);else if("="==n)++a;else{if("]"==n&&a==r){t.cur=m;break}a=null}return o}}return{startState:function(e){return{basecol:e||0,indentDepth:0,cur:m}},token:function(e,t){if(e.eatSpace())return null;var n=t.cur(e,t),e=e.current();return"variable"==n&&(i.test(e)?n="keyword":o.test(e)?n="builtin":r.test(e)&&(n="variable-2")),"comment"!=n&&"string"!=n&&(l.test(e)?++t.indentDepth:s.test(e)&&--t.indentDepth),n},indent:function(e,t){t=u.test(t);return e.basecol+n*(e.indentDepth-(t?1:0))},electricInput:/^\s*(?:end|until|else|\)|\})$/,lineComment:"--",blockCommentStart:"--[[",blockCommentEnd:"]]"}}),e.defineMIME("text/x-lua","lua")});
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
!function(e){"object"==typeof exports&&"object"==typeof module?e(require("../../lib/codemirror")):"function"==typeof define&&define.amd?define(["../../lib/codemirror"],e):e(CodeMirror)}(function(x){"use strict";x.registerHelper("wordChars","r",/[\w.]/),x.defineMode("r",function(r){function e(e){for(var t={},n=0;n<e.length;++n)t[e[n]]=!0;return t}var a,t=["NULL","NA","Inf","NaN","NA_integer_","NA_real_","NA_complex_","NA_character_","TRUE","FALSE"],n=["list","quote","bquote","eval","return","call","parse","deparse"],i=["if","else","repeat","while","function","for","in","next","break"],o=(x.registerHelper("hintWords","r",t.concat(n,i)),e(t)),c=e(n),l=e(i),f=e(["if","else","repeat","while","function","for"]),u=/[+\-*\/^<>=!&|~$:]/;function d(e,t){a=null;var n,i,r=e.next();return"#"==r?(e.skipToEnd(),"comment"):"0"==r&&e.eat("x")?(e.eatWhile(/[\da-f]/i),"number"):"."==r&&e.eat(/\d/)?(e.match(/\d*(?:e[+\-]?\d+)?/),"number"):/\d/.test(r)?(e.match(/\d*(?:\.\d+)?(?:e[+\-]\d+)?L?/),"number"):"'"==r||'"'==r?(t.tokenize=(i=r,function(e,t){var n,r;if(e.eat("\\"))return"x"==(n=e.next())?e.match(/^[a-f0-9]{2}/i):("u"==n||"U"==n)&&e.eat("{")&&e.skipTo("}")?e.next():"u"==n?e.match(/^[a-f0-9]{4}/i):"U"==n?e.match(/^[a-f0-9]{8}/i):/[0-7]/.test(n)&&e.match(/^[0-7]{1,2}/),"string-2";for(;null!=(r=e.next());){if(r==i){t.tokenize=d;break}if("\\"==r){e.backUp(1);break}}return"string"}),"string"):"`"==r?(e.match(/[^`]+`/),"variable-3"):"."==r&&e.match(/.(?:[.]|\d+)/)?"keyword":/[a-zA-Z\.]/.test(r)?(e.eatWhile(/[\w\.]/),n=e.current(),o.propertyIsEnumerable(n)?"atom":l.propertyIsEnumerable(n)?(f.propertyIsEnumerable(n)&&!e.match(/\s*if(\s+|$)/,!1)&&(a="block"),"keyword"):c.propertyIsEnumerable(n)?"builtin":"variable"):"%"==r?(e.skipTo("%")&&e.next(),"operator variable-2"):"<"==r&&e.eat("-")||"<"==r&&e.match("<-")||"-"==r&&e.match(/>>?/)?"operator arrow":"="==r&&t.ctx.argList?"arg-is":u.test(r)?"$"==r?"operator dollar":(e.eatWhile(u),"operator"):/[\(\){}\[\];]/.test(r)&&";"==(a=r)?"semi":null}function s(e,t,n){e.ctx={type:t,indent:e.indent,flags:0,column:n.column(),prev:e.ctx}}function p(e,t){var n=e.ctx;e.ctx={type:n.type,indent:n.indent,flags:n.flags|t,column:n.column,prev:n.prev}}function m(e){e.indent=e.ctx.indent,e.ctx=e.ctx.prev}return{startState:function(){return{tokenize:d,ctx:{type:"top",indent:-r.indentUnit,flags:2},indent:0,afterIdent:!1}},token:function(e,t){if(e.sol()&&(0==(3&t.ctx.flags)&&(t.ctx.flags|=2),4&t.ctx.flags&&m(t),t.indent=e.indentation()),e.eatSpace())return null;var n=t.tokenize(e,t);return"comment"!=n&&0==(2&t.ctx.flags)&&p(t,1),";"!=a&&"{"!=a&&"}"!=a||"block"!=t.ctx.type||m(t),"{"==a?s(t,"}",e):"("==a?(s(t,")",e),t.afterIdent&&(t.ctx.argList=!0)):"["==a?s(t,"]",e):"block"==a?s(t,"block",e):a==t.ctx.type?m(t):"block"==t.ctx.type&&"comment"!=n&&p(t,4),t.afterIdent="variable"==n||"keyword"==n,n},indent:function(e,t){if(e.tokenize!=d)return 0;var t=t&&t.charAt(0),e=e.ctx,n=t==e.type;return"block"==(e=4&e.flags?e.prev:e).type?e.indent+("{"==t?0:r.indentUnit):1&e.flags?e.column+(n?0:1):e.indent+(n?0:r.indentUnit)},lineComment:"#"}}),x.defineMIME("text/x-rsrc","r")});
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
!function(e){"object"==typeof exports&&"object"==typeof module?e(require("../../lib/codemirror"),require("../../addon/mode/simple")):"function"==typeof define&&define.amd?define(["../../lib/codemirror","../../addon/mode/simple"],e):e(CodeMirror)}(function(e){"use strict";e.defineSimpleMode("rust",{start:[{regex:/b?"/,token:"string",next:"string"},{regex:/b?r"/,token:"string",next:"string_raw"},{regex:/b?r#+"/,token:"string",next:"string_raw_hash"},{regex:/'(?:[^'\\]|\\(?:[nrt0'"]|x[\da-fA-F]{2}|u\{[\da-fA-F]{6}\}))'/,token:"string-2"},{regex:/b'(?:[^']|\\(?:['\\nrt0]|x[\da-fA-F]{2}))'/,token:"string-2"},{regex:/(?:(?:[0-9][0-9_]*)(?:(?:[Ee][+-]?[0-9_]+)|\.[0-9_]+(?:[Ee][+-]?[0-9_]+)?)(?:f32|f64)?)|(?:0(?:b[01_]+|(?:o[0-7_]+)|(?:x[0-9a-fA-F_]+))|(?:[0-9][0-9_]*))(?:u8|u16|u32|u64|i8|i16|i32|i64|isize|usize)?/,token:"number"},{regex:/(let(?:\s+mut)?|fn|enum|mod|struct|type|union)(\s+)([a-zA-Z_][a-zA-Z0-9_]*)/,token:["keyword",null,"def"]},{regex:/(?:abstract|alignof|as|async|await|box|break|continue|const|crate|do|dyn|else|enum|extern|fn|for|final|if|impl|in|loop|macro|match|mod|move|offsetof|override|priv|proc|pub|pure|ref|return|self|sizeof|static|struct|super|trait|type|typeof|union|unsafe|unsized|use|virtual|where|while|yield)\b/,token:"keyword"},{regex:/\b(?:Self|isize|usize|char|bool|u8|u16|u32|u64|f16|f32|f64|i8|i16|i32|i64|str|Option)\b/,token:"atom"},{regex:/\b(?:true|false|Some|None|Ok|Err)\b/,token:"builtin"},{regex:/\b(fn)(\s+)([a-zA-Z_][a-zA-Z0-9_]*)/,token:["keyword",null,"def"]},{regex:/#!?\[.*\]/,token:"meta"},{regex:/\/\/.*/,token:"comment"},{regex:/\/\*/,token:"comment",next:"comment"},{regex:/[-+\/*=<>!]+/,token:"operator"},{regex:/[a-zA-Z_]\w*!/,token:"variable-3"},{regex:/[a-zA-Z_]\w*/,token:"variable"},{regex:/[\{\[\(]/,indent:!0},{regex:/[\}\]\)]/,dedent:!0}],string:[{regex:/"/,token:"string",next:"start"},{regex:/(?:[^\\"]|\\(?:.|$))*/,token:"string"}],string_raw:[{regex:/"/,token:"string",next:"start"},{regex:/[^"]*/,token:"string"}],string_raw_hash:[{regex:/"#+/,token:"string",next:"start"},{regex:/(?:[^"]|"(?!#))*/,token:"string"}],comment:[{regex:/.*?\*\//,token:"comment",next:"start"},{regex:/.*/,token:"comment"}],meta:{dontIndentStates:["comment"],electricInput:/^\s*\}$/,blockCommentStart:"/*",blockCommentEnd:"*/",lineComment:"//",fold:"brace"}}),e.defineMIME("text/x-rustsrc","rust"),e.defineMIME("text/rust","rust")});
@@ -0,0 +1 @@
!function(e){"object"==typeof exports&&"object"==typeof module?e(require("../../lib/codemirror")):"function"==typeof define&&define.amd?define(["../../lib/codemirror"],e):e(CodeMirror)}(function(s){"use strict";s.defineMode("shell",function(){var o={};function e(e,t){for(var n=0;n<t.length;n++)o[t[n]]=e}var t=["true","false"],n=["if","then","do","else","elif","while","until","for","in","esac","fi","fin","fil","done","exit","set","unset","export","function"],r=["ab","awk","bash","beep","cat","cc","cd","chown","chmod","chroot","clear","cp","curl","cut","diff","echo","find","gawk","gcc","get","git","grep","hg","kill","killall","ln","ls","make","mkdir","openssl","mv","nc","nl","node","npm","ping","ps","restart","rm","rmdir","sed","service","sh","shopt","shred","source","sort","sleep","ssh","start","stop","su","sudo","svn","tee","telnet","top","touch","vi","vim","wall","wc","wget","who","write","yes","zsh"];function i(e,t){if(e.eatSpace())return null;var n,r=e.sol(),i=e.next();if("\\"===i)return e.next(),null;if("'"===i||'"'===i||"`"===i)return t.tokens.unshift(f(i,"`"===i?"quote":"string")),l(e,t);if("#"===i)return r&&e.eat("!")?(e.skipToEnd(),"meta"):(e.skipToEnd(),"comment");if("$"===i)return t.tokens.unshift(u),l(e,t);if("+"===i||"="===i)return"operator";if("-"===i)return e.eat("-"),e.eatWhile(/\w/),"attribute";if("<"==i){if(e.match("<<"))return"operator";r=e.match(/^<-?\s*['"]?([^'"]*)['"]?/);if(r)return t.tokens.unshift((n=r[1],function(e,t){return e.sol()&&e.string==n&&t.tokens.shift(),e.skipToEnd(),"string-2"})),"string-2"}if(/\d/.test(i)&&(e.eatWhile(/\d/),e.eol()||!/\w/.test(e.peek())))return"number";e.eatWhile(/[\w-]/);t=e.current();return"="===e.peek()&&/\w+/.test(t)?"def":o.hasOwnProperty(t)?o[t]:null}function f(i,o){var s="("==i?")":"{"==i?"}":i;return function(e,t){for(var n,r=!1;null!=(n=e.next());){if(n===s&&!r){t.tokens.shift();break}if("$"===n&&!r&&"'"!==i&&e.peek()!=s){r=!0,e.backUp(1),t.tokens.unshift(u);break}if(!r&&i!==s&&n===i)return t.tokens.unshift(f(i,o)),l(e,t);if(!r&&/['"]/.test(n)&&!/['"]/.test(i)){t.tokens.unshift(function(n,r){return function(e,t){return t.tokens[0]=f(n,r),e.next(),l(e,t)}}(n,"string")),e.backUp(1);break}r=!r&&"\\"===n}return o}}s.registerHelper("hintWords","shell",t.concat(n,r)),e("atom",t),e("keyword",n),e("builtin",r);var u=function(e,t){1<t.tokens.length&&e.eat("$");var n=e.next();return/['"({]/.test(n)?(t.tokens[0]=f(n,"("==n?"quote":"{"==n?"def":"string"),l(e,t)):(/\d/.test(n)||e.eatWhile(/\w/),t.tokens.shift(),"def")};function l(e,t){return(t.tokens[0]||i)(e,t)}return{startState:function(){return{tokens:[]}},token:l,closeBrackets:"()[]{}''\"\"``",lineComment:"#",fold:"brace"}}),s.defineMIME("text/x-sh","shell"),s.defineMIME("application/x-sh","shell")});
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
!function(e){"object"==typeof exports&&"object"==typeof module?e(require("../../lib/codemirror")):"function"==typeof define&&define.amd?define(["../../lib/codemirror"],e):e(CodeMirror)}(function(e){"use strict";function t(e){for(var t={},n=0;n<e.length;n++)t[e[n]]=!0;return t}var i=t(["_","var","let","actor","class","enum","extension","import","protocol","struct","func","typealias","associatedtype","open","public","internal","fileprivate","private","deinit","init","new","override","self","subscript","super","convenience","dynamic","final","indirect","lazy","required","static","unowned","unowned(safe)","unowned(unsafe)","weak","as","is","break","case","continue","default","else","fallthrough","for","guard","if","in","repeat","switch","where","while","defer","return","inout","mutating","nonmutating","isolated","nonisolated","catch","do","rethrows","throw","throws","async","await","try","didSet","get","set","willSet","assignment","associativity","infix","left","none","operator","postfix","precedence","precedencegroup","prefix","right","Any","AnyObject","Type","dynamicType","Self","Protocol","__COLUMN__","__FILE__","__FUNCTION__","__LINE__"]),o=t(["var","let","actor","class","enum","extension","import","protocol","struct","func","typealias","associatedtype","for"]),a=t(["true","false","nil","self","super","_"]),c=t(["Array","Bool","Character","Dictionary","Double","Float","Int","Int8","Int16","Int32","Int64","Never","Optional","Set","String","UInt8","UInt16","UInt32","UInt64","Void"]),u=/^\-?0b[01][01_]*/,d=/^\-?0o[0-7][0-7_]*/,f=/^\-?0x[\dA-Fa-f][\dA-Fa-f_]*(?:(?:\.[\dA-Fa-f][\dA-Fa-f_]*)?[Pp]\-?\d[\d_]*)?/,l=/^\-?\d[\d_]*(?:\.\d[\d_]*)?(?:[Ee]\-?\d[\d_]*)?/,s=/^\$\d+|(`?)[_A-Za-z][_A-Za-z$0-9]*\1/,p=/^\.(?:\$\d+|(`?)[_A-Za-z][_A-Za-z$0-9]*\1)/,m=/^\#[A-Za-z]+/,h=/^@(?:\$\d+|(`?)[_A-Za-z][_A-Za-z$0-9]*\1)/;function _(e,t,n){if(e.sol()&&(t.indented=e.indentation()),e.eatSpace())return null;var r=e.peek();if("/"==r){if(e.match("//"))return e.skipToEnd(),"comment";if(e.match("/*"))return t.tokenize.push(v),v(e,t)}return e.match(m)?"builtin":e.match(h)?"attribute":e.match(u)||e.match(d)||e.match(f)||e.match(l)?"number":e.match(p)?"property":-1<"+-/*%=|&<>~^?!".indexOf(r)?(e.next(),"operator"):-1<":;,.(){}[]".indexOf(r)?(e.next(),e.match(".."),"punctuation"):(r=e.match(/("""|"|')/))?(r=function(e,t,n){var r,i=1==e.length,o=!1;for(;r=t.peek();)if(o){if(t.next(),"("==r)return n.tokenize.push(function(){var r=0;return function(e,t,n){n=_(e,t,n);if("punctuation"==n)if("("==e.current())++r;else if(")"==e.current()){if(0==r)return e.backUp(1),t.tokenize.pop(),t.tokenize[t.tokenize.length-1](e,t);--r}return n}}()),"string";o=!1}else{if(t.match(e))return n.tokenize.pop(),"string";t.next(),o="\\"==r}i&&n.tokenize.pop();return"string"}.bind(null,r[0]),t.tokenize.push(r),r(e,t)):e.match(s)?(r=e.current(),c.hasOwnProperty(r)?"variable-2":a.hasOwnProperty(r)?"atom":i.hasOwnProperty(r)?(o.hasOwnProperty(r)&&(t.prev="define"),"keyword"):"define"==n?"def":"variable"):(e.next(),null)}function v(e,t){for(var n;n=e.next();)if("/"===n&&e.eat("*"))t.tokenize.push(v);else if("*"===n&&e.eat("/")){t.tokenize.pop();break}return"comment"}function x(e,t,n){this.prev=e,this.align=t,this.indented=n}e.defineMode("swift",function(n){return{startState:function(){return{prev:null,context:null,indented:0,tokenize:[]}},token:function(e,t){var n=t.prev;t.prev=null;var r=(t.tokenize[t.tokenize.length-1]||_)(e,t,n);return r&&"comment"!=r?t.prev||(t.prev=r):t.prev=n,"punctuation"==r&&(n=/[\(\[\{]|([\]\)\}])/.exec(e.current()))&&(n[1]?function(e){e.context&&(e.indented=e.context.indented,e.context=e.context.prev)}:function(e,t){t=t.match(/^\s*($|\/[\/\*])/,!1)?null:t.column()+1,e.context=new x(e.context,t,e.indented)})(t,e),r},indent:function(e,t){e=e.context;if(!e)return 0;t=/^[\]\}\)]/.test(t);return null!=e.align?e.align-(t?1:0):e.indented+(t?0:n.indentUnit)},electricInput:/^\s*[\)\}\]]$/,lineComment:"//",blockCommentStart:"/*",blockCommentEnd:"*/",fold:"brace",closeBrackets:"()[]{}''\"\"``"}}),e.defineMIME("text/x-swift","swift")});
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
!function(e){"object"==typeof exports&&"object"==typeof module?e(require("../../lib/codemirror")):"function"==typeof define&&define.amd?define(["../../lib/codemirror"],e):e(CodeMirror)}(function(e){"use strict";e.defineMode("yaml",function(){var n=new RegExp("\\b(("+["true","false","on","off","yes","no"].join(")|(")+"))$","i");return{token:function(e,i){var t=e.peek(),r=i.escaped;if(i.escaped=!1,"#"==t&&(0==e.pos||/\s/.test(e.string.charAt(e.pos-1))))return e.skipToEnd(),"comment";if(e.match(/^('([^']|\\.)*'?|"([^"]|\\.)*"?)/))return"string";if(i.literal&&e.indentation()>i.keyCol)return e.skipToEnd(),"string";if(i.literal&&(i.literal=!1),e.sol()){if(i.keyCol=0,i.pair=!1,i.pairStart=!1,e.match("---"))return"def";if(e.match("..."))return"def";if(e.match(/\s*-\s+/))return"meta"}if(e.match(/^(\{|\}|\[|\])/))return"{"==t?i.inlinePairs++:"}"==t?i.inlinePairs--:"["==t?i.inlineList++:i.inlineList--,"meta";if(0<i.inlineList&&!r&&","==t)return e.next(),"meta";if(0<i.inlinePairs&&!r&&","==t)return i.keyCol=0,i.pair=!1,i.pairStart=!1,e.next(),"meta";if(i.pairStart){if(e.match(/^\s*(\||\>)\s*/))return i.literal=!0,"meta";if(e.match(/^\s*(\&|\*)[a-z0-9\._-]+\b/i))return"variable-2";if(0==i.inlinePairs&&e.match(/^\s*-?[0-9\.\,]+\s?$/))return"number";if(0<i.inlinePairs&&e.match(/^\s*-?[0-9\.\,]+\s?(?=(,|}))/))return"number";if(e.match(n))return"keyword"}return!i.pair&&e.match(/^\s*(?:[,\[\]{}&*!|>'"%@`][^\s'":]|[^\s,\[\]{}#&*!|>'"%@`])[^#:]*(?=:($|\s))/)?(i.pair=!0,i.keyCol=e.indentation(),"atom"):i.pair&&e.match(/^:\s*/)?(i.pairStart=!0,"meta"):(i.pairStart=!1,i.escaped="\\"==t,e.next(),null)},startState:function(){return{pair:!1,pairStart:!1,keyCol:0,inlinePairs:0,inlineList:0,literal:!1,escaped:!1}},lineComment:"#",fold:"indent"}}),e.defineMIME("text/x-yaml","yaml"),e.defineMIME("text/yaml","yaml")});
@@ -0,0 +1 @@
.cm-s-monokai.CodeMirror{background:#272822;color:#f8f8f2}.cm-s-monokai div.CodeMirror-selected{background:#49483e}.cm-s-monokai .CodeMirror-line::selection,.cm-s-monokai .CodeMirror-line>span::selection,.cm-s-monokai .CodeMirror-line>span>span::selection{background:rgba(73,72,62,.99)}.cm-s-monokai .CodeMirror-line::-moz-selection,.cm-s-monokai .CodeMirror-line>span::-moz-selection,.cm-s-monokai .CodeMirror-line>span>span::-moz-selection{background:rgba(73,72,62,.99)}.cm-s-monokai .CodeMirror-gutters{background:#272822;border-right:0}.cm-s-monokai .CodeMirror-guttermarker{color:#fff}.cm-s-monokai .CodeMirror-guttermarker-subtle{color:#d0d0d0}.cm-s-monokai .CodeMirror-linenumber{color:#d0d0d0}.cm-s-monokai .CodeMirror-cursor{border-left:1px solid #f8f8f0}.cm-s-monokai span.cm-comment{color:#75715e}.cm-s-monokai span.cm-atom{color:#ae81ff}.cm-s-monokai span.cm-number{color:#ae81ff}.cm-s-monokai span.cm-comment.cm-attribute{color:#97b757}.cm-s-monokai span.cm-comment.cm-def{color:#bc9262}.cm-s-monokai span.cm-comment.cm-tag{color:#bc6283}.cm-s-monokai span.cm-comment.cm-type{color:#5998a6}.cm-s-monokai span.cm-attribute,.cm-s-monokai span.cm-property{color:#a6e22e}.cm-s-monokai span.cm-keyword{color:#f92672}.cm-s-monokai span.cm-builtin{color:#66d9ef}.cm-s-monokai span.cm-string{color:#e6db74}.cm-s-monokai span.cm-variable{color:#f8f8f2}.cm-s-monokai span.cm-variable-2{color:#9effff}.cm-s-monokai span.cm-type,.cm-s-monokai span.cm-variable-3{color:#66d9ef}.cm-s-monokai span.cm-def{color:#fd971f}.cm-s-monokai span.cm-bracket{color:#f8f8f2}.cm-s-monokai span.cm-tag{color:#f92672}.cm-s-monokai span.cm-header{color:#ae81ff}.cm-s-monokai span.cm-link{color:#ae81ff}.cm-s-monokai span.cm-error{background:#f92672;color:#f8f8f0}.cm-s-monokai .CodeMirror-activeline-background{background:#373831}.cm-s-monokai .CodeMirror-matchingbracket{text-decoration:underline;color:#fff!important}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,3 @@
import Picker from './picker.js'
import Database from './database.js'
export { Picker, Database }
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,22 @@
<div class="attachment-gallery">
{% for att in attachments %}
<div class="attachment-gallery-item">
{% if att.get('is_image') and att.get('thumbnail_url') %}
<img src="{{ att['thumbnail_url'] }}" alt="{{ att.get('original_filename', '') }}" loading="lazy" class="gallery-thumb" data-full="{{ att['url'] }}" data-mime="{{ att.get('mime_type', '') }}">
{% elif att.get('is_image') %}
<img src="{{ att['url'] }}" alt="{{ att.get('original_filename', '') }}" loading="lazy" class="gallery-thumb" data-full="{{ att['url'] }}" data-mime="{{ att.get('mime_type', '') }}">
{% else %}
<a href="{{ att['url'] }}" target="_blank" rel="noopener" class="non-image" download="{{ att.get('original_filename', 'file') }}">
<span class="icon">{{ file_icon_emoji(att.get('original_filename', 'file')) }}</span>
<span class="name">{{ att.get('original_filename', 'file') }}</span>
<span class="size">{{ format_file_size(att.get('file_size', 0)) }}</span>
</a>
{% endif %}
</div>
{% endfor %}
</div>
<div class="attachment-lightbox" id="attachment-lightbox">
<button type="button" class="attachment-lightbox-close">&times;</button>
<img src="" alt="">
</div>

Some files were not shown because too many files have changed in this diff Show More