diff --git a/.env.sample b/.env.sample
index a51af8a1..626d070e 100644
--- a/.env.sample
+++ b/.env.sample
@@ -1,5 +1,11 @@
-VITE_ADSENSE_PUB_ID =
-VITE_GOOGLE_ANALYTICS_ID =
-VITE_GOOGLE_SEARCH_CONSOLE_VERIFICATION =
-VITE_PXIMG_BASEURL_I = /-/
-VITE_PXIMG_BASEURL_S = /~/
+# Pximg proxy base URLs
+# Nuxt convention: NUXT_PUBLIC_PXIMG_BASE_URL_I, or legacy VITE_PXIMG_BASEURL_I
+VITE_PXIMG_BASEURL_I="/-/"
+VITE_PXIMG_BASEURL_S="/~/"
+
+# Google Analytics
+VITE_GOOGLE_ANALYTICS_ID=
+
+# UA blacklist (JSON array of strings to block)
+# Nuxt convention: NUXT_UA_BLACKLIST, or legacy UA_BLACKLIST
+UA_BLACKLIST=[]
diff --git a/.gitignore b/.gitignore
index ab3bd2ed..9faafac2 100644
--- a/.gitignore
+++ b/.gitignore
@@ -104,8 +104,16 @@ dist
.tern-port
.vercel
+.claude
dev-test
*.dev.*
.vercel
.vs
+
+# Nuxt
+.nuxt
+.output
+
+# macOS
+.DS_Store
diff --git a/.mcp.json b/.mcp.json
new file mode 100644
index 00000000..9f48601b
--- /dev/null
+++ b/.mcp.json
@@ -0,0 +1,8 @@
+{
+ "mcpServers": {
+ "nuxt": {
+ "type": "http",
+ "url": "https://nuxt.com/mcp"
+ }
+ }
+}
diff --git a/.vscode/settings.json b/.vscode/settings.json
deleted file mode 100644
index 4e713441..00000000
--- a/.vscode/settings.json
+++ /dev/null
@@ -1,5 +0,0 @@
-{
- "i18n-ally.localesPaths": [
- "src/locales"
- ]
-}
\ No newline at end of file
diff --git a/.vscode/vue.code-snippets b/.vscode/vue.code-snippets
index abd10498..e65ac470 100644
--- a/.vscode/vue.code-snippets
+++ b/.vscode/vue.code-snippets
@@ -12,7 +12,7 @@
"",
"",
"",
- ""
+ ""
],
"description": "Init vue components"
}
diff --git a/DEV_NOTES/PIXIV_WEB_API.md b/DEV_NOTES/PIXIV_WEB_API.md
new file mode 100644
index 00000000..be5060a5
--- /dev/null
+++ b/DEV_NOTES/PIXIV_WEB_API.md
@@ -0,0 +1,678 @@
+# Pixiv Web API (AJAX endpoint) Reference
+
+> Reverse-engineered from pixiv.net web client. All endpoints are under `https://www.pixiv.net/` unless noted otherwise.
+
+---
+
+## 1. Authentication & Session
+
+### 1.1 Session Cookie
+
+All authenticated requests require the `PHPSESSID` cookie. Obtain it by logging in to pixiv.net via browser and extracting the cookie value.
+
+### 1.2 CSRF Token
+
+Write operations (POST) require an `X-CSRF-TOKEN` header. The token can be extracted from the homepage HTML:
+
+**Method A** — Legacy `` tag:
+
+```html
+
+```
+
+Parse the JSON from `content` attribute, read the `token` field.
+
+**Method B** — Next.js data (current):
+
+```html
+
+```
+
+Parse the JSON, then:
+
+```
+JSON.parse(nextData.props.pageProps.serverSerializedPreloadedState).api.token
+```
+
+The same `__NEXT_DATA__` also contains `userData.self` for the logged-in user's profile.
+
+### 1.3 Required Request Headers
+
+All API requests to `www.pixiv.net` should include:
+
+| Header | Value |
+| ----------------- | ------------------------------------------- |
+| `User-Agent` | A modern browser UA string |
+| `Referer` | `https://www.pixiv.net/` |
+| `Origin` | `https://www.pixiv.net` |
+| `Accept-Language` | e.g. `zh-CN,zh;q=0.9,en;q=0.8` |
+| `Cookie` | `PHPSESSID=` |
+| `X-CSRF-TOKEN` | _(POST only)_ CSRF token obtained from §1.2 |
+
+You can use `navigator.userAgent` in your Chrome to get a valid User-Agent string. e.g. `Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36`
+
+---
+
+## 2. Image CDN
+
+Pixiv serves images from separate CDN domains. Requests to these domains **must** include `Referer: https://www.pixiv.net/` or they will be rejected (HTTP 403).
+
+| Domain | Purpose |
+| --------------------- | --------------------------------- |
+| `https://i.pximg.net` | Artwork images, user avatars |
+| `https://s.pximg.net` | Static assets (stamps, UI images) |
+
+### Image URL Structure
+
+All artwork image URLs follow a unified structure:
+
+```
+https://i.pximg.net/[c/{size}/]{path_segment}/img/{yyyy}/{MM}/{dd}/{HH}/{mm}/{ss}/{illust_id}_p{page}[_{suffix}].{ext}
+```
+
+The `{yyyy/MM/dd/HH/mm/ss}` portion is the artwork's `updateDate` in `Asia/Tokyo` (JST) timezone.
+
+### Quality Levels
+
+Pixiv provides 5 quality levels for each artwork image:
+
+| Level | Example URL | Path Segment | Size Prefix | Filename Suffix |
+| ---------- | -------------------------------------------------------------------------------- | -------------- | ------------------ | --------------- |
+| `mini` | `https://i.pximg.net/c/48x48/img-master/img/.../12345_p0_square1200.jpg` | `img-master` | `c/48x48/` | `_square1200` |
+| `thumb` | `https://i.pximg.net/c/250x250_80_a2/img-master/img/.../12345_p0_square1200.jpg` | `img-master`¹ | `c/250x250_80_a2/` | `_square1200`¹ |
+| `small` | `https://i.pximg.net/c/540x540_70/img-master/img/.../12345_p0_master1200.jpg` | `img-master` | `c/540x540_70/` | `_master1200` |
+| `regular` | `https://i.pximg.net/img-master/img/.../12345_p0_master1200.jpg` | `img-master` | _(none)_ | `_master1200` |
+| `original` | `https://i.pximg.net/img-original/img/.../12345_p0.jpg` | `img-original` | _(none)_ | _(none)_ |
+
+> ¹ The `thumb` level path segment may be `custom-thumb` instead of `img-master`, in which case the filename suffix is `_custom1200`. This depends on whether the artist has set a custom thumbnail crop.
+
+### Size Prefix Format
+
+The size parameter after `/c/` follows the format `{W}x{H}[_{param1}[_{param2}...]]`. The parameters are not fixed:
+
+- `48x48` — width and height only
+- `540x540_70` — width, height, and quality parameter
+- `250x250_80_a2` — width, height, and multiple parameters
+
+### URL Conversion Rules
+
+Given any quality level URL, other levels can be derived using these rules:
+
+**→ regular** (derive regular from any URL):
+
+1. Strip the `/c/{size}/` prefix
+2. Normalize path segment to `img-master` (`custom-thumb` → `img-master`)
+3. Normalize filename suffix to `_master1200` (`_square1200`/`_custom1200` → `_master1200`)
+
+**→ original** (derive original from any URL):
+
+1. Strip the `/c/{size}/` prefix
+2. Normalize path segment to `img-original` (`img-master`/`custom-thumb` → `img-original`)
+3. Remove filename suffix (`_square1200`/`_custom1200`/`_master1200` → removed)
+
+> ⚠️ The actual file extension of the original may differ from `.jpg` (e.g. `.png`). URL derivation can only guess the extension; callers should handle 404 responses.
+
+### Recommended SDK Interfaces
+
+SDK implementations should provide two approaches for obtaining all quality level URLs:
+
+**Approach A — Derive from a known URL (preferred)**
+
+When at least one image URL is available (e.g. from the API's `urls` field or a thumbnail `url`), derive all levels via string transformation. This approach requires no additional information and correctly handles special path segments like `custom-thumb`.
+
+```
+ToRegularURL(imageURL) → regular URL
+ToOriginalURL(imageURL) → original URL
+```
+
+**Approach B — Build from illustId + updateDate**
+
+When only the artwork ID and update time are available (no image URL), all URLs can be constructed directly:
+
+```
+BuildIllustURLs(illustId, updateDate, page) → { mini, thumb, small, regular, original }
+```
+
+> Note: This approach always uses `img-master` for the `thumb` path segment and cannot reproduce `custom-thumb`. For scenarios requiring exact thumbnails, fetch the `urls` field from the API instead.
+
+**Applicability by Endpoint**
+
+Some API endpoints (e.g. `/ajax/illust/{id}`) return a complete `urls` object — no conversion needed. Others return only partial information:
+
+| Endpoint | Returns | Recommended |
+| -------------------------- | -------------------- | ------------ |
+| `/ajax/illust/{id}` | Full `urls` object | Use directly |
+| `/ajax/illust/{id}/pages` | Full `urls` | Use directly |
+| `/ranking.php` | Thumbnail `url` only | Approach A |
+| `/ajax/illust/discovery` | Full `urls` | Use directly |
+| Only id + updateDate known | No URL available | Approach B |
+
+### URL Conversion Examples
+
+```
+Input (mini): https://i.pximg.net/c/48x48/img-master/img/2026/03/22/00/00/19/142584954_p0_square1200.jpg
+→ regular: https://i.pximg.net/img-master/img/2026/03/22/00/00/19/142584954_p0_master1200.jpg
+→ original: https://i.pximg.net/img-original/img/2026/03/22/00/00/19/142584954_p0.jpg
+
+Input (thumb/custom): https://i.pximg.net/c/250x250_80_a2/custom-thumb/img/2024/10/27/00/08/15/123706421_p0_custom1200.jpg
+→ regular: https://i.pximg.net/img-master/img/2024/10/27/00/08/15/123706421_p0_master1200.jpg
+→ original: https://i.pximg.net/img-original/img/2024/10/27/00/08/15/123706421_p0.jpg
+
+Input (ranking): https://i.pximg.net/c/480x960/img-master/img/2026/03/21/09/39/18/142555360_p0_master1200.jpg
+→ regular: https://i.pximg.net/img-master/img/2026/03/21/09/39/18/142555360_p0_master1200.jpg
+→ original: https://i.pximg.net/img-original/img/2026/03/21/09/39/18/142555360_p0.jpg
+```
+
+---
+
+## 3. Artwork Endpoints
+
+### 3.1 GET `/ajax/illust/{id}?full=1`
+
+Get full artwork details.
+
+**Response** `body`:
+
+```jsonc
+{
+ "id": "12345678",
+ "title": "...",
+ "description": "...", // HTML
+ "illustType": 0, // 0=illust, 1=manga, 2=ugoira
+ "createDate": "...", // ISO 8601
+ "updateDate": "...",
+ "urls": { "mini", "thumb", "small", "regular", "original" },
+ "tags": { "tags": [{ "tag": "...", "translation": { "en": "..." } }] },
+ "width": 1920,
+ "height": 1080,
+ "pageCount": 1,
+ "bookmarkCount": 100,
+ "likeCount": 50,
+ "commentCount": 10,
+ "viewCount": 1000,
+ "userId": "...",
+ "userName": "...",
+ "userAccount": "...",
+ "bookmarkData": null, // null = not bookmarked; { id, private } = bookmarked
+ "isBookmarkable": true,
+ // ... many more fields
+}
+```
+
+### 3.2 GET `/ajax/illust/{id}/pages`
+
+Get all pages of a multi-page artwork.
+
+**Response** `body`: array of:
+
+```jsonc
+{
+ "urls": {
+ "thumb_mini": "https://i.pximg.net/c/128x128/...",
+ "small": "https://i.pximg.net/c/540x540_70/...",
+ "regular": "https://i.pximg.net/img-master/...",
+ "original": "https://i.pximg.net/img-original/...",
+ },
+ "width": 1920,
+ "height": 1080,
+}
+```
+
+### 3.3 GET `/ajax/illust/{id}/ugoira_meta`
+
+Get ugoira (animated illustration) metadata.
+
+**Response** `body`:
+
+```jsonc
+{
+ "src": "https://i.pximg.net/img-zip-ugoira/..._ugoira600x600.zip",
+ "originalSrc": "https://i.pximg.net/img-zip-ugoira/..._ugoira1920x1080.zip",
+ "mime_type": "image/jpeg",
+ "frames": [
+ { "file": "000000.jpg", "delay": 100 },
+ { "file": "000001.jpg", "delay": 100 },
+ // ...
+ ],
+}
+```
+
+The `src`/`originalSrc` is a ZIP archive containing the frame images. Extract frames and render them according to each frame's `delay` (milliseconds).
+
+---
+
+## 4. Discovery & Recommendations
+
+### 4.1 GET `/ajax/illust/discovery`
+
+Discover random artworks (requires authentication).
+
+| Param | Type | Default | Description |
+| ------ | ------ | ------- | ---------------------- |
+| `mode` | string | `safe` | `safe` / `all` / `r18` |
+| `max` | string | `18` | Max number of results |
+
+**Response** `body`:
+
+```jsonc
+{
+ "illusts": [
+ // Array of ArtworkInfo objects (same shape as §3.1 but abbreviated)
+ // May include ad objects: { "isAdContainer": true, ... }
+ // Filter by checking for the presence of "id" field
+ ],
+}
+```
+
+### 4.2 GET `/ajax/illust/{id}/recommend/init`
+
+Get initial recommendations for a specific artwork.
+
+| Param | Type | Default | Description |
+| ------- | ------ | ------- | ----------------- |
+| `limit` | number | 18 | Number of results |
+
+**Response** `body`:
+
+```jsonc
+{
+ "illusts": [ /* ArtworkInfo[] */ ],
+ "nextIds": [ "12345", "67890", ... ] // IDs for pagination
+}
+```
+
+### 4.3 GET `/ajax/illust/recommend/illusts`
+
+Load more recommendations by ID. Use `nextIds` from §4.2 response.
+
+| Param | Type | Description |
+| ------------ | -------- | ----------------------------------------- |
+| `illust_ids` | string[] | Artwork IDs (repeated query key for each) |
+
+Query string example: `?illust_ids=123&illust_ids=456&illust_ids=789`
+
+**Response** `body`: same shape as §4.2.
+
+---
+
+## 5. Search
+
+### 5.1 GET `/ajax/search/artworks/{keyword}`
+
+Search artworks by keyword. The `{keyword}` must be URI-encoded.
+
+| Param | Type | Default | Description |
+| ------ | ------ | ------- | ------------------------- |
+| `p` | string | `1` | Page number (1-indexed) |
+| `mode` | string | `text` | Search mode (e.g. `text`) |
+
+**Response** `body`:
+
+```jsonc
+{
+ "illustManga": {
+ "data": [
+ /* ArtworkInfo[] */
+ ],
+ "total": 12345,
+ },
+}
+```
+
+---
+
+## 6. Ranking
+
+### 6.1 GET `/ranking.php`
+
+Get artwork rankings. This is a legacy PHP endpoint.
+
+| Param | Type | Required | Description |
+| --------- | ------ | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
+| `format` | string | Yes | Must be `json` |
+| `mode` | string | No | `daily` / `weekly` / `monthly` / `rookie` / `original` / `male` / `female` / `daily_r18` / `weekly_r18` / etc. |
+| `content` | string | No | `all` (combined) / `illust` (illustrations) / `ugoira` (animations) / `manga`. Novel ranking uses a separate endpoint and is not supported here. |
+| `p` | number | No | Page number |
+| `date` | string | No | Date in `YYYYMMDD` format |
+
+**Response** (top-level, not wrapped in `body`):
+
+```jsonc
+{
+ "date": "20240101",
+ "contents": [
+ {
+ "illust_id": 12345678,
+ "title": "...",
+ "url": "...", // thumbnail URL (e.g. /c/480x960/img-master/...), use SDK to derive other quality levels
+ "rank": 1,
+ "yes_rank": 2, // previous rank
+ "user_id": 12345,
+ "user_name": "...",
+ "illust_page_count": "1",
+ "tags": ["tag1", "tag2"],
+ // ...
+ },
+ ],
+}
+```
+
+> Note: Unlike `/ajax/*` endpoints, `/ranking.php` returns data directly without a `body` wrapper.
+
+---
+
+## 7. User Endpoints
+
+### 7.1 GET `/ajax/user/{userId}?full=1`
+
+Get full user profile.
+
+**Response** `body`:
+
+```jsonc
+{
+ "userId": "12345",
+ "name": "...",
+ "image": "https://i.pximg.net/...", // avatar
+ "imageBig": "https://i.pximg.net/...", // large avatar
+ "comment": "...", // bio (HTML)
+ "isFollowed": false,
+ "following": 100,
+ "followedBack": false,
+ "social": { "twitter": { "url": "..." } },
+ "region": { "name": "Japan" },
+ // ...
+}
+```
+
+### 7.2 GET `/ajax/user/{userId}/profile/top`
+
+Get user's featured/pinned works.
+
+**Response** `body`:
+
+```jsonc
+{
+ "illusts": { "12345": { /* ArtworkInfo */ }, ... }, // keyed by artwork ID
+ "manga": { "67890": { /* ArtworkInfo */ }, ... },
+ "novels": { "11111": { /* NovelInfo */ }, ... }
+}
+```
+
+### 7.3 GET `/ajax/user/{userId}/profile/all`
+
+Get IDs of all user's works (for client-side pagination).
+
+**Response** `body`:
+
+```jsonc
+{
+ "illusts": { "12345": null, "12346": null, ... }, // keys = artwork IDs
+ "manga": { "67890": null, ... }
+}
+```
+
+### 7.4 GET `/ajax/user/{userId}/profile/illusts`
+
+Get artwork details by IDs for a specific user.
+
+| Param | Type | Description |
+| --------------- | -------- | -------------------------------- |
+| `ids` | string[] | Artwork IDs (repeated query key) |
+| `work_category` | string | `illust` or `manga` |
+| `is_first_page` | number | `0` or `1` |
+
+**Response** `body`:
+
+```jsonc
+{
+ "works": { "12345": { /* ArtworkInfo */ }, ... } // keyed by artwork ID
+}
+```
+
+---
+
+## 8. Following & Feed
+
+### 8.1 GET `/ajax/user/{userId}/following`
+
+Get user's following list (requires authentication for own list).
+
+| Param | Type | Description |
+| -------- | ------ | --------------------------------- |
+| `offset` | number | Pagination offset |
+| `limit` | number | Results per page (typically `24`) |
+| `rest` | string | `show` = public, `hide` = private |
+
+**Response** `body`:
+
+```jsonc
+{
+ "total": 500,
+ "users": [
+ {
+ "userId": "...",
+ "userName": "...",
+ "profileImageUrl": "...",
+ "isFollowed": true,
+ // ...
+ },
+ ],
+ "extraData": {
+ "meta": {
+ "ogp": { "title": "...", "image": "...", "description": "..." },
+ },
+ },
+}
+```
+
+### 8.2 GET `/ajax/follow_latest/illust`
+
+Get latest illustrations from followed users (requires authentication).
+
+| Param | Type | Description |
+| ------ | ------ | ----------------------- |
+| `p` | number | Page number (1-indexed) |
+| `mode` | string | `all` or `r18` |
+
+**Response** `body`:
+
+```jsonc
+{
+ "page": {
+ "isLastPage": false,
+ },
+ "thumbnails": {
+ "illust": [
+ /* ArtworkInfo[] */
+ ],
+ },
+}
+```
+
+---
+
+## 9. Bookmarks
+
+### 9.1 GET `/ajax/user/{userId}/illusts/bookmarks`
+
+Get others' public bookmarked artworks, or own bookmarks (including private) if authenticated.
+
+| Param | Type | Description |
+| -------- | ------ | --------------------------------- |
+| `tag` | string | Tag filter (empty string = all) |
+| `offset` | number | Pagination offset |
+| `limit` | number | Results per page (default `48`) |
+| `rest` | string | `show` = public, `hide` = private |
+
+**Response** `body`:
+
+```jsonc
+{
+ "works": [
+ /* ArtworkInfo[] */
+ ],
+ "total": 1234,
+}
+```
+
+### 9.2 POST `/ajax/illusts/bookmarks/add`
+
+Add artwork to bookmarks.
+
+**Headers**: `X-CSRF-TOKEN`, `Content-Type: application/json`
+
+**Body**:
+
+```json
+{
+ "illust_id": 12345678,
+ "restrict": 0,
+ "comment": "",
+ "tags": []
+}
+```
+
+`restrict`: `0` = public, `1` = private.
+
+### 9.3 POST `/ajax/illusts/bookmarks/delete`
+
+Remove artwork from bookmarks.
+
+**Headers**: `X-CSRF-TOKEN`, `Content-Type: application/x-www-form-urlencoded`
+
+**Body**: `bookmark_id=12345678`
+
+The `bookmark_id` can be found in the `bookmarkData.id` field of artwork detail (§3.1).
+
+---
+
+## 10. Comments
+
+### 10.1 GET `/ajax/illusts/comments/roots`
+
+Get root-level comments on an artwork.
+
+| Param | Type | Description |
+| ----------- | ------ | ----------------- |
+| `illust_id` | string | Artwork ID |
+| `limit` | string | Results per page |
+| `offset` | string | Pagination offset |
+
+**Response** `body`:
+
+```jsonc
+{
+ "hasNext": true,
+ "comments": [
+ {
+ "id": "...",
+ "comment": "...",
+ "stampId": null,
+ "userId": "...",
+ "userName": "...",
+ "img": "...", // user avatar URL
+ "commentDate": "...",
+ "hasReplies": false,
+ // ...
+ },
+ ],
+}
+```
+
+### 10.2 POST `/ajax/illusts/comments/post`
+
+Post a comment on an artwork.
+
+**Headers**: `X-CSRF-TOKEN`, `Content-Type: application/json`
+
+**Body**:
+
+```json
+{
+ "type": "comment",
+ "illust_id": 12345678,
+ "author_user_id": 87654321,
+ "comment": "Nice work!"
+}
+```
+
+---
+
+## 11. User Follow / Unfollow (Legacy PHP)
+
+### 11.1 POST `/bookmark_add.php`
+
+Follow a user.
+
+**Headers**: `X-CSRF-TOKEN`, `Content-Type: application/x-www-form-urlencoded`
+
+**Body**:
+
+```
+mode=add&type=user&user_id=12345&tag=&restrict=0&format=json
+```
+
+`restrict`: `0` = public, `1` = private.
+
+### 11.2 POST `/rpc_group_setting.php`
+
+Unfollow a user.
+
+**Headers**: `X-CSRF-TOKEN`, `Content-Type: application/x-www-form-urlencoded`
+
+**Body**:
+
+```
+mode=del&type=bookuser&id=12345
+```
+
+---
+
+## 12. Common Response Envelope
+
+All `/ajax/*` endpoints return responses in this envelope:
+
+```jsonc
+{
+ "error": false, // true on error
+ "message": "", // error message if error=true
+ "body": {
+ /* ... */
+ }, // actual response data
+}
+```
+
+When `error` is `true`, `body` may be `null` or contain error details. Always check the `error` field before accessing `body`.
+
+The `/ranking.php` endpoint is an exception — it returns data directly without this envelope.
+
+---
+
+## Appendix: Endpoint Summary
+
+| # | Method | Endpoint | Auth | Description |
+| ---- | ------ | --------------------------------------- | --------------------------- | ------------------------- |
+| 3.1 | GET | `/ajax/illust/{id}?full=1` | Optional (Required for r18) | Artwork detail |
+| 3.2 | GET | `/ajax/illust/{id}/pages` | Optional (Required for r18) | Multi-page artwork |
+| 3.3 | GET | `/ajax/illust/{id}/ugoira_meta` | Optional (Required for r18) | Ugoira animation metadata |
+| 4.1 | GET | `/ajax/illust/discovery` | Required | Random artwork discovery |
+| 4.2 | GET | `/ajax/illust/{id}/recommend/init` | Optional | Initial recommendations |
+| 4.3 | GET | `/ajax/illust/recommend/illusts` | Optional | More recommendations |
+| 5.1 | GET | `/ajax/search/artworks/{keyword}` | Optional | Search artworks |
+| 6.1 | GET | `/ranking.php` | No | Artwork ranking |
+| 7.1 | GET | `/ajax/user/{userId}?full=1` | Optional | User profile |
+| 7.2 | GET | `/ajax/user/{userId}/profile/top` | Optional | User featured works |
+| 7.3 | GET | `/ajax/user/{userId}/profile/all` | Optional | All user work IDs |
+| 7.4 | GET | `/ajax/user/{userId}/profile/illusts` | Optional | User works by IDs |
+| 8.1 | GET | `/ajax/user/{userId}/following` | Required | User following list |
+| 8.2 | GET | `/ajax/follow_latest/illust` | Required | Feed from followed users |
+| 9.1 | GET | `/ajax/user/{userId}/illusts/bookmarks` | Optional | User bookmarks |
+| 9.2 | POST | `/ajax/illusts/bookmarks/add` | Required | Add bookmark |
+| 9.3 | POST | `/ajax/illusts/bookmarks/delete` | Required | Remove bookmark |
+| 10.1 | GET | `/ajax/illusts/comments/roots` | Optional | Artwork comments |
+| 10.2 | POST | `/ajax/illusts/comments/post` | Required | Post comment |
+| 11.1 | POST | `/bookmark_add.php` | Required | Follow user |
+| 11.2 | POST | `/rpc_group_setting.php` | Required | Unfollow user |
diff --git a/DEV_NOTES/followUser.http b/DEV_NOTES/followUser.http
deleted file mode 100644
index c2732a72..00000000
--- a/DEV_NOTES/followUser.http
+++ /dev/null
@@ -1,11 +0,0 @@
-POST https://www.pixiv.net/bookmark_add.php
-content-type: application/x-www-form-urlencoded
-
-{
- "mode": "add",
- "type": "user",
- "user_id": "15552366",
- "tag": "",
- "restrict": "0",
- "format": "json"
-}
\ No newline at end of file
diff --git a/DEV_NOTES/notification.http b/DEV_NOTES/notification.http
deleted file mode 100644
index f2c11af4..00000000
--- a/DEV_NOTES/notification.http
+++ /dev/null
@@ -1,43 +0,0 @@
-GET https://www.pixiv.net/ajax/notification
-
-[HTTP/1.1 200 OK]
-application/json
-{
- "error": false,
- "message": "",
- "body": {
- "items": [
- {
- "id": 698556583,
- "type": "bookmarked",
- "unread": true,
- "notifiedAt": "2021-07-17T12:22:25+09:00",
- "linkUrl": "/bookmark_detail.php?illust_id=86468782",
- "iconUrl": "https://i.pximg.net/c/128x128/img-master/img/2020/12/23/05/25/48/86468782_p0_square1200.jpg",
- "targetBlank": false,
- "isProfileIcon": false,
- "content": "2以上的用户把你的作品加入收藏了: \"[草图] 原创角色 Kunika\""
- }
- ],
- "remaining_unread_count": 0,
- "imageResponseCount": 0,
- "quotationCount": 0,
- "isNotAuthorized": false,
- "filter": {
- "reactions": [
- "bookmarked",
- "nice",
- "commented",
- "tagged",
- "content_response",
- "favorited",
- "group_content_reference",
- "group_like",
- "group_comment",
- "received_stacc_message",
- "series_watchlist_watched"
- ]
- }
- }
-}
-
diff --git a/api/http.ts b/api/http.ts
deleted file mode 100644
index f74dd74f..00000000
--- a/api/http.ts
+++ /dev/null
@@ -1,43 +0,0 @@
-import type { VercelRequest, VercelResponse } from '@vercel/node'
-import escapeRegExp from 'lodash.escaperegexp'
-import { ajax } from './utils.js'
-
-export default async function (req: VercelRequest, res: VercelResponse) {
- if (!isAccepted(req)) {
- return res.status(403).send('403')
- }
-
- try {
- const { __PREFIX, __PATH } = req.query
- const { data } = await ajax({
- method: req.method ?? 'GET',
- url: `/${encodeURI(`${__PREFIX}${__PATH ? '/' + __PATH : ''}`)}`,
- params: req.query ?? {},
- data: req.body || undefined,
- headers: req.headers as Record,
- })
- res.status(200).send(data)
- } catch (e: any) {
- res.status(e?.response?.status || 500).send(e?.response?.data || e)
- }
-}
-
-function isAccepted(req: VercelRequest) {
- const { UA_BLACKLIST = '[]' } = process.env
- try {
- const list: string[] = JSON.parse(UA_BLACKLIST)
- const ua = req.headers['user-agent'] ?? ''
- return (
- !!ua &&
- Array.isArray(list) &&
- (list.length > 0
- ? !new RegExp(
- `(${list.map((str) => escapeRegExp(str)).join('|')})`,
- 'gi'
- ).test(ua)
- : true)
- )
- } catch (e) {
- return false
- }
-}
diff --git a/api/image.ts b/api/image.ts
deleted file mode 100644
index 93831deb..00000000
--- a/api/image.ts
+++ /dev/null
@@ -1,82 +0,0 @@
-import { VercelRequest, VercelResponse } from '@vercel/node'
-import axios from 'axios'
-import { USER_AGENT } from './utils.js'
-
-export default async (req: VercelRequest, res: VercelResponse) => {
- const { __PREFIX, __PATH } = req.query
- if (!__PREFIX || !__PATH) {
- return res.status(400).send({ message: 'Missing param(s)' })
- }
-
- let url = ''
-
- switch (__PREFIX) {
- case '-': {
- url = `https://i.pximg.net/${__PATH}`
- break
- }
- case '~': {
- url = `https://s.pximg.net/${__PATH}`
- break
- }
- default:
- return res.status(400).send({ message: 'Invalid request' })
- }
-
- const proxyHeaders = [
- 'accept',
- 'accept-encoding',
- 'accept-language',
- 'range',
- 'if-range',
- 'if-none-match',
- 'if-modified-since',
- 'cache-control',
- ]
-
- const headers = {} as Record
- for (const h of proxyHeaders) {
- if (typeof req.headers[h] === 'string') {
- headers[h] = req.headers[h]
- }
- }
- Object.assign(headers, {
- referer: 'https://www.pixiv.net/',
- 'user-agent': USER_AGENT,
- })
-
- console.log('Proxy image:', url, headers)
-
- return axios
- .get(url, {
- responseType: 'arraybuffer',
- headers,
- })
- .then(
- ({ data, headers, status }) => {
- const exposeHeaders = [
- 'content-type',
- 'content-length',
- 'cache-control',
- 'content-disposition',
- 'last-modified',
- 'etag',
- 'accept-ranges',
- 'content-range',
- 'vary',
- ]
- for (const h of exposeHeaders) {
- if (typeof headers[h] === 'string') {
- res.setHeader(h, headers[h])
- }
- }
- res.status(status).send(Buffer.from(data))
- },
- (err) => {
- console.error('Image proxy error:', err)
- return res
- .status(err?.response?.status || 500)
- .send(err?.response?.data || err)
- }
- )
-}
diff --git a/app/api/pixiv-client.ts b/app/api/pixiv-client.ts
new file mode 100644
index 00000000..e43c2e96
--- /dev/null
+++ b/app/api/pixiv-client.ts
@@ -0,0 +1,437 @@
+import axios, { type AxiosInstance } from 'axios'
+import Cookies from 'js-cookie'
+import nprogress from 'nprogress'
+import { createPximgReplacer } from '~/utils/pximg'
+import type {
+ Artwork,
+ ArtworkGallery,
+ ArtworkInfo,
+ ArtworkInfoOrAd,
+ ArtworkRank,
+ Comments,
+ User,
+ UserListItem,
+ PixivUser,
+} from '~/types'
+
+// ── Response envelope ────────────────────────────────────────────────
+
+interface PixivResponse {
+ error: boolean
+ message: string
+ body: T
+}
+
+export interface PixivWebClientOptions {
+ /** Base URL for API requests (default: current origin) */
+ baseURL?: string
+ /** Proxy base URL for i.pximg.net images (default: VITE_PXIMG_BASEURL_I or https://i.pximg.net/) */
+ pximgBaseUrlI?: string
+ /** Proxy base URL for s.pximg.net static assets (default: VITE_PXIMG_BASEURL_S or https://s.pximg.net/) */
+ pximgBaseUrlS?: string
+ /** Request timeout in ms (default: 15000) */
+ timeout?: number
+}
+
+export interface UgoiraMeta {
+ src: string
+ originalSrc: string
+ mime_type: string
+ frames: { file: string; delay: number }[]
+}
+
+export interface RecommendResult {
+ illusts: ArtworkInfo[]
+ nextIds: string[]
+}
+
+export interface FollowingResult {
+ total: number
+ users: UserListItem[]
+ extraData: {
+ meta: {
+ ogp: { title: string; image: string; description: string }
+ }
+ }
+}
+
+// ── PixivWebClient ───────────────────────────────────────────────────
+
+function resolveBaseUrl(
+ fallback: string,
+ override?: string
+): string {
+ if (override) return override.endsWith('/') ? override : override + '/'
+ return fallback
+}
+
+export class PixivWebClient {
+ private http: AxiosInstance
+ private pximgReplacer: ReturnType
+
+ constructor(options: PixivWebClientOptions = {}) {
+ let defaultI = '/-/'
+ let defaultS = '/~/'
+ try {
+ const config = useRuntimeConfig()
+ defaultI = (config.public.pximgBaseUrlI as string) || defaultI
+ defaultS = (config.public.pximgBaseUrlS as string) || defaultS
+ } catch {
+ // runtimeConfig not available
+ }
+ const baseUrlI = resolveBaseUrl(defaultI, options.pximgBaseUrlI)
+ const baseUrlS = resolveBaseUrl(defaultS, options.pximgBaseUrlS)
+ this.pximgReplacer = createPximgReplacer(baseUrlI, baseUrlS)
+
+ this.http = axios.create({
+ baseURL: options.baseURL,
+ timeout: options.timeout ?? 15 * 1000,
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ })
+
+ this.http.interceptors.request.use((config) => {
+ nprogress.start()
+
+ // Attach CSRF token for POST requests
+ const csrfToken = Cookies.get('CSRFTOKEN')
+ if (csrfToken && config.method?.toLowerCase() === 'post') {
+ config.headers = config.headers || {}
+ config.headers['X-CSRF-TOKEN'] = csrfToken
+ }
+
+ return config
+ })
+
+ this.http.interceptors.response.use(
+ (res) => {
+ nprogress.done()
+ return res
+ },
+ (err) => {
+ nprogress.done()
+ return Promise.reject(err)
+ }
+ )
+ }
+
+ // ── Internal helpers ──────────────────────────────────────────────
+
+ /**
+ * Unwrap /ajax/* response envelope and apply pximg URL replacement.
+ */
+ private unwrap(data: PixivResponse): T {
+ if (data.error) {
+ throw new Error(data.message || 'Pixiv API error')
+ }
+ return this.pximgReplacer.replacePximgInObject(data.body)
+ }
+
+ /**
+ * Apply pximg URL replacement without unwrapping (for non-ajax endpoints).
+ */
+ private transform(data: T): T {
+ return this.pximgReplacer.replacePximgInObject(data)
+ }
+
+ private async postFormData(
+ url: string,
+ data:
+ | string
+ | string[][]
+ | Record
+ | URLSearchParams
+ | undefined
+ ) {
+ return this.http.post(url, new URLSearchParams(data).toString(), {
+ headers: {
+ 'Content-Type': 'application/x-www-form-urlencoded; charset=utf-8',
+ },
+ })
+ }
+
+ // ── Artwork ───────────────────────────────────────────────────────
+
+ async getArtwork(id: string): Promise {
+ const { data } = await this.http.get>(
+ `/ajax/illust/${id}?full=1`
+ )
+ return this.unwrap(data)
+ }
+
+ async getArtworkPages(id: string): Promise {
+ const { data } = await this.http.get>(
+ `/ajax/illust/${id}/pages`
+ )
+ return this.unwrap(data)
+ }
+
+ async getUgoiraMeta(id: string): Promise {
+ const { data } = await this.http.get>(
+ `/ajax/illust/${id}/ugoira_meta`
+ )
+ return this.unwrap(data)
+ }
+
+ // ── Discovery & Recommendations ───────────────────────────────────
+
+ async getDiscovery(params: {
+ mode?: string
+ max?: number
+ }): Promise {
+ const { data } = await this.http.get<
+ PixivResponse<{ illusts: ArtworkInfoOrAd[] }>
+ >('/ajax/illust/discovery', {
+ params: {
+ mode: params.mode ?? 'all',
+ max: String(params.max ?? 18),
+ },
+ })
+ const body = this.unwrap(data)
+ return body.illusts.filter((item): item is ArtworkInfo => 'id' in item)
+ }
+
+ async getRecommendInit(
+ id: string,
+ limit: number = 18
+ ): Promise {
+ const { data } = await this.http.get>(
+ `/ajax/illust/${id}/recommend/init`,
+ { params: { limit } }
+ )
+ return this.unwrap(data)
+ }
+
+ async getRecommendMore(ids: string[]): Promise {
+ const searchParams = new URLSearchParams()
+ for (const id of ids) {
+ searchParams.append('illust_ids', id)
+ }
+ const { data } = await this.http.get>(
+ '/ajax/illust/recommend/illusts',
+ { params: searchParams }
+ )
+ return this.unwrap(data)
+ }
+
+ // ── Search ────────────────────────────────────────────────────────
+
+ async searchArtworks(
+ keyword: string,
+ params?: { p?: number; mode?: string }
+ ): Promise<{ data: ArtworkInfo[]; total: number }> {
+ const { data } = await this.http.get<
+ PixivResponse<{
+ illustManga: { data: ArtworkInfo[]; total: number }
+ }>
+ >(`/ajax/search/artworks/${encodeURIComponent(keyword)}`, {
+ params: { p: params?.p ?? 1, mode: params?.mode ?? 'text' },
+ })
+ const body = this.unwrap(data)
+ return {
+ data: body.illustManga?.data ?? [],
+ total: body.illustManga?.total ?? 0,
+ }
+ }
+
+ // ── Ranking ───────────────────────────────────────────────────────
+
+ async getRanking(params?: {
+ p?: number
+ mode?: string
+ date?: string
+ content?: string
+ }): Promise<{ date: string; contents: ArtworkRank[] }> {
+ const searchParams = new URLSearchParams({ format: 'json' })
+ if (params?.p) searchParams.set('p', String(params.p))
+ if (params?.mode) searchParams.set('mode', params.mode)
+ if (params?.date) searchParams.set('date', params.date)
+ if (params?.content) searchParams.set('content', params.content)
+ // ranking.php returns data directly (no body envelope)
+ const { data } = await this.http.get<{
+ date: string
+ contents: ArtworkRank[]
+ }>('/ranking.php', { params: searchParams })
+ return this.transform(data)
+ }
+
+ // ── User ──────────────────────────────────────────────────────────
+
+ async getUser(id: string): Promise {
+ const { data } = await this.http.get>(
+ `/ajax/user/${id}?full=1`
+ )
+ return this.unwrap(data)
+ }
+
+ async getUserProfileTop(id: string): Promise<{
+ illusts: Record
+ manga: Record
+ novels: Record
+ }> {
+ const { data } = await this.http.get<
+ PixivResponse<{
+ illusts: Record
+ manga: Record
+ novels: Record
+ }>
+ >(`/ajax/user/${id}/profile/top`)
+ return this.unwrap(data)
+ }
+
+ async getUserProfileAll(id: string): Promise<{
+ illusts: Record
+ manga: Record
+ }> {
+ const { data } = await this.http.get<
+ PixivResponse<{
+ illusts: Record
+ manga: Record
+ }>
+ >(`/ajax/user/${id}/profile/all`)
+ return this.unwrap(data)
+ }
+
+ async getUserIllusts(
+ userId: string,
+ ids: string[],
+ workCategory: string
+ ): Promise> {
+ const { data } = await this.http.get<
+ PixivResponse<{ works: Record }>
+ >(`/ajax/user/${userId}/profile/illusts`, {
+ params: { ids, work_category: workCategory, is_first_page: 0 },
+ })
+ return this.unwrap(data).works
+ }
+
+ // ── Following & Feed ──────────────────────────────────────────────
+
+ async getUserFollowing(
+ userId: string,
+ params: { offset: number; limit: number; rest: string }
+ ): Promise {
+ const { data } = await this.http.get>(
+ `/ajax/user/${userId}/following`,
+ { params }
+ )
+ return this.unwrap(data)
+ }
+
+ async getFollowLatest(params: { p: number; mode: string }): Promise<{
+ page: { isLastPage: boolean }
+ thumbnails: { illust: ArtworkInfo[] }
+ }> {
+ const { data } = await this.http.get<
+ PixivResponse<{
+ page: { isLastPage: boolean }
+ thumbnails: { illust: ArtworkInfo[] }
+ }>
+ >('/ajax/follow_latest/illust', { params })
+ return this.unwrap(data)
+ }
+
+ // ── Bookmarks ─────────────────────────────────────────────────────
+
+ async getUserBookmarks(
+ userId: string,
+ params: { tag: string; offset: number; limit: number; rest: string }
+ ): Promise<{ works: ArtworkInfo[]; total: number }> {
+ const { data } = await this.http.get<
+ PixivResponse<{ works: ArtworkInfo[]; total: number }>
+ >(`/ajax/user/${userId}/illusts/bookmarks`, { params })
+ return this.unwrap(data)
+ }
+
+ async addBookmark(illustId: string | number): Promise {
+ const { data } = await this.http.post>(
+ '/ajax/illusts/bookmarks/add',
+ { illust_id: illustId, restrict: 0, comment: '', tags: [] }
+ )
+ return this.unwrap(data)
+ }
+
+ async removeBookmark(bookmarkId: string | number): Promise {
+ const { data } = await this.postFormData('/ajax/illusts/bookmarks/delete', {
+ bookmark_id: '' + bookmarkId,
+ })
+ return data
+ }
+
+ // ── Comments ──────────────────────────────────────────────────────
+
+ async getComments(
+ illustId: string,
+ params: { limit: number; offset: number }
+ ): Promise<{ hasNext: boolean; comments: Comments[] }> {
+ const { data } = await this.http.get<
+ PixivResponse<{ hasNext: boolean; comments: Comments[] }>
+ >('/ajax/illusts/comments/roots', {
+ params: {
+ illust_id: illustId,
+ limit: String(params.limit),
+ offset: String(params.offset),
+ },
+ })
+ return this.unwrap(data)
+ }
+
+ async postComment(params: {
+ illustId: string | number
+ authorUserId: string | number
+ comment: string
+ }): Promise {
+ const { data } = await this.http.post>(
+ '/ajax/illusts/comments/post',
+ {
+ type: 'comment',
+ illust_id: params.illustId,
+ author_user_id: params.authorUserId,
+ comment: params.comment,
+ }
+ )
+ return this.unwrap(data)
+ }
+
+ // ── User Follow / Unfollow ────────────────────────────────────────
+
+ async followUser(userId: string | number): Promise {
+ const { data } = await this.postFormData('/bookmark_add.php', {
+ mode: 'add',
+ type: 'user',
+ user_id: '' + userId,
+ tag: '',
+ restrict: '0',
+ format: 'json',
+ })
+ return data
+ }
+
+ async unfollowUser(userId: string | number): Promise {
+ const { data } = await this.postFormData('/rpc_group_setting.php', {
+ mode: 'del',
+ type: 'bookuser',
+ id: '' + userId,
+ })
+ return data
+ }
+
+ // ── Custom Endpoints ──────────────────────────────────────────────
+
+ async _getSessionUser(): Promise<{
+ userData: PixivUser
+ token: string
+ }> {
+ const { data } = await this.http.get<{
+ userData: PixivUser
+ token: string
+ }>('/api/user', { headers: { 'Cache-Control': 'no-store' } })
+ return this.transform(data)
+ }
+}
+
+/**
+ * Singleton instance for use across the application.
+ */
+export const pixivClient = new PixivWebClient()
diff --git a/src/App.vue b/app/app.vue
similarity index 62%
rename from src/App.vue
rename to app/app.vue
index 07821691..88db3b9b 100644
--- a/src/App.vue
+++ b/app/app.vue
@@ -5,7 +5,9 @@ NaiveuiProvider#app-full-container
main
article
- RouterView
+ NuxtPage(
+ :transition='{ enterActiveClass: "fade-in-up", leaveActiveClass: "fade-out-down", mode: "out-in" }'
+ )
SideNav
SiteFooter
@@ -13,19 +15,15 @@ NaiveuiProvider#app-full-container
+
+
diff --git a/src/components/AuthorCard.vue b/app/components/AuthorCard.vue
similarity index 80%
rename from src/components/AuthorCard.vue
rename to app/components/AuthorCard.vue
index 59c05392..4507cef8 100644
--- a/src/components/AuthorCard.vue
+++ b/app/components/AuthorCard.vue
@@ -3,8 +3,8 @@
.author-inner(v-if='user')
.flex-center
.left
- RouterLink(:to='"/users/" + user.userId')
- img(:src='user.imageBig' alt='')
+ RouterLink(:to='"/users/" + user.userId' :aria-label='"查看作者: " + user.name')
+ img(:alt='user.name + " 的头像"' :src='user.imageBig')
.right
.flex
h4.plain
@@ -35,13 +35,13 @@
diff --git a/src/components/SiteHeader.vue b/app/components/SiteHeader.vue
similarity index 66%
rename from src/components/SiteHeader.vue
rename to app/components/SiteHeader.vue
index 2ef5a362..02d73367 100644
--- a/src/components/SiteHeader.vue
+++ b/app/components/SiteHeader.vue
@@ -1,8 +1,12 @@
header.global-navbar(:class='{ "not-at-top": notAtTop, hidden }')
.flex
- a.side-nav-toggle.plain(@click='toggleSideNav')
- IFasBars(data-icon)
+ button.side-nav-toggle.plain(
+ aria-label='打开导航菜单'
+ title='导航菜单'
+ @click='toggleSideNav'
+ )
+ IFasBars(aria-hidden='true')
.logo-area
RouterLink.plain(to='/')
@@ -12,30 +16,31 @@ header.global-navbar(:class='{ "not-at-top": notAtTop, hidden }')
.search-full.align-right.flex-1
SearchBox
.search-icon.align-right.flex-1
- a.pointer.plain(@click='openSideNav')
- IFasSearch
+ button.pointer.plain(
+ aria-label='搜索'
+ title='搜索'
+ @click='openSideNav'
+ )
+ IFasSearch(aria-hidden='true')
| 搜索
.flex.search-area(v-else)
#global-nav__user-area.user-area
- .user-link
- a.dropdown-btn.plain.pointer(
+ .user-link(ref='userLinkRef')
+ button.dropdown-btn.plain.pointer(
+ :aria-expanded='showUserDropdown'
+ :aria-label='userStore.isLoggedIn ? "用户菜单: " + userStore.userName : "用户菜单"'
:class='{ "show-user": showUserDropdown }'
- @click.stop='showUserDropdown = !showUserDropdown'
+ :title='userStore.isLoggedIn ? userStore.userId + " (" + userStore.userPixivId + ")" : "未登入"'
+ @click='showUserDropdown = !showUserDropdown'
)
img.avatar(
- :src='userStore.isLoggedIn ? userStore.userProfileImg : "/~/common/images/no_profile.png"',
- :title='userStore.isLoggedIn ? userStore.userId + " (" + userStore.userPixivId + ")" : "未登入"'
+ :alt='userStore.isLoggedIn ? userStore.userName : "未登入"'
+ :src='userStore.isLoggedIn ? userStore.userProfileImg : "/~/common/images/no_profile.png"'
)
- Transition(
- enter-active-class='fade-in-up'
- leave-active-class='fade-out-down'
- mode='out-in'
- name='fade'
- )
- .dropdown-content(v-show='showUserDropdown')
- ul
+ .dropdown-content(:class='{ visible: showUserDropdown }')
+ ul
//- notLogIn
li(v-if='!userStore.isLoggedIn')
.nav-user-card
@@ -61,15 +66,15 @@ header.global-navbar(:class='{ "not-at-top": notAtTop, hidden }')
li(v-if='userStore.isLoggedIn')
RouterLink.plain(
- :to='{ name: "users", params: { id: userStore.userId }, query: { tab: "public_bookmarks" } }'
+ :to='`/users/${userStore.userId}?tab=public_bookmarks`'
) 公开收藏
li(v-if='userStore.isLoggedIn')
RouterLink.plain(
- :to='{ name: "users", params: { id: userStore.userId }, query: { tab: "hidden_bookmarks" } }'
+ :to='`/users/${userStore.userId}?tab=hidden_bookmarks`'
) 私密收藏
li(v-if='userStore.isLoggedIn')
RouterLink.plain(
- :to='{ name: "following", params: { id: userStore.userId } }'
+ :to='`/users/${userStore.userId}/following`'
) 我的关注
li(v-if='$route.path !== "/login"')
RouterLink.plain(:to='"/login?back=" + $route.path') {{ userStore.isLoggedIn ? '查看令牌' : '用户登入' }}
@@ -81,16 +86,28 @@ header.global-navbar(:class='{ "not-at-top": notAtTop, hidden }')
import SearchBox from './SearchBox.vue'
import IFasBars from '~icons/fa-solid/bars'
import IFasSearch from '~icons/fa-solid/search'
-import { logout } from './userData'
-import LogoH from '@/assets/LogoH.png'
-import { useSideNavStore, useUserStore } from '@/composables/states'
+import { logout } from '~/composables/userData'
+import LogoH from '~/assets/LogoH.png'
+import { useSideNavStore, useUserStore } from '~/stores/session'
const hidden = ref(false)
const notAtTop = ref(false)
const showUserDropdown = ref(false)
+const userLinkRef = ref(null)
const sideNavStore = useSideNavStore()
const userStore = useUserStore()
+// Close dropdown when clicking outside .user-link
+onClickOutside(userLinkRef, () => {
+ showUserDropdown.value = false
+})
+
+// Close dropdown on route change
+const router = useRouter()
+router.afterEach(() => {
+ showUserDropdown.value = false
+})
+
function toggleSideNav() {
sideNavStore.toggle()
}
@@ -112,25 +129,8 @@ watch(hidden, (value) => {
}
})
-const router = useRouter()
-router.afterEach(() => {
- showUserDropdown.value = false
-})
-
-onMounted(() => {
- window.addEventListener('scroll', () => {
- const newTop = document.documentElement.scrollTop
- if (newTop > 50) {
- notAtTop.value = true
- } else {
- notAtTop.value = false
- }
- })
-
- // Outside close user dropdown
- document.addEventListener('click', () => {
- showUserDropdown.value = false
- })
+useEventListener(window, 'scroll', () => {
+ notAtTop.value = document.documentElement.scrollTop > 50
})
@@ -161,6 +161,10 @@ onMounted(() => {
box-shadow: 0 0px 8px var(--theme-box-shadow-color)
.side-nav-toggle
+ border: none
+ background: none
+ padding: 0
+ font: inherit
font-size: 1.2rem
text-align: center
color: var(--theme-accent-link-color)
@@ -170,12 +174,13 @@ onMounted(() => {
border-radius: 50%
display: flex
align-items: center
+ justify-content: center
&:hover
background-color: rgba(255,255,255,0.2)
-
- [data-icon]
- margin: 0 auto
+ &:focus-visible
+ outline: 2px solid #fff
+ outline-offset: 2px
.logo-area
.site-logo
@@ -195,9 +200,17 @@ onMounted(() => {
position: relative
.dropdown-btn
+ border: none
+ background: none
+ padding: 0
+ font: inherit
+ cursor: pointer
list-style: none
display: flex
align-items: center
+ &:focus-visible
+ outline: 2px solid #fff
+ outline-offset: 2px
.avatar
box-shadow: 0 0 0 2px #fff
transition: box-shadow 0.24s ease
@@ -212,6 +225,14 @@ onMounted(() => {
padding: 0
padding-top: 0.4rem
width: 200px
+ opacity: 0
+ transform: translateY(-0.5rem)
+ pointer-events: none
+ transition: opacity 0.25s ease, transform 0.25s ease
+ &.visible
+ opacity: 1
+ transform: translateY(0)
+ pointer-events: auto
ul
list-style: none
@@ -263,8 +284,31 @@ onMounted(() => {
.search-icon
display: none
- a
+ button
+ border: none
+ background: none
+ padding: 0
+ font: inherit
color: var(--theme-accent-link-color)
+ cursor: pointer
+ &:focus-visible
+ outline: 2px solid #fff
+ outline-offset: 2px
+
+// Home page: transparent navbar, search hidden until scrolled
+[data-route="home"]
+ .global-navbar
+ background: none
+ .search-area
+ opacity: 0
+ transition: opacity 0.4s ease
+ pointer-events: none
+
+ &.not-at-top
+ background-color: var(--theme-accent-color)
+ .search-area
+ opacity: 1
+ pointer-events: all
@media (max-width: 450px)
.global-navbar
diff --git a/src/components/SiteNoticeBanner.vue b/app/components/SiteNoticeBanner.vue
similarity index 100%
rename from src/components/SiteNoticeBanner.vue
rename to app/components/SiteNoticeBanner.vue
diff --git a/src/components/UgoiraViewer.vue b/app/components/UgoiraViewer.vue
similarity index 98%
rename from src/components/UgoiraViewer.vue
rename to app/components/UgoiraViewer.vue
index accb2719..aa9edbfd 100644
--- a/src/components/UgoiraViewer.vue
+++ b/app/components/UgoiraViewer.vue
@@ -6,7 +6,7 @@
ref='canvasRef'
v-if='firstLoaded'
)
- LazyLoad.media(
+ DeferLoad.media(
:height='illust.height',
:src='illust.urls.regular',
:style='{ cursor: isLoading ? "wait" : "pointer" }',
@@ -61,10 +61,10 @@
diff --git a/src/view/about.vue b/app/pages/about.vue
similarity index 95%
rename from src/view/about.vue
rename to app/pages/about.vue
index 5bd40b88..cc9dc5f4 100644
--- a/src/view/about.vue
+++ b/app/pages/about.vue
@@ -74,10 +74,11 @@ mixin repoLink
diff --git a/src/view/artworks.vue b/app/pages/artworks/[id].vue
similarity index 71%
rename from src/view/artworks.vue
rename to app/pages/artworks/[id].vue
index 5e09d67e..12c483b4 100644
--- a/src/view/artworks.vue
+++ b/app/pages/artworks/[id].vue
@@ -48,24 +48,25 @@
) (作者未填写简介)
p.stats
- span.like-count(title='点赞')
- IFasThumbsUp(data-icon)
+ span.stat-item(title='点赞')
+ IFasThumbsUp(aria-hidden='true')
| {{ illust.likeCount }}
//- 收藏
- span.bookmark-count(
+ button.stat-item.bookmark-btn(
+ :aria-label='illust.bookmarkData ? "取消收藏" : "添加收藏"'
:class='{ bookmarked: illust.bookmarkData }',
- :title='!store.isLoggedIn ? "收藏" : illust.bookmarkData ? "取消收藏" : "添加收藏"'
+ :title='!userStore.isLoggedIn ? "收藏" : illust.bookmarkData ? "取消收藏" : "添加收藏"'
@click='illust?.bookmarkData ? handleRemoveBookmark() : handleAddBookmark()'
)
- IFasHeart(data-icon)
+ IFasHeart(aria-hidden='true')
| {{ illust.bookmarkCount }}
- span.view-count(title='浏览')
- IFasEye(data-icon)
+ span.stat-item(title='浏览')
+ IFasEye(aria-hidden='true')
| {{ illust.viewCount }}
- span.count
- IFasImages(data-icon)
+ span.stat-item(title='页数')
+ IFasImages(aria-hidden='true')
| {{ pages.length }}张
p.create-date {{ new Date(illust.createDate).toLocaleString() }}
@@ -106,7 +107,7 @@
AuthorCard(:user='user')
Card.comments(title='评论')
- CommentsArea(
+ CommentArea(
:count='illust.commentCount',
:id='illust.id || illust.illustId'
)
@@ -114,12 +115,12 @@
//- 相关推荐
.recommend-works.body-inner(ref='recommendRef')
h2 相关推荐
- ArtworkList(:list='recommend', :loading='!recommend.length')
+ ArtworkList(:list='artworkStore.recommendations', :loading='!artworkStore.recommendations.length')
ShowMore(
:loading='recommendLoading',
:method='handleMoreRecommend',
:text='recommendLoading ? "加载中" : "加载更多"'
- v-if='recommend.length && recommendNextIds.length'
+ v-if='artworkStore.recommendations.length && artworkStore.recommendNextIds.length'
)
//- Error
@@ -128,14 +129,18 @@
diff --git a/app/pages/index.vue b/app/pages/index.vue
new file mode 100644
index 00000000..3dee23c7
--- /dev/null
+++ b/app/pages/index.vue
@@ -0,0 +1,173 @@
+
+#home-view
+ .top-slider.align-center(
+ :style='{ "background-image": `url(${randomBgRegularUrl || ""})` }'
+ )
+ section.search-area.flex-1
+ SearchBox.big.search
+
+ .site-logo
+ img(:src='LogoH')
+ .description Now, everyone can enjoy Pixiv
+
+ .bg-info
+ a.pointer(@click='homeStore.fetchRandomBg()' title='换一个~')
+ IFasRandom
+ a.pointer(
+ @click='isShowBgInfo = true'
+ style='margin-left: 0.5em'
+ title='关于背景'
+ v-if='randomBg?.id'
+ )
+ IFasInfoCircle
+
+ NModal(
+ :title='`背景图片:${randomBg?.alt}`'
+ closable
+ preset='card'
+ v-model:show='isShowBgInfo'
+ )
+ .bg-info-modal
+ .align-center
+ RouterLink.thumb(:to='"/artworks/" + randomBg?.id')
+ img(:src='randomBgRegularUrl' lazyload :style="{width: '100%', height: 'auto'}")
+ .desc
+ .author
+ RouterLink(:to='"/users/" + randomBg?.userId') @{{ randomBg?.userName }}
+ | 的作品 (ID: {{ randomBg?.id }})
+ NSpace(justify='center' size='small' style='margin-top: 1rem')
+ NTag(
+ :key='tag'
+ @click='$router.push(`/search/${encodeURIComponent(tag)}/1`)'
+ style='cursor: pointer'
+ v-for='tag in randomBg?.tags'
+ ) {{ tag }}
+
+ .body-inner
+ section.discover
+ NH2 探索发现
+ .align-center
+ NButton(
+ :loading='homeStore.loadingDiscovery'
+ @click='homeStore.discoveryList.length ? homeStore.fetchDiscovery() : void 0'
+ round
+ secondary
+ size='small'
+ )
+ template(#default) {{ homeStore.loadingDiscovery ? '加载中' : '换一批' }}
+ template(#icon): NIcon: IFasRandom
+ ArtworkList(:list='homeStore.discoveryList', :loading='homeStore.loadingDiscovery')
+
+
+
+
+
diff --git a/src/view/login.vue b/app/pages/login.vue
similarity index 97%
rename from src/view/login.vue
rename to app/pages/login.vue
index ad35d955..8408d9fb 100644
--- a/src/view/login.vue
+++ b/app/pages/login.vue
@@ -49,14 +49,15 @@ NForm#login-form.not-logged-in(v-if='!userStore.isLoggedIn')
+
+
diff --git a/src/view/search.vue b/app/pages/search/[keyword]/[p].vue
similarity index 56%
rename from src/view/search.vue
rename to app/pages/search/[keyword]/[p].vue
index e27b8311..be691a75 100644
--- a/src/view/search.vue
+++ b/app/pages/search/[keyword]/[p].vue
@@ -4,49 +4,45 @@
SearchBox.big
//- Error
- section(v-if='error && !loading')
+ section(v-if='error && !searchStore.loading')
ErrorPage(:description='error' title='出大问题')
//- Result
section(v-if='!error')
//- Loading
- .loading-area(v-if='loading && !resultList.length')
+ .loading-area(v-if='searchStore.loading && !searchStore.results.length')
ArtworkList(:list='[]', :loading='16')
- .no-more(v-if='!loading && !resultList.length')
+ .no-more(v-if='!searchStore.loading && !searchStore.results.length')
NCard(style='padding: 15vh 0'): NEmpty(description='没有了,一滴都没有了……')
- NSpin.result-area(:show='loading' v-if='resultList.length')
+ NSpin.result-area(:show='searchStore.loading' v-if='searchStore.results.length')
.pagenator
- NPagination(v-model:page='page' :item-count='total' :page-size='resultList.length')
- ArtworkLargeList(:artwork-list='resultList')
+ NPagination(v-model:page='page' :item-count='searchStore.total' :page-size='searchStore.results.length')
+ ArtworkLargeList(:artwork-list='searchStore.results')
.pagenator
- NPagination(v-model:page='page' :item-count='total' :page-size='resultList.length')
+ NPagination(v-model:page='page' :item-count='searchStore.total' :page-size='searchStore.results.length')
+
+
diff --git a/src/view/users.vue b/app/pages/users/[id]/index.vue
similarity index 85%
rename from src/view/users.vue
rename to app/pages/users/[id]/index.vue
index 69f082ae..949876b1 100644
--- a/src/view/users.vue
+++ b/app/pages/users/[id]/index.vue
@@ -55,7 +55,7 @@
NButton(round size='small' type='info')
| 我真棒
.following
- RouterLink(:to='{ name: "following", params: { id: user.userId } }') 关注了 {{ user.following }} 人
+ RouterLink(:to='`/users/${user.userId}/following`') 关注了 {{ user.following }} 人
.gender(v-if='user.gender?.name')
IFasVenusMars(data-icon)
| {{ user.gender.name }}
@@ -119,11 +119,11 @@
v-if='user.illusts && !user.illusts.length'
)
.user-illust.body-inner(v-else)
- ArtworksByUser(:user-id='user.userId' work-category='illust')
+ ArtworkListByUser(:user-id='user.userId' work-category='illust')
NTabPane(display-directive='show:lazy' :name='UserTabs.mangas' tab='漫画')
NEmpty(description='用户没有漫画作品 (*/ω\*)' v-if='!user.manga?.length')
.user-manga.body-inner(v-else)
- ArtworksByUser(:user-id='user.userId' work-category='manga')
+ ArtworkListByUser(:user-id='user.userId' work-category='manga')
NTabPane(:name='UserTabs.public_bookmarks' tab='公开收藏')
ArtworkList(
:list='[]',
@@ -167,12 +167,13 @@
- -->
-
-
-
-
-
-
-
-
-
-
-
-
-