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
run: |
pip install -e .
pip install playwright
pip install -e ".[dev]"
python -m playwright install chromium --with-deps
- name: Run integration tests
@@ -32,3 +31,7 @@ jobs:
with:
name: failure-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*
.pytest_cache/
.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
@@ -21,7 +21,7 @@ make locust-headless # Locust in headless CLI mode (for CI)
- **Database:** `dataset` (auto-syncs schema, uses `uid` for PKs). SQLite.
- **Auth:** Session cookies (`session` cookie), SHA256+SALT via passlib. No JWT.
- **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)
- **Username:** letters, numbers, hyphens, underscores only. 3-32 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
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.
@@ -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>
```
**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
@@ -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:
```javascript
// CORRECT toggle the .visible class on the modal:
// CORRECT - toggle the .visible class on the modal:
modal.classList.add("visible"); // show
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
@@ -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=[...])`.
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)
**`find()` does NOT accept raw SQL strings.** It takes keyword arguments for equality filters, dict comparison operators, or SQLAlchemy column expressions.
```python
# WRONG causes 500 Internal Server Error:
# WRONG - causes 500 Internal Server Error:
table.find("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})
# CORRECT keyword equality:
# CORRECT - keyword equality:
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"]))
# CORRECT multiple equality filters combined:
# CORRECT - multiple equality filters combined:
table.find(topic="devlog", user_uid=some_uid)
```
@@ -169,25 +169,25 @@ if "comments" not in db.tables:
## 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 `templates.TemplateResponse("name.html", {...})`** from `devplacepy.templating` to render.
- **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}")`
- **`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).
- **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.
## 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).
- 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`).
- `DEVPLACE_DATABASE_URL` env var overrides the SQLite path (used by tests).
- 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`.
## 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.
- **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.
### 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.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).
```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:
```python
# CORRECT scoped to comment:
# CORRECT - scoped to comment:
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')")
```
@@ -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).
- **Cookies are cleared per test via `browser_context.clear_cookies()`** in the `page` fixture.
- **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.
- **Never share a page between two logged-in users** in the same test use separate contexts.
- **`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.
### 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 |
| Tests fail in sequence | Session-scoped context + `clear_cookies()` per test |
| 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)
@@ -318,7 +318,7 @@ Notifications are grouped by time period in `notifications.py` `_group_label()`:
- Within 7 days → **This week**
- 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
@@ -333,7 +333,7 @@ Every post card on the feed now has an inline comment form (`.feed-comment-form`
## 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
@@ -372,8 +372,8 @@ A dedicated `/gists` page for sharing code snippets. Uses `gists` table (auto-cr
### Polymorphic reuse
- **Comments**: Uses `_comment_section.html` with `target_type="gist"` same component as posts/projects
- **Voting**: Uses existing `/votes/gist/{uid}` route updates `gists.stars`
- **Comments**: Uses `_comment_section.html` with `target_type="gist"` - same component as posts/projects
- **Voting**: Uses existing `/votes/gist/{uid}` route - updates `gists.stars`
- **Content rendering**: Description rendered via `ContentRenderer.js` (.rendered-content[data-render])
- **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`)
Abstract class for all services:
- **`name`** unique identifier (used in routing, logs, and DB)
- **`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(message)`** writes to both the buffer and standard `logging`
- **`run_once()`** abstract; override with actual work
- **`start()`** / **`stop()`** asyncio task lifecycle with graceful cancellation (10s timeout)
- **`name`** - unique identifier (used in routing, logs, and DB)
- **`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(message)`** - writes to both the buffer and standard `logging`
- **`run_once()`** - abstract; override with actual work
- **`start()`** / **`stop()`** - asyncio task lifecycle with graceful cancellation (10s timeout)
### `ServiceManager` (`services/manager.py`)
Singleton that manages all registered services:
- `register(service)` add a service
- `start_all()` start all registered services
- `stop_all()` cancel all tasks (called on server shutdown)
- `register(service)` - add a service
- `start_all()` - start all registered services
- `stop_all()` - cancel all tasks (called on server shutdown)
- `list_services()``list[dict]` with name, status, uptime, log buffer
### `NewsService` (`services/news.py`)
@@ -424,8 +424,8 @@ Implements `BaseService`:
- 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
- Threshold configurable in admin settings
- 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
- 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
### Database tables
@@ -433,7 +433,7 @@ Implements `BaseService`:
|-------|---------|
| `news` | All synced articles with `status` (published/draft), `grade`, `slug`, `show_on_landing` |
| `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)
@@ -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:
- **`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) }}`
- **`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
@@ -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:
- **`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
- `per_page = 25`, pagination metadata computed server-side and passed as `pagination` dict
- 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:
- **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
- **Comments:** Uses `_comment_section.html` with `target_type="news"` same component as posts/projects
- **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
- **Comments:** Uses `_comment_section.html` with `target_type="news"` - same component as posts/projects
- **`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
@@ -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:
- 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
- All POST routes (create, comment, vote) remain guarded by `require_user()`
- 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
- 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`
- Redirect with `RedirectResponse(url=..., status_code=302)`
- 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 deletion: cascade related data first (comments → votes → post)
- 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()`
- For clickable avatars: `{% set _user = ... %}{% include "_avatar_link.html" %}`
- 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
@@ -542,7 +542,7 @@ hawk .
Zero errors required.
### Step 5: Run existing tests
### Step 5: Run existing tests (only if asked by user)
```bash
make test
@@ -560,7 +560,7 @@ All tests must pass. Tests stop at first failure (`-x`).
- Test both success paths and error/validation paths
- 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
hawk .
@@ -587,13 +587,9 @@ while feature_not_complete:
1. Plan: read existing code, design the change
2. Implement: write code (router → template → CSS → JS)
3. hawk . # must pass
4. make test # all must pass, -x stops at first failure
5. If tests fail: diagnose → fix → goto 3
6. Update tests if new functionality was added
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
4. falcon take + describe # visual check for UI changes
5. If visual fail: fix CSS/template → goto 3
6. Update AGENTS.md if needed
```
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:
### Core SEO utilities
- `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
- `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
### 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()`
@@ -618,15 +614,15 @@ All SEO features are implemented across the following locations:
- All other pages: `index,follow`
### 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`
- `static/css/base.css` `.breadcrumb` (aria-label breadcrumb nav), `.sr-only` (accessible hidden headings)
- `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)
### Heading hierarchy
- `feed.html` `<h1 class="sr-only">Feed</h1>`
- `profile.html` username rendered as `<h1 class="profile-name">`
- `messages.html` `<h1 class="sr-only">Messages</h1>`
- `projects.html` `<h1>Projects</h1>`
- `post.html` post title as `<h1>`, "Related Discussions" as `<h3>`
- `feed.html` - `<h1 class="sr-only">Feed</h1>`
- `profile.html` - username rendered as `<h1 class="profile-name">`
- `messages.html` - `<h1 class="sr-only">Messages</h1>`
- `projects.html` - `<h1>Projects</h1>`
- `post.html` - post title as `<h1>`, "Related Discussions" as `<h3>`
### Post slugs
- 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
### 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
- `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`
### 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
### 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
@@ -19,4 +19,4 @@ EXPOSE 10500
HEALTHCHECK --interval=30s --timeout=10s --retries=3 --start-period=10s \
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_SPAWN_RATE ?= 5
LOCUST_RUN_TIME ?= 120s
DEVPLACE_RATE_LIMIT ?= 1000000
.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
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:
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:
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:
PLAYWRIGHT_HEADLESS=0 python -m pytest tests/test_demo.py -v -s --tb=line -x
locust:
export DEVPLACE_DATABASE_URL="sqlite:///$(LOCUST_DB)"; \
export DEVPLACE_RATE_LIMIT=$(DEVPLACE_RATE_LIMIT); \
mkdir -p $(LOCUST_DB_DIR); \
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 & \
@@ -39,6 +41,7 @@ locust:
locust-headless:
export DEVPLACE_DATABASE_URL="sqlite:///$(LOCUST_DB)"; \
export DEVPLACE_RATE_LIMIT=$(DEVPLACE_RATE_LIMIT); \
mkdir -p $(LOCUST_DB_DIR); \
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 & \
@@ -70,3 +73,8 @@ docker-logs:
docker-clean:
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.
@@ -81,9 +81,9 @@ devplacepy/
### Service framework
- **`BaseService`** abstract class with async run loop, `deque(maxlen=20)` log buffer, `name`, `interval_seconds`
- **`ServiceManager`** singleton that registers, starts, and stops all services
- **`NewsService`** fetches news from `news.app.molodetz.nl/api`, grades with AI, stores articles >= threshold
- **`BaseService`** - abstract class with async run loop, `deque(maxlen=20)` log buffer, `name`, `interval_seconds`
- **`ServiceManager`** - singleton that registers, starts, and stops all services
- **`NewsService`** - fetches news from `news.app.molodetz.nl/api`, grades with AI, stores articles >= threshold
### 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.
CLI: `devplace news clear` delete all news from local database.
CLI: `devplace news clear` - delete all news from local database.
## Database
@@ -123,12 +123,12 @@ PRAGMA temp_store=MEMORY; -- temp tables in memory
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
- **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
- Test users `alice_test` / `bob_test` seeded via HTTP at session start
- 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
- 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
- `page.locator(...).wait_for(state="visible")` preferred over bare selectors
## 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}`
@@ -159,12 +159,12 @@ Gitea Actions workflow at `.gitea/workflows/test.yaml`:
## Feature workflow
1. Implement the feature (router + template + CSS + JS)
2. `hawk .` validate all source files
3. `make test` run all tests (fail-fast)
2. `hawk .` - validate all source files
3. `make test` - run all tests (fail-fast)
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`
6. Update `AGENTS.md` and `README.md` if new conventions were introduced
## License
MIT DevPlace
MIT - DevPlace
+210 -74
View File
@@ -1,33 +1,95 @@
import logging
from datetime import datetime
from datetime import datetime, timezone
from pathlib import Path
from PIL import Image
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.utils import generate_uid
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_QUALITY = 80
IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".tiff"}
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):
row = get_table("site_settings").find_one(key=key)
return row["value"] if row else default
def _get_max_upload_bytes():
return int(_get_setting("max_upload_size_mb", "10")) * 1024 * 1024
def _directory_for(uid):
return f"{uid[:2]}/{uid[2:4]}"
def _detect_mime(file_bytes, original_filename):
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 m.get(ext, "application/octet-stream")
return ALLOWED_UPLOAD_TYPES.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):
try:
@@ -45,95 +107,169 @@ def _generate_thumbnail(file_bytes, thumb_path):
logger.warning(f"Thumbnail generation failed: {e}")
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):
if len(file_bytes) > _get_max_upload_bytes():
return None
ext = Path(original_filename).suffix.lower()
if ext not in ALLOWED_UPLOAD_TYPES:
return None
uid = generate_uid()
ext = Path(original_filename).suffix.lower() or ".bin"
stored_name = f"{uid}{ext}"
directory = _directory_for(uid)
file_dir = ATTACHMENTS_DIR / directory
file_dir.mkdir(parents=True, exist_ok=True)
(file_dir / stored_name).write_bytes(file_bytes)
mime = _detect_mime(file_bytes, original_filename)
is_img = mime.startswith("image/")
img_w, img_h = None, None
thumb = None
if is_img:
try:
img = Image.open(BytesIO(file_bytes))
img_w, img_h = img.width, img.height
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
is_image = mime.startswith("image/")
image_width, image_height = None, None
thumbnail = None
if is_image:
image_width, image_height = _image_dimensions(file_bytes)
if ext not in (".gif",):
thumbnail = _generate_thumbnail(file_bytes, file_dir / f"{uid}_thumb.jpg")
get_table("attachments").insert({
"uid": uid, "target_type": "", "target_uid": "", "user_uid": user_uid,
"original_filename": original_filename, "stored_name": stored_name,
"directory": directory, "file_size": len(file_bytes), "mime_type": mime,
"image_width": img_w, "image_height": img_h,
"has_thumbnail": 1 if thumb else 0,
"created_at": datetime.utcnow().isoformat(),
"uid": uid,
"target_type": "",
"target_uid": "",
"user_uid": user_uid,
"original_filename": original_filename,
"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):
if not uids: return
atts = get_table("attachments")
for auid in uids:
auid = auid.strip()
if not auid: continue
e = atts.find_one(uid=auid)
if e: atts.update({"id": e["id"], "uid": auid, "target_type": target_type, "target_uid": target_uid}, ["id"])
if not uids:
return
attachments = get_table("attachments")
for uid in uids:
uid = uid.strip()
if not uid:
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):
atts = get_table("attachments")
att = atts.find_one(uid=uid)
if not att: return
sn, d = att.get("stored_name",""), att.get("directory","")
if sn and d:
fp = ATTACHMENTS_DIR / d / sn
try: fp.unlink(missing_ok=True)
except: pass
for tp in (ATTACHMENTS_DIR / d).glob(f"{Path(sn).stem}_thumb.*"):
try: tp.unlink(missing_ok=True)
except: pass
atts.delete(id=att["id"])
attachments = get_table("attachments")
attachment = attachments.find_one(uid=uid)
if not attachment:
return
stored_name = attachment.get("stored_name", "")
directory = attachment.get("directory", "")
if stored_name and directory:
file_path = ATTACHMENTS_DIR / directory / stored_name
try:
file_path.unlink(missing_ok=True)
except Exception as e:
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):
try: rows = list(get_table("attachments").find(target_type=tt, target_uid=tu, order_by=["created_at"]))
except: return []
return [_r2a(r) for r in rows]
def delete_target_attachments(target_type, target_uid):
for attachment in get_table("attachments").find(target_type=target_type, target_uid=target_uid):
delete_attachment(attachment["uid"])
def get_attachments_batch(tt, uids):
if not uids: return {}
from devplacepy.database import db
if "attachments" not in db.tables: return {u:[] for u in uids}
ph = ",".join(f":p{i}" for i in range(len(uids)))
try:
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(target_type, target_uid):
if "attachments" not in db.tables:
return []
rows = list(get_table("attachments").find(target_type=target_type, target_uid=target_uid, order_by=["created_at"]))
return [_row_to_attachment(r) for r in rows]
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:
if row["target_uid"] in r: r[row["target_uid"]].append(_r2a(row))
return r
if row["target_uid"] in result:
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):
if b < 1024: return f"{b} B"
if b < 1048576: return f"{b/1024:.1f} KB"
return f"{b/1048576:.1f} MB"
def _row_to_attachment(row):
stored_name = row.get("stored_name", "")
directory = row.get("directory", "")
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()
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"}
return m.get(ext, "\U0001F4CE")
def format_file_size(size):
if size < 1024:
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:
try:
from multiavatar import multiavatar
svg = multiavatar(seed)
from multiavatar.multiavatar import multiavatar
svg = multiavatar(seed, None, None)
if svg and svg.strip().startswith("<svg"):
return svg
except Exception as e:
logger.exception(e)
logger.warning(f"Avatar generation failed for {seed}: {e}")
initial = seed[:1].upper() if seed else "?"
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 sys
from devplacepy.database import get_table
from devplacepy.utils import strip_html
def cmd_role_get(args):
@@ -32,7 +33,7 @@ def cmd_news_clear(args):
from devplacepy.database import db
for table in ("news", "news_images", "news_sync"):
if table in db.tables:
count = len(list(db[table].all()))
count = db[table].count()
db[table].delete()
print(f"Deleted {count} rows from '{table}'")
else:
@@ -40,6 +41,22 @@ def cmd_news_clear(args):
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):
from devplacepy.database import db
from devplacepy.config import STATIC_DIR
@@ -101,6 +118,8 @@ def main():
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.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")
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")
SESSION_MAX_AGE = 86400 * 7
PORT = 10500
SITE_URL = environ.get("DEVPLACE_SITE_URL", "").rstrip("/")
+68 -14
View File
@@ -1,6 +1,7 @@
import dataset
import logging
from datetime import datetime
from datetime import datetime, timezone
from devplacepy.cache import TTLCache
from devplacepy.config import DATABASE_URL
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_target", ["target_type", "target_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, "messages", "idx_messages_sender", ["sender_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, "gists", "idx_gists_user_uid", ["user_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:
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 {}
placeholders = ", ".join(f":p{i}" for i in range(len(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)
return {r["post_uid"]: r["c"] for r in rows}
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["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):
@@ -175,7 +187,8 @@ def load_comments(target_type, target_uid):
users = get_users_by_uids(uids)
ups, downs = get_vote_counts(cids)
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 = {}
for c in raw:
cmap[c["uid"]] = {
@@ -215,6 +228,17 @@ def get_attachments_by_type(resource_type: str, resource_uids: list) -> dict:
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:
if "attachments" not in db.tables:
return
@@ -249,11 +273,50 @@ def _delete_attachment_file(storage_path: str) -> None:
logger.warning(f"Failed to delete attachment file {storage_path}: {e}")
_settings_cache = TTLCache(ttl=60)
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:
return default
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):
@@ -263,15 +326,6 @@ def resolve_by_slug(table, slug):
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):
total_pages = max(1, __import__("math").ceil(total / per_page))
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.responses import HTMLResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
from fastapi.exceptions import RequestValidationError
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.utils import get_current_user, time_ago
from devplacepy.seo import base_seo_context, site_url, website_schema, breadcrumb_schema, combine
@@ -22,23 +23,77 @@ logging.basicConfig(
logger = logging.getLogger(__name__)
_rate_limit_store = defaultdict(list)
RATE_LIMIT = 60
RATE_LIMIT = int(os.environ.get("DEVPLACE_RATE_LIMIT", "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.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.exception_handler(404)
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")
return templates.TemplateResponse("error.html", {**seo_ctx, "request": request, "error_code": 404, "error_message": "Page not found"}, status_code=404)
seo_ctx = base_seo_context(request, title="Not Found - DevPlace", description="The page you requested does not exist.", robots="noindex")
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)
async def server_error(request: Request, exc):
seo_ctx = base_seo_context(request, title="Server Error — DevPlace", description="Something went wrong.", robots="noindex")
return templates.TemplateResponse("error.html", {**seo_ctx, "request": request, "error_code": 500, "error_message": "Internal server error"}, status_code=500)
logger.exception("500 error on %s %s", request.method, request.url.path)
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(feed.router, prefix="/feed")
@@ -72,7 +127,7 @@ async def add_security_headers(request: Request, call_next):
@app.middleware("http")
async def rate_limit_middleware(request: Request, call_next):
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()
window_start = now - RATE_WINDOW
_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 = []
if "news" in db.tables:
news_table = get_table("news")
raw = list(news_table.find(order_by=["-synced_at"], _limit=6))
images_table = get_table("news_images") if "news_images" in db.tables else None
raw = list(news_table.find(show_on_landing=1, order_by=["-synced_at"], _limit=6))
images_by_news = get_news_images_by_uids([a["uid"] 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({
"uid": a["uid"],
"slug": a.get("slug", ""),
@@ -125,7 +175,7 @@ async def landing(request: Request):
"grade": a.get("grade", 0),
"synced_at": a.get("synced_at", "") or "",
"time_ago": time_ago(a["synced_at"]),
"image_url": image_url,
"image_url": images_by_news.get(a["uid"], ""),
})
landing_posts = []
@@ -151,12 +201,12 @@ async def landing(request: Request):
base = site_url(request)
seo_ctx = base_seo_context(
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.",
breadcrumbs=[],
schemas=[website_schema(base)],
)
return templates.TemplateResponse("landing.html", {
return templates.TemplateResponse(request, "landing.html", {
**seo_ctx, "request": request,
"landing_articles": landing_articles,
"landing_posts": landing_posts,
+155 -29
View File
@@ -1,49 +1,175 @@
from pydantic import BaseModel, Field
from typing import Optional
from datetime import date
from typing import Literal
from pydantic import BaseModel, Field, field_validator, model_validator
from devplacepy.constants import TOPICS
class SignupRequest(BaseModel):
username: str = Field(min_length=3, max_length=32, pattern=r"^[a-zA-Z0-9_-]+$")
email: str = Field(min_length=5, max_length=255)
class SignupForm(BaseModel):
username: str = Field(min_length=3, max_length=32)
email: str = Field(min_length=1, max_length=255)
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):
email: str = Field(min_length=5, max_length=255)
class LoginForm(BaseModel):
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)
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):
content: str = Field(min_length=1, max_length=2000)
title: Optional[str] = Field(default=None, max_length=500)
topic: str = Field(pattern=r"^(devlog|showcase|question|rant|fun|random)$")
project_uid: Optional[str] = None
class PostForm(BaseModel):
content: str = Field(min_length=10, max_length=2000)
title: str = Field(default="", max_length=500)
topic: str = "random"
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):
content: str = Field(min_length=1, max_length=1000)
parent_uid: Optional[str] = None
class PostEditForm(BaseModel):
content: str = Field(min_length=10, max_length=2000)
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)
description: str = Field(min_length=1, max_length=5000)
release_date: Optional[date] = None
demo_date: Optional[date] = None
project_type: str = Field(pattern=r"^(game|game_asset|software|mobile_app|website)$")
release_date: str = ""
demo_date: str = ""
project_type: Literal["game", "game_asset", "software", "mobile_app", "website"] = "software"
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)
receiver_uid: str
receiver_uid: str = Field(min_length=1)
attachment_uids: list[str] = []
class ProfileUpdate(BaseModel):
bio: Optional[str] = Field(default=None, max_length=500)
location: Optional[str] = Field(default=None, max_length=200)
git_link: Optional[str] = Field(default=None, max_length=500)
website: Optional[str] = Field(default=None, max_length=500)
class ProfileForm(BaseModel):
bio: str = Field(default="", max_length=500)
location: str = Field(default="", max_length=200)
git_link: str = Field(default="", 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
from typing import Annotated
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 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.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
logger = logging.getLogger(__name__)
@@ -21,17 +23,17 @@ async def admin_index(request: Request):
async def admin_users(request: Request, page: int = 1):
admin = require_admin(request)
users_table = get_table("users")
total = len(list(users_table.all()))
total = users_table.count()
pagination = build_pagination(page, total)
offset = (pagination["page"] - 1) * pagination["per_page"]
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:
posts_count = len(list(get_table("posts").find(user_uid=u["uid"])))
u["posts_count"] = posts_count
u["posts_count"] = post_counts.get(u["uid"], 0)
base = site_url(request)
seo_ctx = base_seo_context(
request,
title="Users Admin",
title="Users - Admin",
description="Manage DevPlace users.",
breadcrumbs=[
{"name": "Home", "url": "/feed"},
@@ -40,7 +42,7 @@ async def admin_users(request: Request, page: int = 1):
],
schemas=[website_schema(base)],
)
return templates.TemplateResponse("admin_users.html", {
return templates.TemplateResponse(request, "admin_users.html", {
**seo_ctx,
"request": request,
"user": admin,
@@ -51,29 +53,23 @@ async def admin_users(request: Request, page: int = 1):
@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)
form = await request.form()
role = form.get("role", "").strip().capitalize()
if role not in ("Member", "Admin"):
return RedirectResponse(url="/admin/users", status_code=302)
role = data.role.capitalize()
if uid == admin["uid"]:
return RedirectResponse(url="/admin/users", status_code=302)
users = get_table("users")
users.update({"uid": uid, "role": role}, ["uid"])
clear_user_cache(uid)
logger.info(f"Admin {admin['username']} set user {uid} role to {role}")
return RedirectResponse(url="/admin/users", status_code=302)
@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)
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.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}")
return RedirectResponse(url="/admin/users", status_code=302)
@@ -88,6 +84,7 @@ async def admin_user_toggle(request: Request, uid: str):
if user:
new_state = not user.get("is_active", True)
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}")
return RedirectResponse(url="/admin/users", status_code=302)
@@ -100,7 +97,7 @@ async def admin_settings(request: Request):
base = site_url(request)
seo_ctx = base_seo_context(
request,
title="Settings Admin",
title="Settings - Admin",
description="Manage DevPlace site settings.",
breadcrumbs=[
{"name": "Home", "url": "/feed"},
@@ -109,7 +106,7 @@ async def admin_settings(request: Request):
],
schemas=[website_schema(base)],
)
return templates.TemplateResponse("admin_settings.html", {
return templates.TemplateResponse(request, "admin_settings.html", {
**seo_ctx,
"request": request,
"user": admin,
@@ -122,29 +119,26 @@ async def admin_settings(request: Request):
async def admin_news(request: Request, page: int = 1):
admin = require_admin(request)
news_table = get_table("news")
total = len(list(news_table.all()))
total = news_table.count()
pagination = build_pagination(page, total)
offset = (pagination["page"] - 1) * pagination["per_page"]
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 = []
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({
"article": a,
"time_ago": time_ago(a["synced_at"]),
"synced_at": a.get("synced_at", ""),
"grade": a.get("grade", 0),
"has_image": has_image,
"has_image": a["uid"] in images_by_news,
})
base = site_url(request)
seo_ctx = base_seo_context(
request,
title="News Admin",
title="News - Admin",
description="Manage DevPlace news articles.",
breadcrumbs=[
{"name": "Home", "url": "/feed"},
@@ -153,7 +147,7 @@ async def admin_news(request: Request, page: int = 1):
],
schemas=[website_schema(base)],
)
return templates.TemplateResponse("admin_news.html", {
return templates.TemplateResponse(request, "admin_news.html", {
**seo_ctx,
"request": request,
"user": admin,
@@ -215,15 +209,17 @@ async def admin_news_delete(request: Request, uid: str):
@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)
form = await request.form()
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)
if existing:
if value == "":
continue
settings.update({"id": existing["id"], "key": key, "value": value}, ["id"])
else:
settings.insert({"uid": generate_uid(), "key": key, "value": value})
clear_settings_cache()
logger.info(f"Admin {admin['username']} updated settings")
return RedirectResponse(url="/admin/settings", status_code=302)
+39 -68
View File
@@ -1,13 +1,15 @@
import hashlib
import secrets
import logging
from datetime import datetime, timedelta
from fastapi import APIRouter, Request
from typing import Annotated
from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Request, Form
from fastapi.responses import RedirectResponse, HTMLResponse
from devplacepy.database import get_table
from devplacepy.templating import templates
from devplacepy.utils import hash_password, verify_password, create_session, generate_uid, get_current_user
from devplacepy.seo import base_seo_context
from devplacepy.models import SignupForm, LoginForm, ForgotPasswordForm, ResetPasswordForm
logger = logging.getLogger(__name__)
router = APIRouter()
@@ -24,7 +26,7 @@ async def signup_page(request: Request):
description="Create your DevPlace account and start connecting with developers.",
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)
@@ -38,29 +40,16 @@ async def login_page(request: Request):
description="Sign in to DevPlace to connect with developers.",
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")
async def signup(request: Request):
form = await request.form()
username = form.get("username", "").strip()
email = form.get("email", "").strip().lower()
password = form.get("password", "")
confirm_password = form.get("confirm_password", "")
async def signup(request: Request, data: Annotated[SignupForm, Form()]):
username = data.username
email = data.email.strip().lower()
password = data.password
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")
if users.find_one(username=username):
errors.append("Username already taken")
@@ -70,12 +59,12 @@ async def signup(request: Request):
if errors:
seo_ctx = base_seo_context(request, title="Join DevPlace", robots="noindex,nofollow")
return templates.TemplateResponse(
"signup.html",
request, "signup.html",
{**seo_ctx, "request": request, "errors": errors, "username": username, "email": email},
)
uid = generate_uid()
is_first = len(list(users.all())) == 0
is_first = users.count() == 0
users.insert({
"uid": uid,
"username": username,
@@ -90,7 +79,7 @@ async def signup(request: Request):
"level": 1,
"xp": 0,
"stars": 0,
"created_at": datetime.utcnow().isoformat(),
"created_at": datetime.now(timezone.utc).isoformat(),
})
badges = get_table("badges")
@@ -98,7 +87,7 @@ async def signup(request: Request):
"uid": generate_uid(),
"user_uid": uid,
"badge_name": "Member",
"created_at": datetime.utcnow().isoformat(),
"created_at": datetime.now(timezone.utc).isoformat(),
})
token = create_session(uid)
@@ -109,18 +98,14 @@ async def signup(request: Request):
@router.post("/login")
async def login(request: Request):
form = await request.form()
email = form.get("email", "").strip().lower()
password = form.get("password", "")
remember_me = form.get("remember_me") == "on"
async def login(request: Request, data: Annotated[LoginForm, Form()]):
email = data.email.strip().lower()
password = data.password
remember_me = data.remember_me == "on"
errors = []
if not email or not password:
errors.append("Email and password are required")
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"]):
errors.append("Invalid email or password")
@@ -128,7 +113,7 @@ async def login(request: Request):
if errors:
seo_ctx = base_seo_context(request, title="Sign In", robots="noindex,nofollow")
return templates.TemplateResponse(
"login.html",
request, "login.html",
{**seo_ctx, "request": request, "errors": errors, "email": email},
)
@@ -147,22 +132,13 @@ async def forgot_password_page(request: Request):
title="Reset Password",
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")
async def forgot_password(request: Request):
form = await request.form()
email = form.get("email", "").strip().lower()
errors = []
if not email or "@" not in email:
errors.append("Valid email is required")
async def forgot_password(request: Request, data: Annotated[ForgotPasswordForm, Form()]):
email = data.email.strip().lower()
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")
user = users.find_one(email=email)
@@ -174,13 +150,13 @@ async def forgot_password(request: Request):
"uid": generate_uid(),
"user_uid": user["uid"],
"token": token_hash,
"expires_at": (datetime.utcnow() + timedelta(hours=1)).isoformat(),
"expires_at": (datetime.now(timezone.utc) + timedelta(hours=1)).isoformat(),
"used": False,
"created_at": datetime.utcnow().isoformat(),
"created_at": datetime.now(timezone.utc).isoformat(),
})
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,
})
@@ -192,37 +168,32 @@ async def reset_password_page(request: Request, token: str):
title="Set New Password",
robots="noindex,nofollow",
)
return templates.TemplateResponse("reset_password.html", {
return templates.TemplateResponse(request, "reset_password.html", {
**seo_ctx, "request": request, "token": token,
})
@router.post("/reset-password/{token}")
async def reset_password(request: Request, token: str):
form = await request.form()
password = form.get("password", "")
confirm = form.get("confirm_password", "")
async def reset_password(request: Request, token: str, data: Annotated[ResetPasswordForm, Form()]):
password = data.password
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")
users = get_table("users")
token_hash = hashlib.sha256(token.encode()).hexdigest()
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")
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,
})
+14 -6
View File
@@ -1,20 +1,28 @@
import hashlib
import logging
from fastapi import APIRouter, Request
from fastapi.responses import Response
from devplacepy.avatar import generate_avatar_svg
from devplacepy.cache import TTLCache
logger = logging.getLogger(__name__)
router = APIRouter()
_cache = {}
_cache = TTLCache(ttl=86400, max_size=4096)
_CACHE_CONTROL = "public, max-age=86400, immutable"
@router.get("/{style}/{seed}")
async def avatar_proxy(request: Request, style: str, seed: str, size: int = 128):
cache_key = f"{seed}:{size}"
if cache_key in _cache:
return Response(content=_cache[cache_key], media_type="image/svg+xml")
etag = '"' + hashlib.md5(cache_key.encode("utf-8")).hexdigest() + '"'
headers = {"ETag": etag, "Cache-Control": _CACHE_CONTROL}
svg = generate_avatar_svg(seed)
_cache[cache_key] = svg
return Response(content=svg, media_type="image/svg+xml")
if request.headers.get("if-none-match") == etag:
return Response(status_code=304, headers=headers)
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
from datetime import datetime
from fastapi import APIRouter, Request
from typing import Annotated
from datetime import datetime, timezone
from fastapi import APIRouter, Request, Form
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.utils import generate_uid, require_user, time_ago, get_current_user, create_mention_notifications
from devplacepy.seo import base_seo_context
@@ -23,7 +26,7 @@ async def bugs_page(request: Request):
bug_list = []
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:
bug_list.append({
"bug": b,
@@ -42,7 +45,7 @@ async def bugs_page(request: Request):
{"name": "Bug Reports", "url": "/bugs"},
],
)
return templates.TemplateResponse("bugs.html", {
return templates.TemplateResponse(request, "bugs.html", {
**seo_ctx,
"request": request,
"user": user,
@@ -51,14 +54,10 @@ async def bugs_page(request: Request):
@router.post("/create")
async def create_bug(request: Request):
async def create_bug(request: Request, data: Annotated[BugForm, Form()]):
user = require_user(request)
form = await request.form()
title = form.get("title", "").strip()
description = form.get("description", "").strip()
if not title or not description:
return RedirectResponse(url="/bugs", status_code=302)
title = data.title.strip()
description = data.description.strip()
bugs_table = get_table("bug_reports")
bug_uid = generate_uid()
@@ -68,10 +67,11 @@ async def create_bug(request: Request):
"title": title,
"description": description,
"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}")
logger.info(f"Bug report created by {user['username']}: {title}")
+21 -25
View File
@@ -1,9 +1,13 @@
import logging
from datetime import datetime
from fastapi import APIRouter, Request
from typing import Annotated
from datetime import datetime, timezone
from fastapi import APIRouter, Request, Form
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.models import CommentForm
logger = logging.getLogger(__name__)
router = APIRouter()
@@ -30,23 +34,15 @@ def resolve_target_redirect(target_type, target_uid):
@router.post("/create")
async def create_comment(request: Request):
async def create_comment(request: Request, data: Annotated[CommentForm, Form()]):
user = require_user(request)
form = await request.form()
content = form.get("content", "").strip()
target_uid = form.get("target_uid") or form.get("post_uid", "")
target_type = form.get("target_type", "post")
parent_uid = form.get("parent_uid", "")
content = data.content.strip()
target_uid = data.target_uid or data.post_uid
target_type = data.target_type
parent_uid = data.parent_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()
insert = {
"uid": comment_uid,
@@ -55,16 +51,14 @@ async def create_comment(request: Request):
"user_uid": user["uid"],
"content": content,
"parent_uid": parent_uid or None,
"created_at": datetime.utcnow().isoformat(),
"created_at": datetime.now(timezone.utc).isoformat(),
}
if target_type == "post":
insert["post_uid"] = target_uid
get_table("comments").insert(insert)
attachment_uids = form.getlist("attachment_uids") if hasattr(form, "getlist") else []
for auid in attachment_uids:
if auid.strip():
get_table("attachments").update({"uid": auid.strip(), "resource_uid": insert["uid"], "resource_type": "comment"}, ["uid"])
if data.attachment_uids:
link_attachments(data.attachment_uids, "comment", comment_uid)
badges = get_table("badges")
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(),
"user_uid": user["uid"],
"badge_name": "First Comment",
"created_at": datetime.utcnow().isoformat(),
"created_at": datetime.now(timezone.utc).isoformat(),
})
if target_type == "post":
@@ -89,8 +83,9 @@ async def create_comment(request: Request):
"message": f"{user['username']} replied to your comment",
"related_uid": user["uid"],
"read": False,
"created_at": datetime.utcnow().isoformat(),
"created_at": datetime.now(timezone.utc).isoformat(),
})
clear_unread_cache(parent["user_uid"])
else:
posts = get_table("posts")
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",
"related_uid": user["uid"],
"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)
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)
target_type = comment.get("target_type", "post")
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")
comments.delete(id=comment["id"])
logger.info(f"Comment {comment_uid} deleted by {user['username']}")
+7 -13
View File
@@ -1,8 +1,7 @@
import logging
from datetime import datetime
from fastapi import APIRouter, Request
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.templating import templates
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)
posts, next_cursor = get_feed_posts(user, tab, topic, before)
users_table = get_table("users")
total_members = len(list(users_table.all()))
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()))
stats = get_site_stats()
top_authors = list(users_table.find(stars={">": 0}, order_by=["-stars"], _limit=5))
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"}],
schemas=[website_schema(base)],
)
return templates.TemplateResponse("feed.html", {
return templates.TemplateResponse(request, "feed.html", {
**seo_ctx,
"request": request,
"user": user,
"posts": posts,
"current_tab": tab,
"current_topic": topic,
"total_members": total_members,
"posts_today": posts_today,
"total_projects": total_projects,
"total_gists": total_gists,
"total_members": stats["total_members"],
"posts_today": stats["posts_today"],
"total_projects": stats["total_projects"],
"total_gists": stats["total_gists"],
"top_authors": top_authors,
"daily_topic": daily_topic,
"next_cursor": next_cursor,
+5 -3
View File
@@ -1,8 +1,9 @@
import logging
from datetime import datetime
from datetime import datetime, timezone
from fastapi import APIRouter, Request
from fastapi.responses import RedirectResponse
from devplacepy.database import get_table
from devplacepy.templating import clear_unread_cache
from devplacepy.utils import generate_uid, require_user
logger = logging.getLogger(__name__)
@@ -26,7 +27,7 @@ async def follow_user(request: Request, username: str):
"uid": generate_uid(),
"follower_uid": user["uid"],
"following_uid": target["uid"],
"created_at": datetime.utcnow().isoformat(),
"created_at": datetime.now(timezone.utc).isoformat(),
})
notifications = get_table("notifications")
@@ -37,8 +38,9 @@ async def follow_user(request: Request, username: str):
"message": f"{user['username']} started following you",
"related_uid": user["uid"],
"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}")
return RedirectResponse(url=f"/profile/{username}", status_code=302)
+98 -25
View File
@@ -1,11 +1,14 @@
import logging
from datetime import datetime
from fastapi import APIRouter, Request, HTTPException
from typing import Annotated
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 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.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__)
router = APIRouter()
@@ -49,6 +52,33 @@ def get_gists_list(user_uid=None, language=None):
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)
async def gist_detail(request: Request, gist_slug: str):
user = get_current_user(request)
@@ -75,14 +105,15 @@ async def gist_detail(request: Request, gist_slug: str):
request,
title=gist.get("title", "Gist"),
description=gist.get("description", "")[:160],
og_type="article",
breadcrumbs=[
{"name": "Home", "url": "/feed"},
{"name": "Gists", "url": "/gists"},
{"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,
"request": request,
"user": user,
@@ -98,24 +129,12 @@ async def gist_detail(request: Request, gist_slug: str):
@router.post("/create")
async def create_gist(request: Request):
async def create_gist(request: Request, data: Annotated[GistForm, Form()]):
user = require_user(request)
form = await request.form()
title = form.get("title", "").strip()
description = form.get("description", "").strip()
source_code = form.get("source_code", "").strip()
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)
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:
@@ -133,7 +152,61 @@ async def create_gist(request: Request):
"source_code": source_code,
"language": language,
"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
from datetime import datetime
from fastapi import APIRouter, Request
from typing import Annotated
from datetime import datetime, timezone
from fastapi import APIRouter, Request, Form
from devplacepy.models import MessageForm
from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse
from devplacepy.database import get_table, db, get_attachments_by_type
from devplacepy.templating import templates
from devplacepy.database import get_table, db
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.seo import base_seo_context
@@ -65,9 +68,8 @@ def get_conversation_messages(user_uid: str, other_uid: str):
msgs.append(m)
msgs.sort(key=lambda m: m["created_at"])
for msg in msgs:
if msg["receiver_uid"] == user_uid and not msg["read"]:
messages_table.update({"id": msg["id"], "read": True}, ["id"])
with db:
db.query("UPDATE messages SET read = 1 WHERE receiver_uid = :me AND sender_uid = :other AND read = 0", me=user_uid, other=other_uid)
from devplacepy.database import get_users_by_uids
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 = []
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:
result.append({
"message": m,
@@ -115,7 +117,7 @@ async def messages_page(request: Request, with_uid: str = None, search: str = ""
{"name": "Messages", "url": "/messages"},
],
)
return templates.TemplateResponse("messages.html", {
return templates.TemplateResponse(request, "messages.html", {
**seo_ctx,
"request": request,
"user": user,
@@ -144,22 +146,39 @@ async def search_users(request: Request, q: str = ""):
@router.post("/send")
async def send_message(request: Request):
async def send_message(request: Request, data: Annotated[MessageForm, Form()]):
user = require_user(request)
form = await request.form()
content = form.get("content", "").strip()
receiver_uid = form.get("receiver_uid", "")
content = data.content.strip()
receiver_uid = data.receiver_uid
if content and receiver_uid:
messages_table = get_table("messages")
msg_uid = generate_uid()
messages_table.insert({
"uid": msg_uid,
"sender_uid": user["uid"],
"receiver_uid": receiver_uid,
"content": content,
messages_table = get_table("messages")
msg_uid = generate_uid()
messages_table.insert({
"uid": msg_uid,
"sender_uid": user["uid"],
"receiver_uid": receiver_uid,
"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,
"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
from datetime import datetime, timedelta
from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Request
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.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__)
router = APIRouter()
@@ -16,7 +16,7 @@ NEWS_MAX_AGE_DAYS = 4
@router.get("", response_class=HTMLResponse)
async def news_page(request: 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")
articles = list(news_table.find(
@@ -26,14 +26,7 @@ async def news_page(request: Request):
))
article_uids = [a["uid"] for a in articles]
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"]
images_by_news = get_news_images_by_uids(article_uids)
enriched = []
for a in articles:
@@ -53,7 +46,7 @@ async def news_page(request: Request):
schemas=[website_schema(base)],
)
return templates.TemplateResponse("news.html", {
return templates.TemplateResponse(request, "news.html", {
**seo_ctx,
"request": request,
"user": user,
@@ -84,11 +77,13 @@ async def news_detail_page(request: Request, news_slug: str):
request,
title=article.get("title", "News Article"),
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}],
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,
"request": request,
"user": user,
+9 -8
View File
@@ -1,9 +1,9 @@
import logging
from datetime import datetime, timedelta
from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from devplacepy.database import get_table
from devplacepy.templating import templates
from devplacepy.database import get_table, db
from devplacepy.templating import templates, clear_unread_cache
from devplacepy.utils import require_user, time_ago
from devplacepy.seo import base_seo_context
@@ -14,7 +14,7 @@ router = APIRouter()
def _group_label(created_at: str) -> str:
try:
dt = datetime.fromisoformat(created_at)
now = datetime.utcnow()
now = datetime.now(timezone.utc)
today = now.date()
date = dt.date()
if date == today:
@@ -78,7 +78,7 @@ async def notifications_page(request: Request):
{"name": "Notifications", "url": "/notifications"},
],
)
return templates.TemplateResponse("notifications.html", {
return templates.TemplateResponse(request, "notifications.html", {
**seo_ctx,
"request": request,
"user": user,
@@ -93,13 +93,14 @@ async def mark_read(request: Request, notification_uid: str):
n = notifications_table.find_one(uid=notification_uid)
if n and n["user_uid"] == user["uid"]:
notifications_table.update({"id": n["id"], "read": True}, ["id"])
clear_unread_cache(user["uid"])
return RedirectResponse(url="/notifications", status_code=302)
@router.post("/mark-all-read")
async def mark_all_read(request: Request):
user = require_user(request)
notifications_table = get_table("notifications")
for n in notifications_table.find(user_uid=user["uid"], read=False):
notifications_table.update({"id": n["id"], "read": True}, ["id"])
with db:
db.query("UPDATE notifications SET read = 1 WHERE user_uid = :u AND read = 0", u=user["uid"])
clear_unread_cache(user["uid"])
return RedirectResponse(url="/notifications", status_code=302)
+27 -84
View File
@@ -1,65 +1,35 @@
import logging
import aiofiles
from datetime import datetime
from pathlib import Path
from fastapi import APIRouter, Request, HTTPException
from typing import Annotated
from datetime import datetime, timezone
from fastapi import APIRouter, Request, HTTPException, Form
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.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.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__)
router = APIRouter()
@router.post("/create")
async def create_post(request: Request):
async def create_post(request: Request, data: Annotated[PostForm, Form()]):
user = require_user(request)
form = await request.form()
content = form.get("content", "").strip()
title = form.get("title", "").strip()
topic = form.get("topic", "random")
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)
content = data.content.strip()
title = data.title.strip()
topic = data.topic
project_uid = data.project_uid
image_filename = None
form = await request.form()
image_file = form.get("image")
if image_file and hasattr(image_file, "filename") and image_file.filename:
try:
content_bytes = await image_file.read()
if len(content_bytes) > 5 * 1024 * 1024:
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}")
if image_file is not None and hasattr(image_file, "filename") and image_file.filename:
image_filename = save_inline_image(await image_file.read(), image_file.filename)
if image_filename:
content += f"\n\n![](/static/uploads/{image_filename})"
posts = get_table("posts")
uid = generate_uid()
@@ -75,7 +45,7 @@ async def create_post(request: Request):
"project_uid": project_uid or None,
"image": image_filename,
"stars": 0,
"created_at": datetime.utcnow().isoformat(),
"created_at": datetime.now(timezone.utc).isoformat(),
})
badges = get_table("badges")
@@ -85,14 +55,11 @@ async def create_post(request: Request):
"uid": generate_uid(),
"user_uid": user["uid"],
"badge_name": "First Post",
"created_at": datetime.utcnow().isoformat(),
"created_at": datetime.now(timezone.utc).isoformat(),
})
attachment_uids = form.getlist("attachment_uids")
if attachment_uids:
link_attachments(attachment_uids, "post", uid)
if attachment_uids:
link_attachments(attachment_uids, "post", uid)
if data.attachment_uids:
link_attachments(data.attachment_uids, "post", uid)
create_mention_notifications(content, user["uid"], f"/posts/{post_slug}")
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"])
return templates.TemplateResponse("post.html", {
return templates.TemplateResponse(request, "post.html", {
**seo_ctx,
"request": request,
"user": user,
@@ -167,36 +134,18 @@ async def view_post(request: Request, post_slug: str):
@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)
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")
post = resolve_by_slug(posts, post_slug)
if not post or post["user_uid"] != user["uid"]:
return RedirectResponse(url="/feed", status_code=302)
if errors:
return RedirectResponse(url=f"/posts/{post['slug'] or post['uid']}", status_code=302)
posts.update({
"uid": post["uid"],
"content": content,
"title": title or None,
"topic": topic,
"content": data.content.strip(),
"title": data.title.strip() or None,
"topic": data.topic,
}, ["uid"])
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"])
get_table("comments").delete(post_uid=post["uid"])
get_table("votes").delete(target_uid=post["uid"])
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}")
delete_inline_image(post.get("image"))
posts.delete(id=post["id"])
logger.info(f"Post {post['uid']} deleted by {user['username']}")
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
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 devplacepy.database import get_table, db
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
logger = logging.getLogger(__name__)
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)
async def profile_page(request: Request, username: str, tab: str = "posts"):
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"]))
projects = list(get_table("projects").find(user_uid=profile_user["uid"]))
gists = 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_raw = list(get_table("gists").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 = []
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"]})
comments_table = get_table("comments")
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)
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']})",
description=desc,
robots=robots,
og_type="profile",
breadcrumbs=[
{"name": "Home", "url": "/feed"},
{"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,
"request": request,
"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")
async def update_profile(request: Request):
async def update_profile(request: Request, data: Annotated[ProfileForm, Form()]):
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.update({
"uid": user["uid"],
"bio": bio,
"location": location,
"git_link": git_link,
"website": website,
"bio": data.bio.strip(),
"location": data.location.strip(),
"git_link": data.git_link.strip(),
"website": data.website.strip(),
}, ["uid"])
clear_user_cache(user["uid"])
logger.info(f"Profile updated for {user['username']}")
return RedirectResponse(url=f"/profile/{user['username']}", status_code=302)
+39 -51
View File
@@ -1,8 +1,12 @@
import logging
from datetime import datetime
from fastapi import APIRouter, Request, HTTPException
from typing import Annotated
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 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.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
@@ -13,39 +17,29 @@ router = APIRouter()
def get_projects_list(tab: str = "recent", search: str = "", user_uid: str = None, project_type: str = None):
projects = get_table("projects")
all_projects = list(projects.all())
filters = {}
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:
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:
search_lower = search.lower()
all_projects = [
p for p in all_projects
if search_lower in p.get("title", "").lower()
or search_lower in p.get("description", "").lower()
]
clauses = []
if search and projects.exists:
like = f"%{search}%"
clauses.append(or_(projects.table.columns.title.ilike(like), projects.table.columns.description.ilike(like)))
order = ["-stars", "-created_at"] if tab == "popular" else ["-created_at"]
all_projects = list(projects.find(*clauses, **filters, order_by=order))
if all_projects:
from devplacepy.database import get_users_by_uids
uids = [p["user_uid"] for p in all_projects]
users_map = get_users_by_uids(uids)
users_map = get_users_by_uids([p["user_uid"] for p in all_projects])
for p in all_projects:
author = users_map.get(p["user_uid"])
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
@@ -59,8 +53,7 @@ async def projects_page(
):
user = get_current_user(request)
projects = get_projects_list(tab, search, user_uid, project_type)
users = get_table("users")
total_members = len(list(users.all()))
total_members = get_site_stats()["total_members"]
base = site_url(request)
seo_ctx = base_seo_context(
@@ -73,7 +66,7 @@ async def projects_page(
],
schemas=[website_schema(base)],
)
return templates.TemplateResponse("projects.html", {
return templates.TemplateResponse(request, "projects.html", {
**seo_ctx,
"request": request,
"user": user,
@@ -94,7 +87,6 @@ async def project_detail(request: Request, project_slug: str):
if not project:
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"]])
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)],
)
return templates.TemplateResponse("project_detail.html", {
return templates.TemplateResponse(request, "project_detail.html", {
**seo_ctx,
"request": request,
"user": user,
@@ -139,26 +131,17 @@ async def delete_project(request: Request, project_slug: str):
projects = get_table("projects")
project = resolve_by_slug(projects, project_slug)
if project and project["user_uid"] == user["uid"]:
delete_attachments("project", project["uid"])
delete_target_attachments("project", project["uid"])
projects.delete(id=project["id"])
logger.info(f"Project {project['uid']} deleted by {user['username']}")
return RedirectResponse(url="/projects", status_code=302)
@router.post("/create")
async def create_project(request: Request):
async def create_project(request: Request, data: Annotated[ProjectForm, Form()]):
user = require_user(request)
form = await request.form()
title = form.get("title", "").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)
title = data.title.strip()
description = data.description.strip()
projects = get_table("projects")
uid = generate_uid()
@@ -169,13 +152,18 @@ async def create_project(request: Request):
"title": title,
"slug": project_slug,
"description": description,
"release_date": release_date or None,
"demo_date": demo_date or None,
"project_type": project_type,
"platforms": platforms,
"status": status,
"release_date": data.release_date or None,
"demo_date": data.demo_date or None,
"project_type": data.project_type,
"platforms": data.platforms.strip(),
"status": data.status,
"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
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
logger = logging.getLogger(__name__)
@@ -17,16 +17,18 @@ Disallow: /notifications/
Disallow: /votes/
Disallow: /avatar/
Disallow: /follow/
Disallow: /admin/
Disallow: /uploads/
Disallow: /*?tab=
Disallow: /*?sort=
Allow: /static/
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):
base = site_url(request)
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)
seo_ctx = base_seo_context(
request,
title="Services Admin",
title="Services - Admin",
description="Monitor background services on DevPlace.",
breadcrumbs=[
{"name": "Home", "url": "/feed"},
@@ -26,7 +26,7 @@ async def services_page(request: Request):
],
schemas=[website_schema(base)],
)
return templates.TemplateResponse("services.html", {
return templates.TemplateResponse(request, "services.html", {
**seo_ctx,
"request": request,
"user": admin,
+22 -78
View File
@@ -1,33 +1,14 @@
import hashlib
import logging
import aiofiles
from pathlib import Path
from fastapi import APIRouter, Request
from fastapi.responses import JSONResponse
from devplacepy.database import get_table, get_setting
from devplacepy.config import STATIC_DIR
from devplacepy.utils import generate_uid, require_user
from devplacepy.database import get_table, get_setting, get_int_setting
from devplacepy.utils import require_user
from devplacepy.attachments import store_attachment, delete_attachment as _delete_attachment, ALLOWED_UPLOAD_TYPES
logger = logging.getLogger(__name__)
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")
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:
return JSONResponse({"error": "No file provided"}, status_code=400)
max_size_mb = int(get_setting("max_upload_size_mb", "10"))
max_size_bytes = max_size_mb * 1024 * 1024
ext = Path(file.filename).suffix.lower()
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_extensions = set()
if allowed_types_raw:
for ext in allowed_types_raw.split(","):
ext = ext.strip().lower()
if ext.startswith("."):
allowed_extensions.add(ext)
else:
allowed_extensions.add(f".{ext}")
for allowed_ext in allowed_types_raw.split(","):
allowed_ext = allowed_ext.strip().lower()
allowed_extensions.add(allowed_ext if allowed_ext.startswith(".") else f".{allowed_ext}")
if allowed_extensions and ext not in allowed_extensions:
return JSONResponse({"error": f"File type '{ext}' not allowed"}, status_code=415)
try:
content = await file.read()
@@ -56,61 +38,23 @@ async def upload_file(request: Request):
logger.warning(f"Failed to read uploaded file: {e}")
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)
ext = Path(file.filename).suffix.lower()
if allowed_extensions and ext not in allowed_extensions:
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)
logger.info(f"File uploaded: {file.filename} ({len(content)} bytes) -> {result['url']}")
return JSONResponse(result, status_code=201)
@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)
attachments = get_table("attachments")
att = attachments.find_one(uid=attachment_uid)
att = get_table("attachments").find_one(uid=attachment_uid)
if not att:
return JSONResponse({"error": "Attachment not found"}, status_code=404)
if att.get("resource_uid"):
resource_type = att["resource_type"]
if resource_type == "post":
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"])
if att.get("user_uid") and att["user_uid"] != user["uid"]:
return JSONResponse({"error": "Not authorized"}, status_code=403)
_delete_attachment(attachment_uid)
logger.info(f"Attachment {attachment_uid} deleted by {user['username']}")
return JSONResponse({"status": "deleted"})
+13 -13
View File
@@ -1,22 +1,21 @@
import logging
from datetime import datetime
from fastapi import APIRouter, Request
from typing import Annotated
from datetime import datetime, timezone
from fastapi import APIRouter, Request, Form
from fastapi.responses import RedirectResponse
from devplacepy.database import get_table
from devplacepy.templating import clear_unread_cache
from devplacepy.utils import generate_uid, require_user
from devplacepy.models import VoteForm
logger = logging.getLogger(__name__)
router = APIRouter()
@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)
form = await request.form()
value = int(form.get("value", "1"))
if value not in (1, -1):
return RedirectResponse(url="/feed", status_code=302)
value = data.value
votes = get_table("votes")
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_type": target_type,
"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)))
down_count = len(list(votes.find(target_uid=target_uid, value=-1)))
up_count = votes.count(target_uid=target_uid, value=1)
down_count = votes.count(target_uid=target_uid, value=-1)
net = up_count - down_count
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"]
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.insert({
"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}",
"related_uid": user["uid"],
"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")
return RedirectResponse(url=referer, status_code=302)
+73 -13
View File
@@ -3,6 +3,8 @@ import logging
from datetime import datetime
from xml.etree.ElementTree import Element, tostring
from xml.dom import minidom
from devplacepy.config import SITE_URL
from devplacepy.utils import strip_html
logger = logging.getLogger(__name__)
@@ -13,14 +15,15 @@ SITE_NAME = "DevPlace"
def truncate(text, max_len=160):
if not text:
return ""
text = " ".join(text.split())[:max_len]
if len(text) >= max_len:
text = text.rsplit(" ", 1)[0] + "..."
return text
text = " ".join(text.split())
if len(text) <= max_len:
return text
text = text[:max_len - 3].rsplit(" ", 1)[0]
return text + "..."
def site_url(request):
return str(request.base_url).rstrip("/")
return SITE_URL or str(request.base_url).rstrip("/")
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}
]
}
if post.get("title"):
schema["headline"] = post["title"]
return schema
@@ -98,7 +99,7 @@ def software_application_schema(project, base_url):
"@type": "SoftwareApplication",
"name": project.get("title", "Untitled"),
"description": truncate(project.get("description", ""), 300),
"url": f"{base_url}/projects",
"url": f"{base_url}/projects/{project.get('slug') or project['uid']}",
"applicationCategory": "DeveloperApplication",
"operatingSystem": project.get("platforms", "Cross-platform"),
"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):
if not schemas:
return None
@@ -129,21 +167,25 @@ def combine(schemas):
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):
base = site_url(request)
page_title = f"{title} {SITE_NAME}" if title else SITE_NAME
canonical = str(request.url).split("?")[0]
page_title = f"{title} - {SITE_NAME}" if title else SITE_NAME
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}"
return {
"page_title": page_title,
"meta_description": description,
"meta_description": clean_description,
"meta_robots": robots,
"canonical_url": canonical,
"og_title": title or SITE_NAME,
"og_description": description,
"og_description": clean_description,
"og_image": og_img,
"og_type": og_type,
"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}/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}/gists", changefreq="daily", priority="0.8"))
if "posts" in db.tables:
posts = list(get_table("posts").find(order_by=["-created_at"], _limit=500))
@@ -211,11 +254,28 @@ def make_sitemap(base_url):
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:
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))
for u in users:
if post_counts.get(u["uid"], 0) < 2:
continue
urlset.append(url_element(
f"{base_url}/profile/{u['username']}",
lastmod=u.get("created_at", ""),
changefreq="weekly",
priority="0.4"
))
+6 -6
View File
@@ -2,7 +2,7 @@ import asyncio
import logging
from abc import ABC, abstractmethod
from collections import deque
from datetime import datetime
from datetime import datetime, timezone
logger = logging.getLogger(__name__)
@@ -34,11 +34,11 @@ class BaseService(ABC):
def uptime(self) -> str | None:
if self._started_at is None:
return None
delta = datetime.utcnow() - self._started_at
delta = datetime.now(timezone.utc) - self._started_at
return str(delta).split(".")[0]
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}"
self.log_buffer.append(entry)
logger.info(f"[{self.name}] {message}")
@@ -49,11 +49,11 @@ class BaseService(ABC):
async def _run_loop(self) -> None:
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)")
while self._running:
try:
self._last_run = datetime.utcnow().isoformat()
self._last_run = datetime.now(timezone.utc).isoformat()
self._next_run = None
await self.run_once()
except asyncio.CancelledError:
@@ -63,7 +63,7 @@ class BaseService(ABC):
self.log(f"Error in run_once: {e}")
if not self._running:
break
self._next_run = datetime.utcnow().isoformat()
self._next_run = datetime.now(timezone.utc).isoformat()
self.log(f"Sleeping for {self.interval_seconds}s")
try:
await asyncio.sleep(self.interval_seconds)
+126 -137
View File
@@ -1,13 +1,13 @@
import logging
import os
import re
from datetime import datetime
from datetime import datetime, timezone
import httpx
from devplacepy.database import get_table
from devplacepy.database import get_table, get_setting
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__)
@@ -17,22 +17,13 @@ AI_MODEL_DEFAULT = "molodetz"
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:
key = os.environ.get("NEWS_AI_KEY")
if key:
return key
table = get_table("site_settings")
row = table.find_one(key="news_ai_key")
if row:
return row.get("value", "")
key = get_setting("news_ai_key", "")
if key:
return key
key = os.environ.get("OPENROUTER_API_KEY")
if key:
return key
@@ -51,12 +42,11 @@ def _extract_grade(text: str) -> int | 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:
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.get(url)
resp.raise_for_status()
html = resp.text
resp = await client.get(url, timeout=10.0)
resp.raise_for_status()
html = resp.text
pattern = re.compile(r'<img[^>]+src=["\']([^"\']+)["\']', re.IGNORECASE)
matches = pattern.findall(html)
images = []
@@ -77,10 +67,10 @@ class NewsService(BaseService):
super().__init__(name="news", interval_seconds=3600)
async def run_once(self) -> None:
api_url = _get_setting("news_api_url", NEWS_API_URL_DEFAULT)
ai_url = _get_setting("news_ai_url", AI_URL_DEFAULT)
ai_model = _get_setting("news_ai_model", AI_MODEL_DEFAULT)
threshold = int(_get_setting("news_grade_threshold", str(GRADE_THRESHOLD_DEFAULT)))
api_url = get_setting("news_api_url", NEWS_API_URL_DEFAULT)
ai_url = get_setting("news_ai_url", AI_URL_DEFAULT)
ai_model = get_setting("news_ai_model", AI_MODEL_DEFAULT)
threshold = int(get_setting("news_grade_threshold", str(GRADE_THRESHOLD_DEFAULT)))
self.log(f"Fetching news from {api_url}")
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}")
return
articles = data.get("articles", [])
self.log(f"Received {len(articles)} articles")
articles = data.get("articles", [])
self.log(f"Received {len(articles)} articles")
news_table = get_table("news")
images_table = get_table("news_images")
sync_table = get_table("news_sync")
news_table = get_table("news")
images_table = get_table("news_images")
sync_table = get_table("news_sync")
synced_ids = set()
for entry in sync_table.find():
synced_ids.add(entry["external_id"])
synced_ids = set()
for entry in sync_table.find():
synced_ids.add(entry["external_id"])
new_count = 0
updated_count = 0
draft_count = 0
failed_count = 0
skipped_count = 0
new_count = 0
updated_count = 0
draft_count = 0
failed_count = 0
skipped_count = 0
for article in articles:
external_id = article.get("guid", "")
if not external_id:
continue
for article in articles:
external_id = article.get("guid", "")
if not external_id:
continue
if external_id in synced_ids:
skipped_count += 1
continue
if external_id in synced_ids:
skipped_count += 1
continue
grade = await self._grade_article(article, ai_url, ai_model)
now = datetime.utcnow().isoformat()
grade = await self._grade_article(article, ai_url, ai_model, client)
now = datetime.now(timezone.utc).isoformat()
if grade is None:
grade_val = 0
auto_published = False
failed_count += 1
sync_status = "grading_failed"
elif grade < threshold:
grade_val = grade
auto_published = False
draft_count += 1
sync_status = "graded"
else:
grade_val = grade
auto_published = True
sync_status = "graded"
if grade is None:
grade_val = 0
auto_published = False
failed_count += 1
sync_status = "grading_failed"
elif grade < threshold:
grade_val = grade
auto_published = False
draft_count += 1
sync_status = "graded"
else:
grade_val = grade
auto_published = True
sync_status = "graded"
is_published = "published" if auto_published else "draft"
existing = news_table.find_one(external_id=external_id)
is_published = "published" if auto_published else "draft"
existing = news_table.find_one(external_id=external_id)
if existing:
existing_slug = existing.get("slug", "")
news_table.update({
"id": existing["id"],
"grade": grade_val,
"status": is_published,
"title": article.get("title", ""),
"slug": existing_slug or make_combined_slug(article.get("title", "") or "news", existing["uid"]),
"description": (article.get("description", "") or "")[:5000],
"url": article.get("link", ""),
"source_name": article.get("feed_name", ""),
"content": (article.get("content", "") or "")[:10000],
"author": article.get("author", ""),
"article_published": article.get("published", ""),
"synced_at": now,
}, ["id"])
updated_count += 1
images_table.delete(news_uid=existing["uid"])
article_uid = existing["uid"]
else:
article_uid = generate_uid()
article_slug = make_combined_slug(article.get("title", "") or "news", article_uid)
news_table.insert({
"uid": article_uid,
"slug": article_slug,
"external_id": external_id,
"title": article.get("title", ""),
"description": (article.get("description", "") or "")[:5000],
"url": article.get("link", ""),
"image_url": "",
"source_name": article.get("feed_name", ""),
"grade": grade_val,
"status": is_published,
"content": (article.get("content", "") or "")[:10000],
"author": article.get("author", ""),
"article_published": article.get("published", ""),
"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", ""),
if existing:
existing_slug = existing.get("slug", "")
news_table.update({
"id": existing["id"],
"grade": grade_val,
"status": is_published,
"title": article.get("title", ""),
"slug": existing_slug or make_combined_slug(article.get("title", "") or "news", existing["uid"]),
"description": strip_html(article.get("description", "") or "")[:5000],
"url": article.get("link", ""),
"source_name": article.get("feed_name", ""),
"content": strip_html(article.get("content", "") or "")[:10000],
"author": article.get("author", ""),
"article_published": article.get("published", ""),
"synced_at": now,
}, ["id"])
updated_count += 1
images_table.delete(news_uid=existing["uid"])
article_uid = existing["uid"]
else:
article_uid = generate_uid()
article_slug = make_combined_slug(article.get("title", "") or "news", article_uid)
news_table.insert({
"uid": article_uid,
"slug": article_slug,
"external_id": external_id,
"title": article.get("title", ""),
"description": strip_html(article.get("description", "") or "")[:5000],
"url": article.get("link", ""),
"image_url": "",
"source_name": article.get("feed_name", ""),
"grade": grade_val,
"status": is_published,
"content": strip_html(article.get("content", "") or "")[:10000],
"author": article.get("author", ""),
"article_published": article.get("published", ""),
"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, 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}, "
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]
description = (article.get("description", "") or "")[:1000]
content = (article.get("content", "") or "")[:1500]
description = strip_html(article.get("description", "") or "")[:1000]
content = strip_html(article.get("content", "") or "")[:1500]
prompt = (
"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}"
try:
async with httpx.AsyncClient(timeout=15.0) as client:
resp = await client.post(ai_url, json=payload, headers=headers)
if resp.status_code != 200:
self.log(f"AI grading returned {resp.status_code}: {resp.text[:200]}")
resp.raise_for_status()
result = resp.json()
resp = await client.post(ai_url, json=payload, headers=headers, timeout=15.0)
if resp.status_code != 200:
self.log(f"AI grading returned {resp.status_code}: {resp.text[:200]}")
resp.raise_for_status()
result = resp.json()
text = result.get("choices", [{}])[0].get("message", {}).get("content", "")
if not text:
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);
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 {
border: 2px dashed var(--border-light);
.attachment-upload-container button.attachment-upload-btn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.375rem 0.5rem;
border-radius: var(--radius);
padding: 1.5rem 1rem;
text-align: center;
font-size: 1.125rem;
color: var(--text-muted);
cursor: pointer;
transition: all 0.2s;
background: var(--bg-input);
position: relative;
background: none;
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);
background: var(--accent-light);
}
.attachment-upload-zone.dragover {
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 {
.attachment-upload-container button.attachment-upload-btn.uploading {
pointer-events: none;
opacity: 0.6;
}
@@ -52,6 +33,13 @@
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 0.75rem;
flex: 0 0 100%;
width: 100%;
}
.attachment-preview-list:empty,
.attachment-upload-error:empty {
display: none;
}
.attachment-preview {
+22
View File
@@ -148,3 +148,25 @@
color: var(--success);
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;
color: var(--text-primary);
letter-spacing: -0.02em;
flex-shrink: 0;
}
.topnav-logo span { color: var(--accent); }
.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.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:hover { color: var(--text-primary); }
.nav-badge {
@@ -567,6 +568,116 @@ img {
}
.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:hover { opacity: 0.85; }
.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;
}
.page-messages .messages-layout {
margin-top: calc(var(--nav-height) + 1rem);
}
.modal-overlay {
display: none;
@@ -685,6 +793,22 @@ img {
display: flex;
}
@media (max-width: 480px) {
.modal-card {
margin: 0.5rem;
}
}
@media (max-width: 360px) {
.page {
padding: 0.25rem;
}
.card {
padding: 0.75rem;
}
}
.breadcrumb {
max-width: var(--max-content);
margin: 0 auto;
@@ -762,7 +886,29 @@ img {
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 {
padding: 0.375rem 0.5rem;
padding-top: calc(var(--nav-height) + 0.375rem);
@@ -770,8 +916,66 @@ img {
}
}
@media (max-width: 768px) {
@media (max-width: 1024px) {
.page {
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);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 1.25rem;
padding: 1rem;
transition: all 0.2s;
}
@@ -103,42 +103,62 @@
font-size: 0.6875rem;
color: var(--text-muted);
margin-left: auto;
padding-left: 0.75rem;
white-space: nowrap;
line-height: 1.5;
}
.post-topic {
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 {
font-size: 1.125rem;
font-weight: 700;
margin-bottom: 0.5rem;
color: var(--text-primary);
line-height: 1.3;
transition: color 0.15s ease;
}
.post-content {
font-size: 0.875rem;
color: var(--text-secondary);
line-height: 1.65;
margin-bottom: 0.75rem;
margin-bottom: 1rem;
word-break: break-word;
display: -webkit-box;
-webkit-line-clamp: 4;
-webkit-box-orient: vertical;
overflow: hidden;
cursor: pointer;
transition: color 0.15s ease;
}
.post-content:hover {
color: var(--accent);
}
.post-actions {
display: flex;
align-items: center;
gap: 0.25rem;
padding-top: 0.75rem;
gap: 0.5rem;
padding-top: 1rem;
border-top: 1px solid var(--border);
}
.post-action-btn {
line-height: 1;
display: inline-flex;
align-items: center;
gap: 0.375rem;
@@ -163,6 +183,24 @@
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 {
margin-left: auto;
}
@@ -318,3 +356,75 @@
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 {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
grid-template-columns: 1fr;
gap: 1rem;
}
@@ -34,12 +34,7 @@
border-radius: var(--radius-lg);
padding: 1.25rem;
transition: all 0.2s;
}
a.gist-card {
display: block;
text-decoration: none;
color: inherit;
cursor: pointer;
}
.gist-card:hover {
@@ -62,9 +57,6 @@ a.gist-card {
line-height: 1.3;
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.gist-card-meta {
@@ -282,11 +274,37 @@ a.gist-card {
color: var(--text-muted);
}
@media (max-width: 768px) {
@media (max-width: 1024px) {
.gists-layout {
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;
}
}
@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 {
padding: 1rem;
padding: 0.75rem 1rem;
border-top: 1px solid var(--border);
display: flex;
gap: 0.75rem;
align-items: center;
gap: 0.5rem;
}
.messages-input-area input {
.messages-input-area input[type="text"] {
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 {
padding: 0.5rem 1rem;
flex-shrink: 0;
width: 36px;
height: 36px;
padding: 0;
background: var(--accent);
color: #fff;
border-radius: var(--radius);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 1rem;
transition: background 0.15s;
}
.messages-send-btn:hover {
@@ -207,6 +227,22 @@
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) {
.messages-layout {
grid-template-columns: 1fr;
@@ -214,9 +250,17 @@
min-height: calc(100vh - var(--nav-height) - 2rem);
}
.messages-list {
display: none;
}
.messages-list.show {
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-weight: 600;
color: var(--text-secondary);
background: none;
border: none;
padding: 0;
font-family: inherit;
cursor: pointer;
}
.news-detail-back:hover {
@@ -278,3 +283,22 @@
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;
}
@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);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 1.5rem;
margin-bottom: 1.5rem;
padding: 1rem;
margin-bottom: 1rem;
}
.post-detail-header {
@@ -33,6 +33,8 @@
font-size: 0.8125rem;
color: var(--text-muted);
margin-left: auto;
padding-left: 0.75rem;
line-height: 1.5;
}
.post-detail-title {
@@ -45,13 +47,14 @@
font-size: 0.9375rem;
color: var(--text-secondary);
line-height: 1.7;
margin-top: 0.625rem;
margin-bottom: 1rem;
}
.post-detail-actions {
display: flex;
align-items: center;
gap: 0.5rem;
gap: 1rem;
padding-top: 1rem;
border-top: 1px solid var(--border);
}
@@ -60,7 +63,7 @@
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 1.5rem;
padding: 1rem;
}
.comments-section h3 {
@@ -72,7 +75,7 @@
.comment {
display: flex;
gap: 0.75rem;
padding: 0.75rem 0;
padding: 1rem 0;
position: relative;
}
@@ -160,23 +163,37 @@
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid var(--border);
align-items: center;
}
.comment-form > a {
flex-shrink: 0;
line-height: 0;
}
.comment-form textarea {
flex: 1;
min-width: 0;
min-height: 44px;
max-height: 120px;
}
.comment-form-actions {
display: flex;
align-items: flex-end;
gap: 0.25rem;
flex-wrap: wrap;
align-items: center;
flex-shrink: 0;
}
.comment-form-actions button {
padding: 0.5rem;
padding: 0.375rem 0.5rem;
font-size: 1rem;
line-height: 1;
color: var(--text-muted);
background: none;
border: none;
border-radius: var(--radius);
cursor: pointer;
transition: color 0.2s;
}
@@ -184,16 +201,16 @@
color: var(--text-primary);
}
.comment-form-submit {
padding: 0.5rem 1rem;
button.comment-form-submit {
padding: 0.375rem 0.5rem;
background: var(--accent);
color: #fff;
border-radius: var(--radius);
font-weight: 600;
font-size: 0.8125rem;
line-height: 1.4;
}
.comment-form-submit:hover {
button.comment-form-submit:hover {
background: var(--accent-hover);
}
@@ -210,6 +227,7 @@
background: none;
border: none;
cursor: pointer;
line-height: 1;
}
.post-action-btn:hover {
@@ -221,6 +239,24 @@
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 {
position: absolute;
left: 16px;
@@ -229,3 +265,48 @@
width: 2px;
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;
}
}
@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);
padding: 1.25rem;
transition: border-color 0.2s;
cursor: pointer;
}
.project-card:hover {
@@ -167,4 +168,37 @@
.projects-grid {
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;
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 {
constructor() {
this.initPasswordToggles();
this.initModals();
this.initPostForm();
this.initCommentForms();
this.initVoteButtons();
this.initMessageSearch();
this.initNotificationDismiss();
this.initProfileEdit();
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();
this.modals = new ModalManager();
this.forms = new FormManager();
this.votes = new VoteManager();
this.messageSearch = new MessageSearch();
this.profile = new ProfileEditor();
this.mobileNav = new MobileNav();
this.comments = new CommentManager();
this.content = new ContentEnhancer();
this.dom = new DomUtils();
}
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();
+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() {
this.emojiMap = this.buildEmojiMap();
this.imageExtRe = /\.(jpg|jpeg|png|gif|webp|svg|bmp|webp)(\?.*)?$/i;
@@ -125,7 +125,8 @@ class ContentRenderer {
if (m.index > last) {
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) {
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) {
this.textarea = textarea;
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 {
constructor() {
constructor(textareaId, langSelectId) {
this.editor = null;
this.init();
this.initialized = false;
this.textareaId = textareaId || "gist-source-editor";
this.langSelectId = langSelectId || "gist-language";
}
init() {
if (this.initialized) return;
if (typeof CodeMirror === "undefined") return;
const textarea = document.getElementById("gist-source-editor");
if (!textarea || textarea.dataset.cminit) return;
textarea.dataset.cminit = "1";
const textarea = document.getElementById(this.textareaId);
if (!textarea) return;
this.initialized = true;
this.editor = CodeMirror.fromTextArea(textarea, {
lineNumbers: true,
@@ -31,7 +34,7 @@ class GistEditor {
this.editor.setSize(null, 400);
const langSelect = document.getElementById("gist-language");
const langSelect = document.getElementById(this.langSelectId);
if (langSelect) {
langSelect.addEventListener("change", () => {
let mode = langSelect.value;
+1 -1
View File
@@ -1,4 +1,4 @@
class MentionInput {
export class MentionInput {
constructor(element) {
this.input = element;
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