Compare commits
34
Commits
e544056165
...
production
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
49853e079b | ||
|
|
2afb2038e5 | ||
|
|
18c9f20090 | ||
|
|
28bc8c1b74 | ||
|
|
8778d60c21 | ||
|
|
9c88d2120c | ||
|
|
065944a5b1 | ||
|
|
c47ab9e635 | ||
|
|
cb49f0cee1 | ||
|
|
4c30a32eb2 | ||
|
|
5b99ea8e17 | ||
|
|
b23f655389 | ||
|
|
22e066a202 | ||
|
|
e7f139437e | ||
|
|
335366d064 | ||
|
|
78c21cc507 | ||
|
|
f1fb8b8c16 | ||
|
|
f65a4aed9a | ||
|
|
c6ad62063a | ||
|
|
06f884b827 | ||
|
|
d74c87a733 | ||
|
|
3532497f3c | ||
|
|
d08038fc6c | ||
|
|
7b79097328 | ||
|
|
58c1a37d34 | ||
|
|
87f6ec63ac | ||
|
|
94f6001c85 | ||
|
|
2691684c08 | ||
|
|
d2bafabbad | ||
|
|
cc4374f9ba | ||
|
|
4c8d8663de | ||
|
|
1423c688cf | ||
|
|
f44cea570a | ||
|
|
8e51eed0b6 |
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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", "*"]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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
@@ -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")
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||
})
|
||||
|
||||
|
||||
@@ -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
@@ -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}")
|
||||
|
||||
@@ -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']}")
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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"
|
||||
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"
|
||||
|
||||
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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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"
|
||||
))
|
||||
|
||||
@@ -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
@@ -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 |
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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%;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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%;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 = "📎";
|
||||
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">📎</span><span>${file.name}</span>`;
|
||||
div.appendChild(iconDiv);
|
||||
}
|
||||
const remove = document.createElement("button");
|
||||
remove.type = "button";
|
||||
remove.className = "attachment-remove";
|
||||
remove.innerHTML = "×";
|
||||
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;
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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,4 +1,4 @@
|
||||
class EmojiPicker {
|
||||
export class EmojiPicker {
|
||||
constructor(textarea) {
|
||||
this.textarea = textarea;
|
||||
this.picker = null;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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,4 +1,4 @@
|
||||
class MentionInput {
|
||||
export class MentionInput {
|
||||
constructor(element) {
|
||||
this.input = element;
|
||||
this.dropdown = null;
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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 |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 8.7 KiB |
Binary file not shown.
Binary file not shown.
@@ -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")});
|
||||
@@ -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
@@ -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 }
|
||||
+1836
File diff suppressed because one or more lines are too long
+1244
File diff suppressed because one or more lines are too long
+2456
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">×</button>
|
||||
<img src="" alt="">
|
||||
</div>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user