Compare commits
216 Commits
chore/migr
...
cef5873c95
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cef5873c95 | ||
|
|
c296921eaf | ||
|
|
8e9ca655a9 | ||
|
|
a5de08087e | ||
|
|
bff3db79f9 | ||
|
|
08ea81684e | ||
|
|
af7c87a18c | ||
|
|
4c29151f7c | ||
|
|
d455ae0000 | ||
|
|
03e7185abe | ||
|
|
5de84f135f | ||
|
|
72dee337b7 | ||
|
|
70d9d8a890 | ||
|
|
9380eace92 | ||
|
|
57bc63d43b | ||
|
|
ac3a641b22 | ||
|
|
cdfc90f7eb | ||
|
|
f8e0bbf9a7 | ||
|
|
f2ba56e936 | ||
|
|
bb3a4a4dad | ||
|
|
af2ffc3949 | ||
|
|
b77c097032 | ||
|
|
9f21cb7882 | ||
|
|
1a2d8f4ebf | ||
|
|
d99672dbd8 | ||
|
|
a09e541fa8 | ||
|
|
455addf760 | ||
|
|
b87836782b | ||
|
|
299493854e | ||
|
|
54c52feeba | ||
|
|
60e3849c20 | ||
|
|
4ab0be75e7 | ||
|
|
6a0f0d08d2 | ||
|
|
0b3411626c | ||
|
|
01bb4b8c70 | ||
|
|
8a8509a0f3 | ||
|
|
151a4d9137 | ||
|
|
c412c75cfd | ||
|
|
e39ac32e43 | ||
|
|
a7ea2ce9cf | ||
|
|
1bf09a6629 | ||
|
|
7a2ccc1bd1 | ||
|
|
e11609990d | ||
|
|
e121cc2d05 | ||
|
|
f9e1495fc3 | ||
|
|
eeca9d071f | ||
|
|
e5dcdac65a | ||
|
|
2994839ee5 | ||
|
|
dd576ced90 | ||
|
|
0a2445a06d | ||
|
|
f3ed5e063f | ||
|
|
2002ac670a | ||
|
|
0ad747b69f | ||
|
|
c5236b2587 | ||
|
|
fc4ed188d7 | ||
|
|
de7ac8d096 | ||
|
|
0ef339d42e | ||
|
|
0e70d808ce | ||
|
|
952417621e | ||
|
|
9d1b6552cb | ||
|
|
f3708b2f9d | ||
|
|
a4d9774191 | ||
|
|
37c65142b7 | ||
|
|
6bb5c8a860 | ||
|
|
572a05962c | ||
|
|
33285d723a | ||
|
|
813f16655c | ||
|
|
bf19e982fe | ||
|
|
248dea6ade | ||
|
|
4fd72226ff | ||
|
|
816c3687d8 | ||
|
|
f2f2a3114b | ||
|
|
2f71566083 | ||
|
|
80ae544a28 | ||
|
|
bfd981de13 | ||
|
|
453332513a | ||
|
|
abc0113c8e | ||
|
|
52deb5feb4 | ||
|
|
9e07204430 | ||
|
|
cd60429145 | ||
|
|
aad07184fd | ||
|
|
76829afba2 | ||
|
|
05f59a568d | ||
|
|
559bfccd08 | ||
|
|
f8ae4351d6 | ||
|
|
30f200df30 | ||
|
|
58568e2245 | ||
|
|
fd7d06ce69 | ||
|
|
5a9c0a3704 | ||
|
|
e425be5c96 | ||
|
|
84803c45dd | ||
|
|
a526a9f6af | ||
|
|
08e01d8484 | ||
|
|
c88340d5f1 | ||
|
|
550b3cf019 | ||
|
|
2f05b93f51 | ||
|
|
cc266eac7c | ||
|
|
be006f08b4 | ||
|
|
55ee3bddeb | ||
|
|
1f324208d2 | ||
|
|
002cf9a4b1 | ||
|
|
efe9c01e63 | ||
|
|
d31c774ace | ||
|
|
d8a59467a0 | ||
|
|
dffee40776 | ||
|
|
4328411d88 | ||
|
|
3339e28d41 | ||
|
|
c8e5fd26a0 | ||
|
|
f562ca48b1 | ||
|
|
7c40feeae0 | ||
|
|
4d7b7d01f6 | ||
|
|
c78ce38845 | ||
|
|
b572380c37 | ||
|
|
43c5e65077 | ||
|
|
7b5af57941 | ||
|
|
abda5264a8 | ||
|
|
e715fb02d3 | ||
|
|
4c7c688688 | ||
|
|
7b4c7947aa | ||
|
|
68022971cd | ||
|
|
f529832eee | ||
|
|
3e9418285f | ||
|
|
17f015b686 | ||
|
|
adb1bd5945 | ||
|
|
86866e7d77 | ||
|
|
cf5567de7c | ||
|
|
9d6f70546e | ||
|
|
812f393283 | ||
|
|
37b39a6d96 | ||
|
|
df8bda0130 | ||
|
|
74491a45a9 | ||
|
|
da4fada8a1 | ||
|
|
df3e217d01 | ||
|
|
d50adc72e9 | ||
|
|
78de5e280f | ||
|
|
28ca9a17a9 | ||
|
|
836b480ea6 | ||
|
|
9f75e7971d | ||
|
|
181ffd1e5c | ||
|
|
c00065ce4a | ||
|
|
4ce8c30649 | ||
|
|
d098b3b404 | ||
|
|
e03ff49764 | ||
|
|
19495ddf0c | ||
|
|
63f5d644eb | ||
|
|
63bc3f4d5d | ||
|
|
ded770aff1 | ||
|
|
d12d7b660b | ||
|
|
86c9feaf55 | ||
|
|
449a16f791 | ||
|
|
ee8bbd4a37 | ||
|
|
1f272bc3e2 | ||
|
|
29c22a9b0f | ||
|
|
5c76170645 | ||
|
|
ceb800b6ac | ||
|
|
f809a1f5f8 | ||
|
|
5cda110a98 | ||
|
|
ce690b6767 | ||
|
|
73253c9ad2 | ||
|
|
369d730f70 | ||
|
|
668f73b546 | ||
|
|
b1be42b5bf | ||
|
|
70b679b204 | ||
|
|
36dc1293f9 | ||
|
|
801b7cb94a | ||
|
|
1fa66d8594 | ||
|
|
b827f31cf0 | ||
|
|
cf02569c75 | ||
|
|
0a4f8dbf41 | ||
|
|
b6f6759af5 | ||
|
|
c42c26a407 | ||
|
|
cfe5ef8fcd | ||
|
|
4c5116bc89 | ||
|
|
8581baafb7 | ||
|
|
29c092e0a0 | ||
|
|
410c0cec7c | ||
|
|
66a1bcbaa9 | ||
|
|
541e058d12 | ||
|
|
a05ff9f238 | ||
|
|
6558de8df5 | ||
|
|
b48c1bd0d5 | ||
|
|
e369541dc0 | ||
|
|
73a6014750 | ||
|
|
c197b80095 | ||
|
|
f3e74aed34 | ||
|
|
8d9134a062 | ||
|
|
006cd63388 | ||
|
|
3de9eb35f6 | ||
|
|
99c349f6df | ||
|
|
64b29bcdef | ||
|
|
9e26e231cb | ||
|
|
b003a3e008 | ||
|
|
1b5e23e3d4 | ||
|
|
52d7c84bd0 | ||
|
|
d76182b2c2 | ||
|
|
ed1a6b7fba | ||
|
|
d5b251c4a0 | ||
|
|
19b77810ec | ||
|
|
c8b0a78973 | ||
|
|
087d1a355e | ||
|
|
5d90da8ab5 | ||
|
|
72d065975d | ||
|
|
8b3df48791 | ||
|
|
cd8e173e05 | ||
|
|
8767f7c430 | ||
|
|
369a200a42 | ||
|
|
06eaa3c9a7 | ||
|
|
4cbcf42e3d | ||
|
|
a0be25c0dd | ||
|
|
dcee26100f | ||
|
|
ac68c68f8a | ||
|
|
982f3070a1 | ||
|
|
20e9538983 | ||
|
|
10c3e1ca60 | ||
| 6dc98c80ca | |||
|
|
1aef212a36 |
@@ -1,8 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "remanso-skills",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"description": "Local skills for the Remanso project",
|
|
||||||
"author": {
|
|
||||||
"name": "julien"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,196 +0,0 @@
|
|||||||
---
|
|
||||||
name: migrate-oxlint
|
|
||||||
description: Guide for migrating a project from ESLint to Oxlint. Use when asked to migrate, convert, or switch a JavaScript/TypeScript project's linter from ESLint to Oxlint.
|
|
||||||
---
|
|
||||||
|
|
||||||
This skill guides you through migrating a JavaScript/TypeScript project from ESLint to [Oxlint](https://oxc.rs/docs/guide/usage/linter/).
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
Oxlint is a high-performance linter that implements many popular ESLint rules natively in Rust. It can be used alongside ESLint or as a full replacement.
|
|
||||||
|
|
||||||
An official migration tool is available, and will be used by this skill: [`@oxlint/migrate`](https://github.com/oxc-project/oxlint-migrate)
|
|
||||||
|
|
||||||
## Step 1: Run Automated Migration
|
|
||||||
|
|
||||||
Run the migration tool in the project root:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npx @oxlint/migrate
|
|
||||||
```
|
|
||||||
|
|
||||||
This reads your ESLint flat config (`eslint.config.js` for example) and generates a `.oxlintrc.json` file from it. It will find your ESLint config file automatically in most cases.
|
|
||||||
|
|
||||||
See options below for more info.
|
|
||||||
|
|
||||||
### Key Options
|
|
||||||
|
|
||||||
| Option | Description |
|
|
||||||
| --------------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| `--type-aware` | Include type-aware rules from `@typescript-eslint` (will require the `oxlint-tsgolint` package to be installed after migrating) |
|
|
||||||
| `--with-nursery` | Include experimental rules still under development, may not be fully stable or consistent with ESLint equivalents |
|
|
||||||
| `--js-plugins [bool]` | Enable/disable ESLint plugin migration via `jsPlugins` (default: enabled) |
|
|
||||||
| `--details` | List rules that could not be migrated |
|
|
||||||
| `--replace-eslint-comments` | Convert all `// eslint-disable` comments to `// oxlint-disable` |
|
|
||||||
| `--output-file <file>` | Specify a different output path (default: `.oxlintrc.json`) |
|
|
||||||
|
|
||||||
If your ESLint config is not at the default location, pass the path explicitly:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npx @oxlint/migrate ./path/to/eslint.config.js
|
|
||||||
```
|
|
||||||
|
|
||||||
## Step 2: Review Generated Config
|
|
||||||
|
|
||||||
After migration, review the generated `.oxlintrc.json`.
|
|
||||||
|
|
||||||
### Plugin Mapping
|
|
||||||
|
|
||||||
The migration tool automatically maps ESLint plugins to oxlint's built-in equivalents. The following table is for reference when reviewing the generated config:
|
|
||||||
|
|
||||||
| ESLint Plugin | Oxlint Plugin Name |
|
|
||||||
| --------------------------------------------------- | ------------------ |
|
|
||||||
| `@typescript-eslint/eslint-plugin` | `typescript` |
|
|
||||||
| `eslint-plugin-react` / `eslint-plugin-react-hooks` | `react` |
|
|
||||||
| `eslint-plugin-import` / `eslint-plugin-import-x` | `import` |
|
|
||||||
| `eslint-plugin-unicorn` | `unicorn` |
|
|
||||||
| `eslint-plugin-jsx-a11y` | `jsx-a11y` |
|
|
||||||
| `eslint-plugin-react-perf` | `react-perf` |
|
|
||||||
| `eslint-plugin-promise` | `promise` |
|
|
||||||
| `eslint-plugin-jest` | `jest` |
|
|
||||||
| `@vitest/eslint-plugin` | `vitest` |
|
|
||||||
| `eslint-plugin-jsdoc` | `jsdoc` |
|
|
||||||
| `eslint-plugin-next` | `nextjs` |
|
|
||||||
| `eslint-plugin-node` | `node` |
|
|
||||||
| `eslint-plugin-vue` | `vue` |
|
|
||||||
|
|
||||||
Default plugins (enabled when `plugins` field is omitted): `unicorn`, `typescript`, `oxc`.
|
|
||||||
Setting the `plugins` array explicitly overrides these defaults.
|
|
||||||
|
|
||||||
ESLint core rules are usable in oxlint without needing to configure a plugin in the config file.
|
|
||||||
|
|
||||||
### Rule Categories
|
|
||||||
|
|
||||||
Oxlint groups rules into categories for bulk configuration, though only `correctness` is enabled by default:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"categories": {
|
|
||||||
"correctness": "error",
|
|
||||||
"suspicious": "warn"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Available categories: `correctness` (default: enabled), `suspicious`, `pedantic`, `perf`, `style`, `restriction`, `nursery`.
|
|
||||||
|
|
||||||
Individual rule settings in `rules` override category settings.
|
|
||||||
|
|
||||||
`@oxlint/migrate` will turn `correctness` off to avoid enabling additional rules that weren't enabled by your ESLint config. You can choose to enable additional categories after migration if desired.
|
|
||||||
|
|
||||||
### Check Unmigrated Rules
|
|
||||||
|
|
||||||
Run with `--details` to see which ESLint rules could not be migrated:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npx @oxlint/migrate --details
|
|
||||||
```
|
|
||||||
|
|
||||||
Review the output and decide whether to keep ESLint for those rules or not. Some rules may be mentioned in the output from `--details` as having equivalents in oxlint that were not automatically mapped by the migration tool. In those cases, consider enabling the equivalent oxlint rule manually after migration.
|
|
||||||
|
|
||||||
## Step 3: Install Oxlint
|
|
||||||
|
|
||||||
Install the core oxlint package (use `yarn install`, `pnpm install`, `vp install`, `bun install`, etc. depending on your package manager):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm install -D oxlint
|
|
||||||
```
|
|
||||||
|
|
||||||
If you want to add the `oxlint-tsgolint` package, if you intend to use type-aware rules that require TypeScript type information:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm install -D oxlint-tsgolint
|
|
||||||
```
|
|
||||||
|
|
||||||
No other packages besides the above are needed by default, though you will need to keep/install any additional ESLint plugins that were migrated into `jsPlugins`. Do not add `@oxlint/migrate` to the package.json, it is meant for one-off usage.
|
|
||||||
|
|
||||||
## Step 4: Handle Unsupported Features
|
|
||||||
|
|
||||||
Some features require manual attention:
|
|
||||||
|
|
||||||
- Local plugins (relative path imports): Must be migrated manually to `jsPlugins`
|
|
||||||
- `eslint-plugin-prettier`: Supported, but very slow. It is recommended to use [oxfmt](https://oxc.rs/docs/guide/usage/formatter) instead, or switch to `prettier --check` as a separate step alongside oxlint.
|
|
||||||
- `settings` in override configs: Oxlint does not support `settings` inside `overrides` blocks.
|
|
||||||
- ESLint v9+ plugins: Not all work with oxlint's JS Plugins API, but the majority will.
|
|
||||||
|
|
||||||
### Local Plugins
|
|
||||||
|
|
||||||
If you have any custom ESLint rules in the project repo itself, you can migrate them manually after running the migration tool by adding them to the `jsPlugins` field in `.oxlintrc.json`:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"jsPlugins": ["./path/to/my-plugin.js"],
|
|
||||||
"rules": {
|
|
||||||
"local-plugin/rule-name": "error"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### External ESLint Plugins
|
|
||||||
|
|
||||||
For ESLint plugins without a built-in oxlint equivalent, use the `jsPlugins` field to load them:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"jsPlugins": ["eslint-plugin-custom"],
|
|
||||||
"rules": {
|
|
||||||
"custom/my-rule": "warn"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Step 5: Update CI and Scripts
|
|
||||||
|
|
||||||
Replace ESLint commands with oxlint. Path arguments are optional; oxlint defaults to the current working directory.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Before
|
|
||||||
npx eslint src/
|
|
||||||
npx eslint --fix src/
|
|
||||||
|
|
||||||
# After
|
|
||||||
npx oxlint src/
|
|
||||||
npx oxlint --fix src/
|
|
||||||
```
|
|
||||||
|
|
||||||
### Common CLI Options
|
|
||||||
|
|
||||||
| ESLint | oxlint equivalent |
|
|
||||||
| ------------------------- | ---------------------------------------------- |
|
|
||||||
| `eslint .` | `oxlint` (default: lints the cwd) |
|
|
||||||
| `eslint src/` | `oxlint src/` |
|
|
||||||
| `eslint --fix` | `oxlint --fix` |
|
|
||||||
| `eslint --max-warnings 0` | `oxlint --deny-warnings` or `--max-warnings 0` |
|
|
||||||
| `eslint --format json` | `oxlint --format json` |
|
|
||||||
|
|
||||||
Additional oxlint options:
|
|
||||||
|
|
||||||
- `--tsconfig <path>`: Specify tsconfig.json path, likely unnecessary unless you have a non-standard name for `tsconfig.json`.
|
|
||||||
|
|
||||||
## Tips
|
|
||||||
|
|
||||||
- You can run alongside ESLint if necessary: Oxlint is designed to complement ESLint during migration, but with JS Plugins many projects can switch over fully without losing many rules.
|
|
||||||
- Disable comments work: `// eslint-disable` and `// eslint-disable-next-line` comments are supported by oxlint. Use `--replace-eslint-comments` when running @oxlint/migrate to convert them to `// oxlint-disable` equivalents if desired.
|
|
||||||
- List available rules: Run `npx oxlint --rules` to see all supported rules, or refer to the [rule documentation](https://oxc.rs/docs/guide/usage/linter/rules.html).
|
|
||||||
- Schema support: Add `"$schema": "./node_modules/oxlint/configuration_schema.json"` to `.oxlintrc.json` for editor autocompletion if the migration tool didn't do it automatically.
|
|
||||||
- Output formats: `default`, `stylish`, `json`, `github`, `gitlab`, `junit`, `checkstyle`, `unix`
|
|
||||||
- Ignore files: `.eslintignore` is supported by oxlint if you have it, but it's recommended to move any ignore patterns into the `ignorePatterns` field in `.oxlintrc.json` for consistency and simplicity. All files and paths ignored via a `.gitignore` file will be ignored by oxlint by default as well.
|
|
||||||
- If you ran the migration tool multiple times, remove the `.oxlintrc.json.bak` backup file created by the migration tool once you've finished migrating.
|
|
||||||
- If you are not using any JS Plugins and have replaced your ESLint configuration, you can remove all ESLint packages from your project dependencies.
|
|
||||||
- Ensure your editor is configured to use oxlint instead of ESLint for linting and error reporting. You may want to install the Oxc extension for your preferred editor. See https://oxc.rs/docs/guide/usage/linter/editors.html for more details.
|
|
||||||
|
|
||||||
## References
|
|
||||||
|
|
||||||
- [CLI Reference](https://oxc.rs/docs/guide/usage/linter/cli.html)
|
|
||||||
- [Config File Reference](https://oxc.rs/docs/guide/usage/linter/config-file-reference.html)
|
|
||||||
- [Complete Oxlint rule list and docs](https://oxc.rs/docs/guide/usage/linter/rules.html)
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "remanso-local",
|
|
||||||
"description": "Local plugins for the Remanso project",
|
|
||||||
"owner": {
|
|
||||||
"name": "julien"
|
|
||||||
},
|
|
||||||
"plugins": [
|
|
||||||
{
|
|
||||||
"name": "remanso-skills",
|
|
||||||
"description": "Local skills for the Remanso project (migrate-oxlint, etc.)",
|
|
||||||
"source": "./.agents"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -5,10 +5,6 @@
|
|||||||
"label": "Vue i18n",
|
"label": "Vue i18n",
|
||||||
"uri": "https://vue-i18n.intlify.dev/guide/introduction.html"
|
"uri": "https://vue-i18n.intlify.dev/guide/introduction.html"
|
||||||
}
|
}
|
||||||
],
|
]
|
||||||
"vite.config.ts": {
|
|
||||||
"label": "Remanso GitHub app",
|
|
||||||
"uri": "https://github.com/organizations/remanso-spance/settings/apps/lite-note"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
40
.gitea/workflows/ci.yml
Normal file
40
.gitea/workflows/ci.yml
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ci-${{ gitea.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
verify:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup pnpm
|
||||||
|
uses: pnpm/action-setup@v4
|
||||||
|
with:
|
||||||
|
version: 11.5.2
|
||||||
|
|
||||||
|
- name: Setup Node
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
cache: pnpm
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Type-check
|
||||||
|
run: pnpm types
|
||||||
|
|
||||||
|
- name: Lint
|
||||||
|
run: pnpm lint
|
||||||
|
|
||||||
|
- name: Test
|
||||||
|
run: pnpm test --run
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -2,7 +2,6 @@
|
|||||||
node_modules
|
node_modules
|
||||||
/dist
|
/dist
|
||||||
|
|
||||||
|
|
||||||
# local env files
|
# local env files
|
||||||
.env.local
|
.env.local
|
||||||
.env.*.local
|
.env.*.local
|
||||||
|
|||||||
@@ -1,37 +0,0 @@
|
|||||||
# Define the checksum file
|
|
||||||
CHECKSUM_FILE=".env.checksum"
|
|
||||||
|
|
||||||
# Calculate the current checksum of the .env file
|
|
||||||
CURRENT_CHECKSUM=$(shasum -a 256 .env | awk '{ print $1 }')
|
|
||||||
|
|
||||||
# Check if checksum file exists
|
|
||||||
if [ -f "$CHECKSUM_FILE" ]; then
|
|
||||||
# Read the previous checksum
|
|
||||||
PREVIOUS_CHECKSUM=$(cat "$CHECKSUM_FILE")
|
|
||||||
|
|
||||||
# Compare the current checksum with the previous checksum
|
|
||||||
if [ "$CURRENT_CHECKSUM" = "$PREVIOUS_CHECKSUM" ]; then
|
|
||||||
echo ".env file has not changed. Skipping Netlify environment import."
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
# If the checksum is different or the file doesn't exist, import the variables
|
|
||||||
echo "Importing environment variables to Netlify..."
|
|
||||||
netlify env:import .env
|
|
||||||
|
|
||||||
if [ $? -ne 0 ]; then
|
|
||||||
echo "Failed to import environment variables to Netlify. Aborting push."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Save the new checksum
|
|
||||||
echo "$CURRENT_CHECKSUM" > "$CHECKSUM_FILE"
|
|
||||||
|
|
||||||
# Stage the checksum file
|
|
||||||
git add "$CHECKSUM_FILE"
|
|
||||||
|
|
||||||
# Amend the last commit with the updated checksum
|
|
||||||
git commit -m "Update .env checksum"
|
|
||||||
|
|
||||||
echo "Environment variables imported successfully."
|
|
||||||
23
.zed/settings.json
Normal file
23
.zed/settings.json
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"format_on_save": "on",
|
||||||
|
"formatter": {
|
||||||
|
"external": {
|
||||||
|
"command": "node_modules/.bin/oxfmt",
|
||||||
|
"arguments": ["--stdin-filepath", "{buffer_path}"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"languages": {
|
||||||
|
"TypeScript": {
|
||||||
|
"language_servers": ["!deno", "..."]
|
||||||
|
},
|
||||||
|
"TSX": {
|
||||||
|
"language_servers": ["!deno", "..."]
|
||||||
|
},
|
||||||
|
"JavaScript": {
|
||||||
|
"language_servers": ["!deno", "..."]
|
||||||
|
},
|
||||||
|
"JSX": {
|
||||||
|
"language_servers": ["!deno", "..."]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,7 +25,7 @@ Run a single test file: `pnpm test src/modules/repo/services/resolvePath.spec.ts
|
|||||||
- **Vue 3** (3.5) with Composition API, **Pinia** for state, **Vue Router**
|
- **Vue 3** (3.5) with Composition API, **Pinia** for state, **Vue Router**
|
||||||
- **Vite 7** build tool, **TypeScript** strict mode
|
- **Vite 7** build tool, **TypeScript** strict mode
|
||||||
- **Tailwind CSS v4** + **DaisyUI v5** for styling (PostCSS plugin approach, not tailwind.config.js)
|
- **Tailwind CSS v4** + **DaisyUI v5** for styling (PostCSS plugin approach, not tailwind.config.js)
|
||||||
- **TanStack Vue Query** + **ts-rest** + **arktype** for typed API contracts
|
- **arktype** for runtime type validation (used by the ATProto module)
|
||||||
- **PouchDB** (IndexedDB) for local persistence
|
- **PouchDB** (IndexedDB) for local persistence
|
||||||
- **Octokit** for GitHub API integration
|
- **Octokit** for GitHub API integration
|
||||||
- **markdown-it** with plugins (KaTeX, Shikiji, Mermaid, checkboxes, GitHub alerts)
|
- **markdown-it** with plugins (KaTeX, Shikiji, Mermaid, checkboxes, GitHub alerts)
|
||||||
@@ -50,8 +50,7 @@ src/
|
|||||||
│ ├── user/ # Authentication, user settings
|
│ ├── user/ # Authentication, user settings
|
||||||
│ ├── card/ # Spaced repetition
|
│ ├── card/ # Spaced repetition
|
||||||
│ ├── history/ # Edit history tracking
|
│ ├── history/ # Edit history tracking
|
||||||
│ ├── atproto/ # ATProto/Bluesky integration (DID resolution, blob URLs)
|
│ └── atproto/ # ATProto/Bluesky integration (DID resolution, blob URLs)
|
||||||
│ └── post/ # ts-rest API client for public note publishing (api.remanso.space)
|
|
||||||
├── hooks/ # Composition hooks (useMarkdown, useBacklinks, useGitHubContent, etc.)
|
├── hooks/ # Composition hooks (useMarkdown, useBacklinks, useGitHubContent, etc.)
|
||||||
├── data/ # PouchDB wrapper and data models
|
├── data/ # PouchDB wrapper and data models
|
||||||
├── utils/ # Utilities including custom markdown-it plugins
|
├── utils/ # Utilities including custom markdown-it plugins
|
||||||
@@ -73,7 +72,6 @@ src/
|
|||||||
- `src/modules/repo/store/userRepo.store.ts` - Central Pinia store for repo/file state
|
- `src/modules/repo/store/userRepo.store.ts` - Central Pinia store for repo/file state
|
||||||
- `src/modules/repo/services/octo.ts` - Octokit wrapper for GitHub API
|
- `src/modules/repo/services/octo.ts` - Octokit wrapper for GitHub API
|
||||||
- `src/hooks/useMarkdown.hook.ts` - Markdown rendering with all plugins
|
- `src/hooks/useMarkdown.hook.ts` - Markdown rendering with all plugins
|
||||||
- `src/modules/post/data/client.ts` - ts-rest API contract definitions with arktype validation
|
|
||||||
- `src/data/data.ts` - PouchDB database wrapper
|
- `src/data/data.ts` - PouchDB database wrapper
|
||||||
|
|
||||||
### Patterns
|
### Patterns
|
||||||
@@ -81,5 +79,4 @@ src/
|
|||||||
- All components use Composition API (`<script setup lang="ts">`)
|
- All components use Composition API (`<script setup lang="ts">`)
|
||||||
- Custom hooks encapsulate feature logic (`use*.hook.ts` or `use*.ts`)
|
- Custom hooks encapsulate feature logic (`use*.hook.ts` or `use*.ts`)
|
||||||
- Event buses (`noteEventBus`, `backlinkEventBus`) for cross-component communication
|
- Event buses (`noteEventBus`, `backlinkEventBus`) for cross-component communication
|
||||||
- ts-rest contracts use arktype schemas for request/response validation
|
|
||||||
- Path alias: `@` maps to `src/`
|
- Path alias: `@` maps to `src/`
|
||||||
|
|||||||
10
Dockerfile
10
Dockerfile
@@ -1,11 +1,11 @@
|
|||||||
# ---- Stage 1: deps (only invalidated when lockfile changes) ----
|
# ---- Stage 1: deps (only invalidated when lockfile changes) ----
|
||||||
FROM node:22-alpine AS deps
|
FROM node:22-alpine AS deps
|
||||||
|
|
||||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
RUN corepack enable
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY package.json pnpm-lock.yaml ./
|
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
||||||
|
|
||||||
RUN pnpm install --frozen-lockfile
|
RUN pnpm install --frozen-lockfile
|
||||||
|
|
||||||
@@ -13,7 +13,7 @@ RUN pnpm install --frozen-lockfile
|
|||||||
# ---- Stage 2: build (invalidated on any source change) ----
|
# ---- Stage 2: build (invalidated on any source change) ----
|
||||||
FROM node:22-alpine AS builder
|
FROM node:22-alpine AS builder
|
||||||
|
|
||||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
RUN corepack enable
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
@@ -28,8 +28,6 @@ RUN pnpm run build
|
|||||||
FROM nginx:alpine AS runner
|
FROM nginx:alpine AS runner
|
||||||
|
|
||||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
|
||||||
EXPOSE 80
|
EXPOSE 80
|
||||||
|
|
||||||
HEALTHCHECK --interval=30s --timeout=3s \
|
|
||||||
CMD wget -qO- http://localhost:80/ || exit 1
|
|
||||||
|
|||||||
40
_scripts/build-monochrome-icon.ts
Normal file
40
_scripts/build-monochrome-icon.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import path from "path"
|
||||||
|
import sharp from "sharp"
|
||||||
|
|
||||||
|
// PWA spec: `purpose: "monochrome"` icons are *masks*. The user agent ignores
|
||||||
|
// RGB and uses only the alpha channel as the silhouette, then paints it with
|
||||||
|
// the platform theme color. So the source PNG must be RGBA with the silhouette
|
||||||
|
// in alpha, NOT a black-on-white RGB image.
|
||||||
|
|
||||||
|
const SRC = path.resolve(__dirname, "../public/favicon.png")
|
||||||
|
const OUT = path.resolve(__dirname, "../public/monochromeicon.png")
|
||||||
|
const SIZE = 1024
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const { data, info } = await sharp(SRC)
|
||||||
|
.resize(SIZE, SIZE, { fit: "contain", background: { r: 0, g: 0, b: 0, alpha: 0 } })
|
||||||
|
.ensureAlpha()
|
||||||
|
.raw()
|
||||||
|
.toBuffer({ resolveWithObject: true })
|
||||||
|
|
||||||
|
if (info.channels !== 4) throw new Error(`expected RGBA, got ${info.channels} channels`)
|
||||||
|
|
||||||
|
const out = Buffer.alloc(data.length)
|
||||||
|
for (let i = 0; i < data.length; i += 4) {
|
||||||
|
out[i] = 0
|
||||||
|
out[i + 1] = 0
|
||||||
|
out[i + 2] = 0
|
||||||
|
out[i + 3] = data[i + 3]
|
||||||
|
}
|
||||||
|
|
||||||
|
await sharp(out, { raw: { width: SIZE, height: SIZE, channels: 4 } })
|
||||||
|
.png({ compressionLevel: 9 })
|
||||||
|
.toFile(OUT)
|
||||||
|
|
||||||
|
console.log(`Wrote ${OUT} (${SIZE}x${SIZE} RGBA)`)
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((e) => {
|
||||||
|
console.error(e)
|
||||||
|
process.exit(1)
|
||||||
|
})
|
||||||
@@ -28,8 +28,8 @@ let themeConfigContent = readFileSync(themeConfigPath, "utf8")
|
|||||||
|
|
||||||
// Remplacer la valeur du thème sombre
|
// Remplacer la valeur du thème sombre
|
||||||
themeConfigContent = themeConfigContent.replace(
|
themeConfigContent = themeConfigContent.replace(
|
||||||
/dark:\s*['"][^'"]*['"],/,
|
/dark:\s*['"][^'"]*['"](,?)/,
|
||||||
`dark: '${newTheme}',`
|
`dark: '${newTheme}'$1`
|
||||||
)
|
)
|
||||||
|
|
||||||
// Écrire le contenu mis à jour dans le fichier
|
// Écrire le contenu mis à jour dans le fichier
|
||||||
|
|||||||
@@ -0,0 +1,236 @@
|
|||||||
|
# Remanso React Native Migration — Design Spec
|
||||||
|
|
||||||
|
**Date:** 2026-04-20
|
||||||
|
**Status:** Approved
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Migrate Remanso from a Vue 3 web app to a fully native iOS + Android app built with Expo (React Native). The primary motivation is native feel: fluid stack animations, swipe-back gestures, and native navigation chrome. The scope is a full replacement of the web app.
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
| Concern | Current (Vue) | React Native |
|
||||||
|
| ---------------- | ---------------------- | ---------------------------------------------------------- |
|
||||||
|
| Framework | Vue 3 + Vite | Expo SDK (managed workflow) |
|
||||||
|
| Routing | Vue Router | Expo Router (file-system routing over React Navigation v7) |
|
||||||
|
| State | Pinia | Zustand |
|
||||||
|
| Server state | TanStack Vue Query | TanStack Query (same library) |
|
||||||
|
| Styling | DaisyUI + Tailwind CSS | NativeWind v4 |
|
||||||
|
| Local DB | PouchDB (IndexedDB) | Expo SQLite |
|
||||||
|
| Simple KV store | localStorage | MMKV |
|
||||||
|
| Auth | OAuth redirects | expo-auth-session |
|
||||||
|
| GitHub API | @octokit/rest | @octokit/rest (unchanged) |
|
||||||
|
| i18n | vue-i18n | react-i18next |
|
||||||
|
| Date utils | date-fns | date-fns (unchanged) |
|
||||||
|
| Markdown content | markdown-it (DOM) | react-native-webview |
|
||||||
|
| Fonts | CSS custom properties | expo-font |
|
||||||
|
|
||||||
|
## Navigation Structure
|
||||||
|
|
||||||
|
Expo Router generates a React Navigation v7 tree from the file system. The stacked note pattern — the core native feel win — maps to a nested Stack navigator where each backlink push adds a note with a native slide animation and swipe-left pops it.
|
||||||
|
|
||||||
|
```
|
||||||
|
Root Stack
|
||||||
|
├── Auth screens (unauthenticated, no tab bar)
|
||||||
|
│ ├── Home / Welcome
|
||||||
|
│ ├── GitHub OAuth callback
|
||||||
|
│ └── ATProto OAuth callback
|
||||||
|
│
|
||||||
|
└── Authenticated App — Bottom Tabs
|
||||||
|
├── Tab: Feed (Stack)
|
||||||
|
│ ├── Repo picker
|
||||||
|
│ └── Note Stack (nested Stack)
|
||||||
|
│ ├── Note (root)
|
||||||
|
│ ├── Note (pushed via backlink) ← swipe-back to pop
|
||||||
|
│ └── Note (pushed via backlink) ← swipe-back to pop
|
||||||
|
│
|
||||||
|
├── Tab: Inbox / Drafts / Todos (Stack)
|
||||||
|
│ └── Note list → Note detail
|
||||||
|
│
|
||||||
|
├── Tab: Public Notes (Stack)
|
||||||
|
│ ├── Public note list
|
||||||
|
│ └── Public note detail
|
||||||
|
│
|
||||||
|
└── Tab: Settings (Stack)
|
||||||
|
├── Settings root
|
||||||
|
└── Font picker (presented as modal)
|
||||||
|
```
|
||||||
|
|
||||||
|
History and Spaced Repetition are accessible as sections within the Feed tab or as additional tabs — to be decided during implementation.
|
||||||
|
|
||||||
|
## Data Layer
|
||||||
|
|
||||||
|
PouchDB is replaced by **Expo SQLite** for structured data and **MMKV** for simple key-value preferences.
|
||||||
|
|
||||||
|
### Expo SQLite schema
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE IF NOT EXISTS github_tokens (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
access_token TEXT NOT NULL,
|
||||||
|
refresh_token TEXT,
|
||||||
|
expires_at INTEGER
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS atproto_sessions (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
did TEXT NOT NULL
|
||||||
|
-- full session_json stored in Expo SecureStore keyed by id
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS saved_repos (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user TEXT NOT NULL,
|
||||||
|
repo TEXT NOT NULL,
|
||||||
|
files_json TEXT NOT NULL,
|
||||||
|
cached_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS history (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user TEXT NOT NULL,
|
||||||
|
repo TEXT NOT NULL,
|
||||||
|
note_path TEXT NOT NULL,
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
created_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
The existing `DataApi` interface (`add`, `update`, `remove`, `get`, `getAll`) is re-implemented as a thin wrapper over Expo SQLite. No ORM — plain async SQL with `CREATE TABLE IF NOT EXISTS` on first open handles schema initialization at this scale.
|
||||||
|
|
||||||
|
Sensitive data (access tokens, refresh tokens, ATProto sessions) is stored in **Expo SecureStore** rather than plain SQLite. SQLite holds only metadata and content cache.
|
||||||
|
|
||||||
|
### MMKV
|
||||||
|
|
||||||
|
Replaces localStorage for user settings: font family, font size, theme preference. Synchronous reads, no async overhead.
|
||||||
|
|
||||||
|
### No Web Worker
|
||||||
|
|
||||||
|
Database calls move to the main thread via Expo SQLite's async API. The performance concern that justified the Web Worker on web does not apply on mobile.
|
||||||
|
|
||||||
|
## Auth Flows
|
||||||
|
|
||||||
|
### GitHub OAuth
|
||||||
|
|
||||||
|
The existing auth server (`api.remanso.space/auth/github`) handles code exchange — the client flow is unchanged:
|
||||||
|
|
||||||
|
1. `expo-auth-session` opens an in-app browser tab with the GitHub authorize URL
|
||||||
|
2. OAuth redirect captured by Expo's deep link handler
|
||||||
|
3. Code sent to `api.remanso.space/auth/github?code=...` for token exchange
|
||||||
|
4. Access token + refresh token stored in Expo SecureStore
|
||||||
|
5. Token refresh logic (15-minute pre-expiry check) stays the same; HTTP calls use `fetch`
|
||||||
|
|
||||||
|
### ATProto / Bluesky OAuth
|
||||||
|
|
||||||
|
`@atproto/oauth-client-browser` is browser-only (IndexedDB, `window.crypto`, browser redirects). There is no official React Native client. A custom client (~200–300 lines) is implemented using:
|
||||||
|
|
||||||
|
- `expo-auth-session` for the OAuth redirect flow (PKCE)
|
||||||
|
- `expo-crypto` for PKCE code verifier/challenge generation
|
||||||
|
- Expo SecureStore for session persistence
|
||||||
|
- The same Bluesky API endpoints as the browser client
|
||||||
|
|
||||||
|
This keeps ATProto auth fully client-side, consistent with the app's current architecture.
|
||||||
|
|
||||||
|
## State Management
|
||||||
|
|
||||||
|
Zustand replaces Pinia. The store shape is identical to `userRepo.store.ts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const useRepoStore = create<RepoState>((set, get) => ({
|
||||||
|
user: "",
|
||||||
|
repo: "",
|
||||||
|
files: [],
|
||||||
|
userSettings: null,
|
||||||
|
needToLogin: false,
|
||||||
|
setRepo: (user, repo) => set({ user, repo }),
|
||||||
|
loadFiles: async () => {
|
||||||
|
/* Octokit call */
|
||||||
|
},
|
||||||
|
loadSettings: async () => {
|
||||||
|
/* MMKV read */
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
```
|
||||||
|
|
||||||
|
TanStack Query handles all GitHub API server state (file fetching, README, repo listing) — same library, same patterns as today.
|
||||||
|
|
||||||
|
## Styling
|
||||||
|
|
||||||
|
NativeWind v4 provides Tailwind utility classes in React Native. DaisyUI is web-only and has no React Native equivalent — all component styling (buttons, cards, modals) is hand-written using NativeWind utilities.
|
||||||
|
|
||||||
|
The two DaisyUI themes (`retro` light, `coffee` dark) are translated into a custom NativeWind theme in `tailwind.config.ts` with the same color tokens. System appearance (`useColorScheme`) drives theme selection.
|
||||||
|
|
||||||
|
Font customization uses `expo-font` for loading custom fonts and React Native's `fontFamily` style prop, replacing the CSS custom property approach.
|
||||||
|
|
||||||
|
## Markdown Rendering
|
||||||
|
|
||||||
|
The markdown-it pipeline (KaTeX, Mermaid, shiki, tabler icons, html5-media, GitHub alerts, checkboxes) runs in the React Native JS context unchanged — same code, same output. The resulting HTML string is passed to a `NoteWebView` component built on `react-native-webview`.
|
||||||
|
|
||||||
|
`NoteWebView` is a native UIView/View wrapper around a WebView engine. It is a React Native component — not a web app. The surrounding app (navigation chrome, tab bar, headers, settings, auth screens) is 100% native. Only the note content pane renders HTML. This is the standard pattern for rich content in React Native (used by GitHub Mobile, Linear, and others).
|
||||||
|
|
||||||
|
The WebView communicates back to the native layer via `postMessage` for:
|
||||||
|
|
||||||
|
- Internal note link taps (trigger React Navigation push)
|
||||||
|
- External URL taps (open in system browser)
|
||||||
|
- Backlink detection events
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── app/ # Expo Router — file-system screens
|
||||||
|
│ ├── _layout.tsx # Root Stack navigator
|
||||||
|
│ ├── index.tsx # Home / Welcome
|
||||||
|
│ └── (tabs)/ # Authenticated tab navigator
|
||||||
|
│ ├── _layout.tsx
|
||||||
|
│ ├── feed/ # Feed + Note Stack screens
|
||||||
|
│ ├── inbox/
|
||||||
|
│ ├── public/
|
||||||
|
│ └── settings/
|
||||||
|
│
|
||||||
|
├── modules/ # Feature domains (mirrors current structure)
|
||||||
|
│ ├── note/ # Note models, hooks, caching
|
||||||
|
│ ├── repo/ # Zustand store, Octokit service
|
||||||
|
│ ├── user/
|
||||||
|
│ │ ├── auth/ # GitHub + ATProto OAuth hooks
|
||||||
|
│ │ └── fonts.ts # Font downloading (was utils/downloadFont.ts)
|
||||||
|
│ ├── card/ # Spaced repetition
|
||||||
|
│ ├── history/ # Edit history
|
||||||
|
│ ├── atproto/ # Custom ATProto OAuth client, DID resolution
|
||||||
|
│ └── post/ # ts-rest API client (unchanged)
|
||||||
|
│
|
||||||
|
├── components/ # Shared UI components
|
||||||
|
│
|
||||||
|
├── rendering/ # Markdown pipeline — first-class module
|
||||||
|
│ ├── pipeline.ts # markdown-it setup + plugin registration
|
||||||
|
│ ├── plugins/ # Custom plugins
|
||||||
|
│ │ ├── html5-media.ts
|
||||||
|
│ │ ├── regexp.ts
|
||||||
|
│ │ └── tabler-icons.ts
|
||||||
|
│ └── NoteWebView.tsx # react-native-webview wrapper
|
||||||
|
│
|
||||||
|
├── lib/ # Shared low-level, stateless helpers
|
||||||
|
│ ├── text.ts # slugify, noteTitle, displayLanguage
|
||||||
|
│ ├── links.ts # link.ts + youtube.ts
|
||||||
|
│ ├── encoding.ts # decodeBase64ToUTF8
|
||||||
|
│ └── notifications.ts # notif.ts
|
||||||
|
│
|
||||||
|
├── hooks/ # React hooks
|
||||||
|
├── data/ # Expo SQLite wrapper (same DataApi interface)
|
||||||
|
├── locales/ # i18n strings (unchanged)
|
||||||
|
└── constants/ # Theme tokens, note width constants
|
||||||
|
```
|
||||||
|
|
||||||
|
## Key Risks
|
||||||
|
|
||||||
|
1. **ATProto custom OAuth client** — no official SDK; requires careful PKCE implementation and session lifecycle management.
|
||||||
|
2. **DaisyUI → NativeWind component styling** — no 1:1 mapping; all themed components need to be rebuilt. Most labor-intensive non-feature work.
|
||||||
|
3. **NoteWebView ↔ native bridge** — link tap handling and scroll coordination between the WebView and the native Stack navigator require careful implementation to feel seamless.
|
||||||
|
4. **Mermaid in WebView** — Mermaid is JS-heavy; initial render may be slow on lower-end Android. May need lazy rendering or a timeout fallback.
|
||||||
|
|
||||||
|
## Out of Scope
|
||||||
|
|
||||||
|
- PWA / service worker (not applicable to native)
|
||||||
|
- Web Worker (replaced by async SQLite)
|
||||||
|
- Comlink (not applicable)
|
||||||
|
- Server-side rendering
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en" data-theme="garden">
|
<html lang="en" data-theme="light">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
||||||
|
|||||||
9
nginx.conf
Normal file
9
nginx.conf
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
}
|
||||||
16
package.json
16
package.json
@@ -2,6 +2,7 @@
|
|||||||
"name": "remanso",
|
"name": "remanso",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
|
"packageManager": "pnpm@11.5.2",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
@@ -12,33 +13,35 @@
|
|||||||
"lint:fix": "oxlint --fix",
|
"lint:fix": "oxlint --fix",
|
||||||
"fmt": "oxfmt",
|
"fmt": "oxfmt",
|
||||||
"fmt:check": "oxfmt --check",
|
"fmt:check": "oxfmt --check",
|
||||||
"prepare": "husky",
|
|
||||||
"theme:light": "esno _scripts/change-theme-light.ts",
|
"theme:light": "esno _scripts/change-theme-light.ts",
|
||||||
"theme:dark": "esno _scripts/change-theme-dark.ts",
|
"theme:dark": "esno _scripts/change-theme-dark.ts"
|
||||||
"generate-pwa-assets": "pwa-assets-generator"
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@atproto/oauth-client-browser": "^0.3.41",
|
"@atproto/oauth-client-browser": "^0.3.41",
|
||||||
"@better-fetch/fetch": "^1.1.21",
|
"@better-fetch/fetch": "^1.1.21",
|
||||||
"@better-fetch/logger": "^1.1.21",
|
"@better-fetch/logger": "^1.1.21",
|
||||||
"@intlify/unplugin-vue-i18n": "^6.0.8",
|
"@intlify/unplugin-vue-i18n": "^6.0.8",
|
||||||
|
"@mdit/plugin-tab": "^0.24.2",
|
||||||
"@octokit/core": "^7.0.6",
|
"@octokit/core": "^7.0.6",
|
||||||
"@octokit/rest": "^22.0.1",
|
"@octokit/rest": "^22.0.1",
|
||||||
"@openpanel/web": "^1.3.0",
|
"@openpanel/web": "^1.3.0",
|
||||||
"@tailwindcss/postcss": "^4.1.16",
|
"@tailwindcss/postcss": "^4.1.16",
|
||||||
"@tanstack/vue-query": "^5.92.9",
|
|
||||||
"@toycode/markdown-it-class": "^1.2.4",
|
"@toycode/markdown-it-class": "^1.2.4",
|
||||||
"@vscode/markdown-it-katex": "^1.1.2",
|
"@vscode/markdown-it-katex": "^1.1.2",
|
||||||
"@vueuse/components": "^14.2.1",
|
"@vueuse/components": "^14.2.1",
|
||||||
"@vueuse/core": "^13.6.0",
|
"@vueuse/core": "^13.6.0",
|
||||||
"@vueuse/router": "^13.6.0",
|
"@vueuse/router": "^13.6.0",
|
||||||
"arktype": "^2.1.29",
|
"arktype": "^2.1.29",
|
||||||
|
"comlink": "^4.4.2",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
|
"dompurify": "^3.4.3",
|
||||||
"events": "^3.3.0",
|
"events": "^3.3.0",
|
||||||
"font-color-contrast": "^11.1.0",
|
"font-color-contrast": "^11.1.0",
|
||||||
"fontfaceobserver": "^2.3.0",
|
"fontfaceobserver": "^2.3.0",
|
||||||
|
"github-slugger": "^2.0.0",
|
||||||
"isomorphic-fetch": "^3.0.0",
|
"isomorphic-fetch": "^3.0.0",
|
||||||
"markdown-it": "^14.1.0",
|
"markdown-it": "^14.1.0",
|
||||||
|
"markdown-it-anchor": "^9.2.0",
|
||||||
"markdown-it-block-embed": "^0.0.3",
|
"markdown-it-block-embed": "^0.0.3",
|
||||||
"markdown-it-checkbox": "^1.1.0",
|
"markdown-it-checkbox": "^1.1.0",
|
||||||
"markdown-it-github-alerts": "^1.0.0",
|
"markdown-it-github-alerts": "^1.0.0",
|
||||||
@@ -55,6 +58,7 @@
|
|||||||
"register-service-worker": "^1.7.2",
|
"register-service-worker": "^1.7.2",
|
||||||
"retrobus": "^1.9.4",
|
"retrobus": "^1.9.4",
|
||||||
"sanitize-html": "^2.17.0",
|
"sanitize-html": "^2.17.0",
|
||||||
|
"shikiji-core": "0.10.2",
|
||||||
"vue": "^3.5.18",
|
"vue": "^3.5.18",
|
||||||
"vue-i18n": "^11.1.11",
|
"vue-i18n": "^11.1.11",
|
||||||
"vue-router": "^4.5.1",
|
"vue-router": "^4.5.1",
|
||||||
@@ -63,6 +67,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/core": "^7.28.5",
|
"@babel/core": "^7.28.5",
|
||||||
|
"@pinia/testing": "^1.0.3",
|
||||||
"@tailwindcss/typography": "^0.5.19",
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
"@types/fontfaceobserver": "^2.1.3",
|
"@types/fontfaceobserver": "^2.1.3",
|
||||||
"@types/markdown-it": "^14.1.2",
|
"@types/markdown-it": "^14.1.2",
|
||||||
@@ -72,6 +77,7 @@
|
|||||||
"@vite-pwa/assets-generator": "^1.0.2",
|
"@vite-pwa/assets-generator": "^1.0.2",
|
||||||
"@vitejs/plugin-vue": "^5.2.4",
|
"@vitejs/plugin-vue": "^5.2.4",
|
||||||
"@vue/compiler-sfc": "^3.5.28",
|
"@vue/compiler-sfc": "^3.5.28",
|
||||||
|
"@vue/test-utils": "^2.4.11",
|
||||||
"autoprefixer": "^10.4.24",
|
"autoprefixer": "^10.4.24",
|
||||||
"daisyui": "^5.5.18",
|
"daisyui": "^5.5.18",
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
@@ -79,7 +85,7 @@
|
|||||||
"eslint-plugin-simple-import-sort": "^12.1.1",
|
"eslint-plugin-simple-import-sort": "^12.1.1",
|
||||||
"eslint-plugin-unused-imports": "^4.4.1",
|
"eslint-plugin-unused-imports": "^4.4.1",
|
||||||
"esno": "^4.8.0",
|
"esno": "^4.8.0",
|
||||||
"husky": "^9.1.7",
|
"jsdom": "^29.1.1",
|
||||||
"oxfmt": "^0.42.0",
|
"oxfmt": "^0.42.0",
|
||||||
"oxlint": "^1.57.0",
|
"oxlint": "^1.57.0",
|
||||||
"prettier": "^3.8.1",
|
"prettier": "^3.8.1",
|
||||||
|
|||||||
925
pnpm-lock.yaml
generated
925
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,2 +1,7 @@
|
|||||||
allowBuilds:
|
allowBuilds:
|
||||||
|
'@parcel/watcher': true
|
||||||
core-js: true
|
core-js: true
|
||||||
|
esbuild: true
|
||||||
|
fsevents: true
|
||||||
|
sharp: true
|
||||||
|
vue-demi: true
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 181 KiB |
BIN
public/monochromeicon.png
Normal file
BIN
public/monochromeicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 101 KiB |
@@ -1,10 +0,0 @@
|
|||||||
{
|
|
||||||
"version": 1,
|
|
||||||
"skills": {
|
|
||||||
"migrate-oxlint": {
|
|
||||||
"source": "oxc-project/oxc",
|
|
||||||
"sourceType": "github",
|
|
||||||
"computedHash": "80ce5201b1ef52d6cabe553a4cacfd6e1db97bad99618216b9cf9318d11d7e64"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
10
src/App.vue
10
src/App.vue
@@ -17,10 +17,18 @@ const { isATProtoReady } = useATProtoLogin()
|
|||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
#main-app {
|
#main-app {
|
||||||
height: 100vh;
|
height: 100dvh;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
max-width: none;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (max-width: 768px) {
|
||||||
|
#main-app {
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
::view-transition-old(root),
|
::view-transition-old(root),
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { OpenPanel } from "@openpanel/web"
|
import { OpenPanel } from "@openpanel/web"
|
||||||
|
|
||||||
export const op = new OpenPanel({
|
export const op = new OpenPanel({
|
||||||
apiUrl: "https://api.panel.apoena.dev",
|
apiUrl: "https://api.stats.apoena.dev",
|
||||||
clientId: "038a6aac-19bb-4a7f-9aae-2d0201fead5b",
|
clientId: "038a6aac-19bb-4a7f-9aae-2d0201fead5b",
|
||||||
trackScreenViews: true,
|
trackScreenViews: true,
|
||||||
trackOutgoingLinks: true,
|
trackOutgoingLinks: true,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ interface EventBusParams {
|
|||||||
user: string
|
user: string
|
||||||
repo: string
|
repo: string
|
||||||
path: string
|
path: string
|
||||||
|
hash?: string
|
||||||
currentNoteSHA?: string
|
currentNoteSHA?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { onBeforeMount, ref } from "vue"
|
|||||||
import { useRoute, useRouter } from "vue-router"
|
import { useRoute, useRouter } from "vue-router"
|
||||||
|
|
||||||
import { useGitHubLogin } from "@/hooks/useGitHubLogin.hook"
|
import { useGitHubLogin } from "@/hooks/useGitHubLogin.hook"
|
||||||
|
import { consumeGithubOAuthReturnPath } from "@/modules/user/service/oauthReturnPath"
|
||||||
import { signIn } from "@/modules/user/service/signIn"
|
import { signIn } from "@/modules/user/service/signIn"
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
@@ -19,12 +20,17 @@ onBeforeMount(async () => {
|
|||||||
if ("error" in token) {
|
if ("error" in token) {
|
||||||
hasError.value = true
|
hasError.value = true
|
||||||
} else {
|
} else {
|
||||||
token.access_token
|
await saveCredentials(token)
|
||||||
saveCredentials(token)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const returnPath = consumeGithubOAuthReturnPath()
|
||||||
|
|
||||||
|
if (!hasError.value && returnPath) {
|
||||||
|
router.replace(returnPath)
|
||||||
|
} else {
|
||||||
router.replace({ name: "Home" })
|
router.replace({ name: "Home" })
|
||||||
}
|
}
|
||||||
|
}
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ const goBack = () => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<a class="btn btn-sm back-button" @click="goBack">
|
<button class="btn btn-sm back-button text-base-content" @click="goBack">
|
||||||
<svg
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
class="icon icon-tabler icon-tabler-arrow-narrow-left"
|
class="icon icon-tabler icon-tabler-arrow-narrow-left"
|
||||||
@@ -41,5 +41,5 @@ const goBack = () => {
|
|||||||
<line x1="5" y1="12" x2="9" y2="16" />
|
<line x1="5" y1="12" x2="9" y2="16" />
|
||||||
<line x1="5" y1="12" x2="9" y2="8" />
|
<line x1="5" y1="12" x2="9" y2="8" />
|
||||||
</svg>
|
</svg>
|
||||||
</a>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,18 +1,14 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import {
|
import { computed, onMounted, onUnmounted, toRefs, watch } from "vue"
|
||||||
computed,
|
|
||||||
defineAsyncComponent,
|
|
||||||
nextTick,
|
|
||||||
onMounted,
|
|
||||||
onUnmounted,
|
|
||||||
toRefs,
|
|
||||||
watch
|
|
||||||
} from "vue"
|
|
||||||
|
|
||||||
|
import HeaderNote from "@/components/HeaderNote.vue"
|
||||||
|
import SignInGithub from "@/components/SignInGithub.vue"
|
||||||
import SkeletonLoader from "@/components/SkeletonLoader.vue"
|
import SkeletonLoader from "@/components/SkeletonLoader.vue"
|
||||||
import StackedNote from "@/components/StackedNote.vue"
|
import StackedNote from "@/components/StackedNote.vue"
|
||||||
|
import { useGitHubLogin } from "@/hooks/useGitHubLogin.hook"
|
||||||
import { useLinks } from "@/hooks/useLinks.hook"
|
import { useLinks } from "@/hooks/useLinks.hook"
|
||||||
import { markdownBuilder } from "@/hooks/useMarkdown.hook"
|
import { markdownBuilder } from "@/hooks/useMarkdown.hook"
|
||||||
|
import { useMarkdownPostRender } from "@/hooks/useMarkdownPostRender.hook"
|
||||||
import { useNoteView } from "@/hooks/useNoteView.hook"
|
import { useNoteView } from "@/hooks/useNoteView.hook"
|
||||||
import { useResizeContainer } from "@/hooks/useResizeContainer.hook"
|
import { useResizeContainer } from "@/hooks/useResizeContainer.hook"
|
||||||
import { useRouteQueryStackedNotes } from "@/hooks/useRouteQueryStackedNotes.hook"
|
import { useRouteQueryStackedNotes } from "@/hooks/useRouteQueryStackedNotes.hook"
|
||||||
@@ -21,10 +17,6 @@ import CacheAllNotes from "@/modules/note/components/CacheAllNote.vue"
|
|||||||
import { useUserRepoStore } from "@/modules/repo/store/userRepo.store"
|
import { useUserRepoStore } from "@/modules/repo/store/userRepo.store"
|
||||||
import { useUserSettings } from "@/modules/user/hooks/useUserSettings.hook"
|
import { useUserSettings } from "@/modules/user/hooks/useUserSettings.hook"
|
||||||
|
|
||||||
const HeaderNote = defineAsyncComponent(
|
|
||||||
() => import("@/components/HeaderNote.vue")
|
|
||||||
)
|
|
||||||
|
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
user: string
|
user: string
|
||||||
@@ -54,6 +46,7 @@ const { listenToClick } = useLinks("note-display")
|
|||||||
const { stackedNotes, scrollToFocusedNote } = useRouteQueryStackedNotes()
|
const { stackedNotes, scrollToFocusedNote } = useRouteQueryStackedNotes()
|
||||||
|
|
||||||
const { titles } = useNoteView()
|
const { titles } = useNoteView()
|
||||||
|
const { isLogged } = useGitHubLogin()
|
||||||
useResizeContainer("note-container", stackedNotes)
|
useResizeContainer("note-container", stackedNotes)
|
||||||
|
|
||||||
const renderedContent = computed(() =>
|
const renderedContent = computed(() =>
|
||||||
@@ -67,14 +60,10 @@ const renderedContent = computed(() =>
|
|||||||
const isLoading = computed(() => renderedContent.value === undefined)
|
const isLoading = computed(() => renderedContent.value === undefined)
|
||||||
const hasContent = computed(() => !!renderedContent.value)
|
const hasContent = computed(() => !!renderedContent.value)
|
||||||
|
|
||||||
watch(
|
useMarkdownPostRender(renderedContent, () => ".note-display", {
|
||||||
renderedContent,
|
onReady: () => listenToClick(),
|
||||||
async () => {
|
tikz: true
|
||||||
await nextTick()
|
})
|
||||||
listenToClick()
|
|
||||||
},
|
|
||||||
{ immediate: true }
|
|
||||||
)
|
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
[refProps.user, refProps.repo],
|
[refProps.user, refProps.repo],
|
||||||
@@ -84,6 +73,10 @@ watch(
|
|||||||
{ immediate: true }
|
{ immediate: true }
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const retryLoad = () => {
|
||||||
|
store.setUserRepo(props.user, props.repo)
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(() => visitRepo())
|
onMounted(() => visitRepo())
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
@@ -107,14 +100,30 @@ onUnmounted(() => {
|
|||||||
<h1 class="heading-1">
|
<h1 class="heading-1">
|
||||||
{{ repo }}
|
{{ repo }}
|
||||||
</h1>
|
</h1>
|
||||||
{{ user }}
|
· {{ user }}
|
||||||
</div>
|
</div>
|
||||||
<cache-all-notes />
|
<cache-all-notes />
|
||||||
</div>
|
</div>
|
||||||
<slot />
|
<slot />
|
||||||
<skeleton-loader v-if="isLoading || !hasContent" />
|
<skeleton-loader v-if="isLoading" />
|
||||||
|
<div
|
||||||
|
v-else-if="withContent && !hasContent && store.loadError === 'network'"
|
||||||
|
class="repo-network-error"
|
||||||
|
>
|
||||||
|
<p>Couldn't reach GitHub. Check your connection.</p>
|
||||||
|
<button class="btn btn-primary" @click="retryLoad">Retry</button>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="withContent && !hasContent" class="repo-not-found">
|
||||||
|
<template v-if="isLogged">
|
||||||
|
<p>This repository is not accessible.</p>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<p>This repository is private. Sign in to view it.</p>
|
||||||
|
<sign-in-github />
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
<p
|
<p
|
||||||
v-else-if="withContent"
|
v-else-if="withContent && hasContent"
|
||||||
class="note-display"
|
class="note-display"
|
||||||
v-html="renderedContent"
|
v-html="renderedContent"
|
||||||
/>
|
/>
|
||||||
@@ -139,6 +148,10 @@ $header-height: 40px;
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
|
||||||
|
.heading-1 {
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
&.content {
|
&.content {
|
||||||
.title,
|
.title,
|
||||||
h1,
|
h1,
|
||||||
@@ -200,6 +213,16 @@ $header-height: 40px;
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.repo-not-found,
|
||||||
|
.repo-network-error {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 2rem 0;
|
||||||
|
color: var(--color-base-content);
|
||||||
|
}
|
||||||
|
|
||||||
.note-display {
|
.note-display {
|
||||||
padding-bottom: 2rem;
|
padding-bottom: 2rem;
|
||||||
}
|
}
|
||||||
@@ -208,12 +231,6 @@ $header-height: 40px;
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
height: 100vh;
|
|
||||||
position: sticky;
|
|
||||||
|
|
||||||
&:not(:first-child) {
|
|
||||||
border-top: 1px solid rgba(18, 19, 58, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.title {
|
.title {
|
||||||
text-align: left;
|
text-align: left;
|
||||||
@@ -221,6 +238,8 @@ $header-height: 40px;
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media screen and (min-width: 769px) {
|
@media screen and (min-width: 769px) {
|
||||||
|
background-color: var(--note-canvas-bg);
|
||||||
|
|
||||||
.repo-title-breadcrumb {
|
.repo-title-breadcrumb {
|
||||||
padding: 0.5rem 1rem 0;
|
padding: 0.5rem 1rem 0;
|
||||||
transform-origin: 0 0;
|
transform-origin: 0 0;
|
||||||
@@ -237,6 +256,11 @@ $header-height: 40px;
|
|||||||
.note {
|
.note {
|
||||||
min-width: var(--note-width);
|
min-width: var(--note-width);
|
||||||
max-width: var(--note-width);
|
max-width: var(--note-width);
|
||||||
|
background-color: var(--color-base-100);
|
||||||
|
}
|
||||||
|
|
||||||
|
.readme {
|
||||||
|
box-shadow: var(--note-sheet-shadow);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -245,6 +269,7 @@ $header-height: 40px;
|
|||||||
.flux-note {
|
.flux-note {
|
||||||
.readme {
|
.readme {
|
||||||
padding: 0 0.75rem;
|
padding: 0 0.75rem;
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -254,6 +279,7 @@ $header-height: 40px;
|
|||||||
|
|
||||||
.note {
|
.note {
|
||||||
width: 100vw;
|
width: 100vw;
|
||||||
|
height: 100svh;
|
||||||
overflow-y: visible;
|
overflow-y: visible;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,50 +7,94 @@ import { useUserRepoStore } from "../modules/repo/store/userRepo.store"
|
|||||||
|
|
||||||
const store = useUserRepoStore()
|
const store = useUserRepoStore()
|
||||||
|
|
||||||
const fontFamilies = computed(() => store.userSettings?.fontFamilies ?? [])
|
const DEFAULT_FONT_FAMILIES = [
|
||||||
const sortedFontFamilies = computed(() =>
|
"EB Garamond",
|
||||||
[...fontFamilies.value].sort((a, b) => a.localeCompare(b))
|
"Inter",
|
||||||
|
"Lato",
|
||||||
|
"Libertinus Serif",
|
||||||
|
"Lora",
|
||||||
|
"Merriweather",
|
||||||
|
"Playfair Display",
|
||||||
|
"Roboto",
|
||||||
|
"Source Serif 4"
|
||||||
|
]
|
||||||
|
|
||||||
|
const fontFamilies = computed(
|
||||||
|
() => store.userSettings?.fontFamilies ?? DEFAULT_FONT_FAMILIES
|
||||||
)
|
)
|
||||||
|
const sortedFontFamilies = computed(() => {
|
||||||
|
const base = fontFamilies.value
|
||||||
|
const extras = [
|
||||||
|
store.userSettings?.chosenTitleFont,
|
||||||
|
store.userSettings?.chosenBodyFont
|
||||||
|
].filter((f): f is string => !!f && !base.includes(f))
|
||||||
|
return [...base, ...extras].sort((a, b) => a.localeCompare(b))
|
||||||
|
})
|
||||||
const fontSizes = Array.from({ length: 7 }, (_, i) => `${9 + i * 2}pt`)
|
const fontSizes = Array.from({ length: 7 }, (_, i) => `${9 + i * 2}pt`)
|
||||||
|
|
||||||
|
const titleFont = computed({
|
||||||
|
get: () => store.userSettings?.chosenTitleFont,
|
||||||
|
set: (value) => store.setTitleFont(value!)
|
||||||
|
})
|
||||||
|
const bodyFont = computed({
|
||||||
|
get: () => store.userSettings?.chosenBodyFont,
|
||||||
|
set: (value) => store.setBodyFont(value!)
|
||||||
|
})
|
||||||
|
const fontSize = computed({
|
||||||
|
get: () => store.userSettings?.chosenFontSize,
|
||||||
|
set: (value) => store.setFontSize(value!)
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="font-change" v-if="sortedFontFamilies.length > 0">
|
<div class="font-change">
|
||||||
<theme-swap />
|
<div>
|
||||||
|
<label for="title-font" class="font-label">t</label>
|
||||||
<select
|
<select id="title-font" class="select" v-model="titleFont">
|
||||||
class="select"
|
|
||||||
:value="store.userSettings?.chosenFontFamily"
|
|
||||||
@change="store.setFontFamily(($event.target as HTMLSelectElement).value)"
|
|
||||||
>
|
|
||||||
<option v-for="font in sortedFontFamilies" :key="font" :value="font">
|
<option v-for="font in sortedFontFamilies" :key="font" :value="font">
|
||||||
{{ font }}
|
{{ font }}
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<select
|
<label for="body-font" class="font-label">p</label>
|
||||||
class="select"
|
<select id="body-font" class="select" v-model="bodyFont">
|
||||||
:value="store.userSettings?.chosenFontSize"
|
<option v-for="font in sortedFontFamilies" :key="font" :value="font">
|
||||||
@change="store.setFontSize(($event.target as HTMLSelectElement).value)"
|
{{ font }}
|
||||||
>
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<theme-swap />
|
||||||
|
|
||||||
|
<label for="font-size" class="font-label">s</label>
|
||||||
|
<select id="font-size" class="select" v-model="fontSize">
|
||||||
<option v-for="size in fontSizes" :key="size" :value="size">
|
<option v-for="size in fontSizes" :key="size" :value="size">
|
||||||
{{ size }}
|
{{ size }}
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.font-change {
|
.font-change {
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
align-items: center;
|
|
||||||
gap: 1rem;
|
|
||||||
|
|
||||||
select {
|
select {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
div {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 1rem;
|
||||||
|
margin: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.font-label {
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
opacity: 0.6;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,32 +1,13 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import FontChange from "@/components/FontChange.vue"
|
import FontChange from "@/components/FontChange.vue"
|
||||||
|
import HomeButton from "@/components/HomeButton.vue"
|
||||||
|
|
||||||
defineProps<{ user: string; repo: string }>()
|
defineProps<{ user: string; repo: string }>()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<header class="header-note">
|
<header class="header-note">
|
||||||
<router-link
|
<home-button />
|
||||||
:to="{ name: 'Home' }"
|
|
||||||
class="button is-small is-white back-button"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
class="icon icon-tabler icon-tabler-arrow-narrow-left"
|
|
||||||
width="28"
|
|
||||||
height="28"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke-width="1.5"
|
|
||||||
stroke="currentColor"
|
|
||||||
fill="none"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
>
|
|
||||||
<line x1="5" y1="12" x2="19" y2="12" />
|
|
||||||
<line x1="5" y1="12" x2="9" y2="16" />
|
|
||||||
<line x1="5" y1="12" x2="9" y2="8" />
|
|
||||||
</svg>
|
|
||||||
</router-link>
|
|
||||||
<!-- <router-link
|
<!-- <router-link
|
||||||
:to="{ name: 'SpacedRepetitionCard', params: { user, repo } }"
|
:to="{ name: 'SpacedRepetitionCard', params: { user, repo } }"
|
||||||
>
|
>
|
||||||
@@ -51,12 +32,15 @@ defineProps<{ user: string; repo: string }>()
|
|||||||
</svg>
|
</svg>
|
||||||
</router-link> -->
|
</router-link> -->
|
||||||
|
|
||||||
<button onclick="font_modal.showModal()">
|
<button
|
||||||
|
class="btn btn-ghost btn-circle text-base-content"
|
||||||
|
onclick="font_modal.showModal()"
|
||||||
|
>
|
||||||
<svg
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
class="icon icon-tabler icons-tabler-outline icon-tabler-typography"
|
class="icon icon-tabler icons-tabler-outline icon-tabler-typography"
|
||||||
width="36"
|
width="30"
|
||||||
height="36"
|
height="30"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
stroke-width="1.5"
|
stroke-width="1.5"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
@@ -71,11 +55,14 @@ defineProps<{ user: string; repo: string }>()
|
|||||||
<path d="M5 20l6 -16l2 0l7 16" />
|
<path d="M5 20l6 -16l2 0l7 16" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<router-link :to="{ name: 'FluxNoteView', params: { user, repo } }">
|
<router-link
|
||||||
|
class="btn btn-ghost btn-circle"
|
||||||
|
:to="{ name: 'FluxNoteView', params: { user, repo } }"
|
||||||
|
>
|
||||||
<svg
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
width="36"
|
width="30"
|
||||||
height="36"
|
height="30"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
fill="none"
|
fill="none"
|
||||||
@@ -88,12 +75,15 @@ defineProps<{ user: string; repo: string }>()
|
|||||||
<path d="M9 21v-6a2 2 0 0 1 2 -2h2a2 2 0 0 1 2 2v6" />
|
<path d="M9 21v-6a2 2 0 0 1 2 -2h2a2 2 0 0 1 2 2v6" />
|
||||||
</svg>
|
</svg>
|
||||||
</router-link>
|
</router-link>
|
||||||
<router-link :to="{ name: 'DraftNotes', params: { user, repo } }">
|
<router-link
|
||||||
|
class="btn btn-ghost btn-circle"
|
||||||
|
:to="{ name: 'DraftNotes', params: { user, repo } }"
|
||||||
|
>
|
||||||
<svg
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
class="icon icon-tabler icon-tabler-notes"
|
class="icon icon-tabler icon-tabler-notes"
|
||||||
width="36"
|
width="30"
|
||||||
height="36"
|
height="30"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
stroke-width="1.5"
|
stroke-width="1.5"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
@@ -107,11 +97,14 @@ defineProps<{ user: string; repo: string }>()
|
|||||||
<line x1="9" y1="15" x2="13" y2="15" />
|
<line x1="9" y1="15" x2="13" y2="15" />
|
||||||
</svg>
|
</svg>
|
||||||
</router-link>
|
</router-link>
|
||||||
<router-link :to="{ name: 'TodoNotes', params: { user, repo } }">
|
<router-link
|
||||||
|
class="btn btn-ghost btn-circle"
|
||||||
|
:to="{ name: 'TodoNotes', params: { user, repo } }"
|
||||||
|
>
|
||||||
<svg
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
width="36"
|
width="30"
|
||||||
height="36"
|
height="30"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
@@ -129,12 +122,15 @@ defineProps<{ user: string; repo: string }>()
|
|||||||
<path d="M11 18l9 0" />
|
<path d="M11 18l9 0" />
|
||||||
</svg>
|
</svg>
|
||||||
</router-link>
|
</router-link>
|
||||||
<router-link :to="{ name: 'FleetingNotes', params: { user, repo } }">
|
<router-link
|
||||||
|
class="btn btn-ghost btn-circle"
|
||||||
|
:to="{ name: 'FleetingNotes', params: { user, repo } }"
|
||||||
|
>
|
||||||
<svg
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
class="icon icon-tabler icon-tabler-mailbox"
|
class="icon icon-tabler icon-tabler-mailbox"
|
||||||
width="36"
|
width="30"
|
||||||
height="36"
|
height="30"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
stroke-width="1.5"
|
stroke-width="1.5"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
@@ -150,7 +146,7 @@ defineProps<{ user: string; repo: string }>()
|
|||||||
</svg>
|
</svg>
|
||||||
</router-link>
|
</router-link>
|
||||||
<dialog id="font_modal" class="modal">
|
<dialog id="font_modal" class="modal">
|
||||||
<div class="modal-box">
|
<div class="modal-box w-10/12 max-w-2xl">
|
||||||
<h3 class="text-lg font-bold">Style settings</h3>
|
<h3 class="text-lg font-bold">Style settings</h3>
|
||||||
<font-change />
|
<font-change />
|
||||||
</div>
|
</div>
|
||||||
@@ -168,12 +164,6 @@ defineProps<{ user: string; repo: string }>()
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
margin-top: 10px;
|
margin-top: 10px;
|
||||||
|
|
||||||
img {
|
|
||||||
&:hover {
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
button {
|
button {
|
||||||
color: var(--color-accent);
|
color: var(--color-accent);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,9 +6,12 @@ const goHome = () => router.push({ name: "Home" })
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<a class="btn btn-ghost btn-circle btn-lg" @click="goHome">
|
<button
|
||||||
|
class="btn btn-ghost btn-circle btn-lg text-base-content"
|
||||||
|
@click="goHome"
|
||||||
|
>
|
||||||
<img src="/favicon.png" alt="Remanso icon" class="remanso-logo" />
|
<img src="/favicon.png" alt="Remanso icon" class="remanso-logo" />
|
||||||
</a>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
|||||||
@@ -24,9 +24,9 @@ const emitNote = (sha: string) => {
|
|||||||
<h5 class="subtitle is-5">🔗</h5>
|
<h5 class="subtitle is-5">🔗</h5>
|
||||||
<ul class="links">
|
<ul class="links">
|
||||||
<li v-for="link in backlink?.links" :key="link.sha">
|
<li v-for="link in backlink?.links" :key="link.sha">
|
||||||
<a @click.prevent="emitNote(link.sha)">
|
<button class="link" @click="emitNote(link.sha)">
|
||||||
{{ link.title }}
|
{{ link.title }}
|
||||||
</a>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
90
src/components/NoteConflictModal.vue
Normal file
90
src/components/NoteConflictModal.vue
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import { onMounted, ref, watch } from "vue"
|
||||||
|
|
||||||
|
const props = defineProps<{ open: boolean }>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
discard: []
|
||||||
|
overwrite: []
|
||||||
|
cancel: []
|
||||||
|
"update:open": [value: boolean]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const dialogRef = ref<HTMLDialogElement | null>(null)
|
||||||
|
|
||||||
|
const close = () => {
|
||||||
|
if (dialogRef.value?.open) dialogRef.value.close()
|
||||||
|
emit("update:open", false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onDiscard = () => {
|
||||||
|
emit("discard")
|
||||||
|
close()
|
||||||
|
}
|
||||||
|
const onOverwrite = () => {
|
||||||
|
emit("overwrite")
|
||||||
|
close()
|
||||||
|
}
|
||||||
|
const onCancel = () => {
|
||||||
|
emit("cancel")
|
||||||
|
close()
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.open,
|
||||||
|
(open) => {
|
||||||
|
const el = dialogRef.value
|
||||||
|
if (!el) return
|
||||||
|
if (open && !el.open) el.showModal()
|
||||||
|
else if (!open && el.open) el.close()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (props.open) dialogRef.value?.showModal()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<dialog
|
||||||
|
ref="dialogRef"
|
||||||
|
class="modal"
|
||||||
|
@close="emit('update:open', false)"
|
||||||
|
@cancel.prevent="onCancel()"
|
||||||
|
>
|
||||||
|
<div class="modal-box">
|
||||||
|
<h3 class="text-lg font-bold">GitHub has a newer version of this note</h3>
|
||||||
|
<p class="py-3 text-sm">
|
||||||
|
Someone (or another device) updated this note on GitHub since you
|
||||||
|
started editing. If you save now, their changes will be overwritten.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="modal-action flex-col gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-ghost"
|
||||||
|
@click="onCancel()"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-warning"
|
||||||
|
@click="onOverwrite()"
|
||||||
|
>
|
||||||
|
Save anyway (overwrite)
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-primary"
|
||||||
|
@click="onDiscard()"
|
||||||
|
>
|
||||||
|
Discard my edits, pull latest
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<form method="dialog" class="modal-backdrop">
|
||||||
|
<button type="submit" @click="onCancel()">close</button>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
|
</template>
|
||||||
74
src/components/NoteFreshnessBadge.spec.ts
Normal file
74
src/components/NoteFreshnessBadge.spec.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import { mount } from "@vue/test-utils"
|
||||||
|
import { describe, expect, it } from "vitest"
|
||||||
|
|
||||||
|
import NoteFreshnessBadge from "./NoteFreshnessBadge.vue"
|
||||||
|
|
||||||
|
const factory = (props: { status: string; lastCheckedAt?: Date | null }) =>
|
||||||
|
mount(NoteFreshnessBadge, {
|
||||||
|
props: { lastCheckedAt: null, ...props } as never
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("NoteFreshnessBadge", () => {
|
||||||
|
it("renders the verified state with the cloud-check icon", () => {
|
||||||
|
const wrapper = factory({ status: "verified" })
|
||||||
|
expect(wrapper.classes()).toContain("state-verified")
|
||||||
|
expect(wrapper.find(".icon-tabler-cloud-check").exists()).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("renders the outdated state with the download icon", () => {
|
||||||
|
const wrapper = factory({ status: "outdated" })
|
||||||
|
expect(wrapper.classes()).toContain("state-outdated")
|
||||||
|
expect(wrapper.find(".icon-tabler-cloud-download").exists()).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("renders the offline state with the cloud-off icon", () => {
|
||||||
|
const wrapper = factory({ status: "offline" })
|
||||||
|
expect(wrapper.classes()).toContain("state-offline")
|
||||||
|
expect(wrapper.find(".icon-tabler-cloud-off").exists()).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("renders the unauthorized state with the lock icon", () => {
|
||||||
|
const wrapper = factory({ status: "unauthorized" })
|
||||||
|
expect(wrapper.classes()).toContain("state-unauthorized")
|
||||||
|
expect(wrapper.find(".icon-tabler-cloud-lock").exists()).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("disables the button while checking", () => {
|
||||||
|
const wrapper = factory({ status: "checking" })
|
||||||
|
expect(wrapper.attributes("disabled")).toBeDefined()
|
||||||
|
expect(wrapper.find(".icon-tabler-loader-2").exists()).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("emits click when not busy", async () => {
|
||||||
|
const wrapper = factory({ status: "verified" })
|
||||||
|
|
||||||
|
await wrapper.trigger("click")
|
||||||
|
|
||||||
|
expect(wrapper.emitted("click")).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("includes the last-checked time in the verified tooltip", () => {
|
||||||
|
const wrapper = factory({
|
||||||
|
status: "verified",
|
||||||
|
lastCheckedAt: new Date("2026-01-01T10:30:00")
|
||||||
|
})
|
||||||
|
const tooltip = wrapper.attributes("title") as string
|
||||||
|
expect(tooltip).toMatch(/Verified at/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("renders the unknown state as default", () => {
|
||||||
|
const wrapper = factory({ status: "unknown" })
|
||||||
|
expect(wrapper.classes()).toContain("state-unknown")
|
||||||
|
expect(wrapper.find(".icon-tabler-cloud-question").exists()).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("offline tooltip prompts a retry", () => {
|
||||||
|
const wrapper = factory({ status: "offline" })
|
||||||
|
expect(wrapper.attributes("title")).toMatch(/retry/i)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("unauthorized tooltip prompts a sign-in", () => {
|
||||||
|
const wrapper = factory({ status: "unauthorized" })
|
||||||
|
expect(wrapper.attributes("title")).toMatch(/[Ss]ign in/)
|
||||||
|
})
|
||||||
|
})
|
||||||
242
src/components/NoteFreshnessBadge.vue
Normal file
242
src/components/NoteFreshnessBadge.vue
Normal file
@@ -0,0 +1,242 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import { computed } from "vue"
|
||||||
|
|
||||||
|
import type { FreshnessStatus } from "@/hooks/useNoteFreshness.hook"
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
status: FreshnessStatus
|
||||||
|
lastCheckedAt: Date | null
|
||||||
|
}>()
|
||||||
|
|
||||||
|
defineEmits<{ (e: "click"): void }>()
|
||||||
|
|
||||||
|
const formatTime = (d: Date) =>
|
||||||
|
d.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" })
|
||||||
|
|
||||||
|
const label = computed(() => {
|
||||||
|
switch (props.status) {
|
||||||
|
case "verified":
|
||||||
|
return "Up to date"
|
||||||
|
case "checking":
|
||||||
|
return "Checking…"
|
||||||
|
case "outdated":
|
||||||
|
return "Outdated"
|
||||||
|
case "offline":
|
||||||
|
return "Can’t reach GitHub"
|
||||||
|
case "unauthorized":
|
||||||
|
return "Sign in to GitHub"
|
||||||
|
case "unknown":
|
||||||
|
default:
|
||||||
|
return "Not checked"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const tooltip = computed(() => {
|
||||||
|
switch (props.status) {
|
||||||
|
case "verified":
|
||||||
|
return props.lastCheckedAt
|
||||||
|
? `Verified at ${formatTime(props.lastCheckedAt)}. Click to re-check.`
|
||||||
|
: "Click to re-check."
|
||||||
|
case "outdated":
|
||||||
|
return "GitHub has a newer version. Click to pull latest."
|
||||||
|
case "offline":
|
||||||
|
return "Could not reach GitHub. Click to retry."
|
||||||
|
case "unauthorized":
|
||||||
|
return "GitHub auth expired. Sign in again, then click to retry."
|
||||||
|
case "checking":
|
||||||
|
return "Checking against GitHub…"
|
||||||
|
case "unknown":
|
||||||
|
default:
|
||||||
|
return "Click to check against GitHub."
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const stateClass = computed(() => `state-${props.status}`)
|
||||||
|
const isBusy = computed(() => props.status === "checking")
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<button
|
||||||
|
class="freshness button is-text is-light"
|
||||||
|
:class="stateClass"
|
||||||
|
:title="tooltip"
|
||||||
|
:aria-label="tooltip"
|
||||||
|
:disabled="isBusy"
|
||||||
|
@click="$emit('click')"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
v-if="status === 'verified'"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="icon icon-tabler icon-tabler-cloud-check"
|
||||||
|
width="20"
|
||||||
|
height="20"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.75"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M11 18.004h-4.343c-2.572 -.004 -4.657 -2.011 -4.657 -4.487c0 -2.475 2.085 -4.482 4.657 -4.482c.393 -1.762 1.794 -3.2 3.675 -3.773c1.88 -.572 3.956 -.193 5.444 1c1.488 1.19 2.162 3.007 1.77 4.769h.99c1.388 0 2.585 .82 3.138 2.007"
|
||||||
|
/>
|
||||||
|
<path d="M15 19l2 2l4 -4" />
|
||||||
|
</svg>
|
||||||
|
<svg
|
||||||
|
v-else-if="status === 'unknown'"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="icon icon-tabler icon-tabler-cloud-question"
|
||||||
|
width="20"
|
||||||
|
height="20"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.75"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M14.5 18.004h-7.843c-2.572 -.004 -4.657 -2.011 -4.657 -4.487c0 -2.475 2.085 -4.482 4.657 -4.482c.393 -1.762 1.794 -3.2 3.675 -3.773c1.88 -.572 3.956 -.193 5.444 1c1.488 1.19 2.162 3.007 1.77 4.769h.99"
|
||||||
|
/>
|
||||||
|
<path d="M19 22v.01" />
|
||||||
|
<path
|
||||||
|
d="M19 19a2.003 2.003 0 0 0 .914 -3.782a1.98 1.98 0 0 0 -2.414 .483"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<svg
|
||||||
|
v-else-if="status === 'outdated'"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="icon icon-tabler icon-tabler-cloud-download"
|
||||||
|
width="20"
|
||||||
|
height="20"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.75"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M19 18a3.5 3.5 0 0 0 0 -7h-1a5 4.5 0 0 0 -11 -2a4.6 4.4 0 0 0 -2.1 8.4"
|
||||||
|
/>
|
||||||
|
<path d="M12 13l0 9" />
|
||||||
|
<path d="M9 19l3 3l3 -3" />
|
||||||
|
</svg>
|
||||||
|
<svg
|
||||||
|
v-else-if="status === 'checking'"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="icon icon-tabler icon-tabler-loader-2 spin"
|
||||||
|
width="20"
|
||||||
|
height="20"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.75"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M12 3a9 9 0 1 0 9 9" />
|
||||||
|
</svg>
|
||||||
|
<svg
|
||||||
|
v-else-if="status === 'unauthorized'"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="icon icon-tabler icon-tabler-cloud-lock"
|
||||||
|
width="20"
|
||||||
|
height="20"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.75"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M11 18.004h-4.343c-2.572 -.004 -4.657 -2.011 -4.657 -4.487c0 -2.475 2.085 -4.482 4.657 -4.482c.393 -1.762 1.794 -3.2 3.675 -3.773c1.88 -.572 3.956 -.193 5.444 1c1.488 1.19 2.162 3.007 1.77 4.769h.99c1.18 0 2.227 .593 2.85 1.5"
|
||||||
|
/>
|
||||||
|
<path d="M15 16m0 1a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v3a1 1 0 0 1 -1 1h-4a1 1 0 0 1 -1 -1z" />
|
||||||
|
<path d="M18 14a2 2 0 0 1 2 2v0h-4v0a2 2 0 0 1 2 -2z" />
|
||||||
|
</svg>
|
||||||
|
<svg
|
||||||
|
v-else
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="icon icon-tabler icon-tabler-cloud-off"
|
||||||
|
width="20"
|
||||||
|
height="20"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.75"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M9.58 5.548c.24 -.11 .492 -.207 .752 -.286c1.88 -.572 3.956 -.193 5.444 1c1.488 1.19 2.162 3.007 1.77 4.769h.99c1.913 0 3.464 1.56 3.464 3.486c0 .957 -.383 1.824 -1.003 2.454m-2.997 1.033h-11.343c-2.572 -.004 -4.657 -2.011 -4.657 -4.487c0 -2.475 2.085 -4.482 4.657 -4.482c.13 -.582 .37 -1.128 .7 -1.62"
|
||||||
|
/>
|
||||||
|
<path d="M3 3l18 18" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.freshness {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
padding: 0.15rem 0.5rem;
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
vertical-align: middle;
|
||||||
|
|
||||||
|
&[disabled] {
|
||||||
|
cursor: progress;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.freshness-label {
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.state-verified {
|
||||||
|
color: var(--color-success, hsl(140, 60%, 35%));
|
||||||
|
}
|
||||||
|
|
||||||
|
.state-outdated {
|
||||||
|
color: var(--color-warning, hsl(35, 90%, 45%));
|
||||||
|
}
|
||||||
|
|
||||||
|
.state-offline {
|
||||||
|
color: var(--color-error, hsl(0, 70%, 45%));
|
||||||
|
}
|
||||||
|
|
||||||
|
.state-unauthorized {
|
||||||
|
color: var(--color-warning, hsl(35, 90%, 45%));
|
||||||
|
}
|
||||||
|
|
||||||
|
.state-unknown,
|
||||||
|
.state-checking {
|
||||||
|
color: var(--color-base-content);
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spin {
|
||||||
|
animation: freshness-spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes freshness-spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (max-width: 768px) {
|
||||||
|
.freshness-label {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
153
src/components/ProfileModal.vue
Normal file
153
src/components/ProfileModal.vue
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from "vue"
|
||||||
|
|
||||||
|
import SignInAtproto from "@/components/SignInAtproto.vue"
|
||||||
|
import SignInGithub from "@/components/SignInGithub.vue"
|
||||||
|
import { useGitHubLogin } from "@/hooks/useGitHubLogin.hook"
|
||||||
|
|
||||||
|
const { accessToken } = useGitHubLogin()
|
||||||
|
const isGitHubLoggedIn = computed(() => !!accessToken.value)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<dialog id="profile_modal" class="modal profile-modal">
|
||||||
|
<div class="modal-box profile-modal-box">
|
||||||
|
<div class="profile-modal-head">
|
||||||
|
<h3>Profile</h3>
|
||||||
|
<form method="dialog">
|
||||||
|
<button class="profile-modal-x" aria-label="close">×</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="profile-modal-section">
|
||||||
|
<div class="profile-modal-label">Bluesky / ATProto</div>
|
||||||
|
<sign-in-atproto :with-sign-out="true" />
|
||||||
|
</div>
|
||||||
|
<hr class="profile-modal-rule" />
|
||||||
|
<div class="profile-modal-section">
|
||||||
|
<div class="profile-modal-label">GitHub</div>
|
||||||
|
<sign-in-github />
|
||||||
|
<router-link
|
||||||
|
v-if="isGitHubLoggedIn"
|
||||||
|
:to="{ name: 'RepoList' }"
|
||||||
|
class="profile-modal-link"
|
||||||
|
>Manage your repos</router-link
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<form method="dialog" class="modal-backdrop">
|
||||||
|
<button></button>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.profile-modal {
|
||||||
|
--pm-rule: color-mix(
|
||||||
|
in oklch,
|
||||||
|
var(--color-base-content) 12%,
|
||||||
|
var(--color-base-200)
|
||||||
|
);
|
||||||
|
--pm-ink: var(--color-base-content);
|
||||||
|
--pm-ink-soft: color-mix(
|
||||||
|
in oklch,
|
||||||
|
var(--color-base-content) 65%,
|
||||||
|
var(--color-base-200)
|
||||||
|
);
|
||||||
|
--pm-ink-faint: color-mix(
|
||||||
|
in oklch,
|
||||||
|
var(--color-base-content) 38%,
|
||||||
|
var(--color-base-200)
|
||||||
|
);
|
||||||
|
--pm-accent: #e36598;
|
||||||
|
--pm-accent-wash: color-mix(in oklch, #e36598 12%, var(--color-base-200));
|
||||||
|
--pm-accent-deep: color-mix(
|
||||||
|
in oklch,
|
||||||
|
#e36598 75%,
|
||||||
|
var(--color-base-content)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-modal-box {
|
||||||
|
background: var(--color-base-200);
|
||||||
|
border: 1px solid var(--pm-rule);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 1.5rem;
|
||||||
|
box-shadow: 0 30px 60px -20px rgba(0, 0, 0, 0.3);
|
||||||
|
color: var(--pm-ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-modal-head {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
font-size: 1.3rem;
|
||||||
|
margin: 0;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-modal-x {
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--pm-ink-soft);
|
||||||
|
line-height: 1;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--pm-accent-deep);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-modal-section {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.75rem;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-modal-label {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
letter-spacing: 0.14em;
|
||||||
|
color: var(--pm-ink-faint);
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-modal-rule {
|
||||||
|
border: 0;
|
||||||
|
border-top: 1px solid var(--pm-rule);
|
||||||
|
margin: 1.25rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-modal-link {
|
||||||
|
font-size: 16px;
|
||||||
|
padding: 0.55rem 1rem;
|
||||||
|
border-radius: 2px;
|
||||||
|
border: 1px solid var(--pm-rule);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--pm-ink-soft);
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
background 0.15s,
|
||||||
|
color 0.15s,
|
||||||
|
border-color 0.15s;
|
||||||
|
text-decoration: none;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--pm-accent-wash);
|
||||||
|
border-color: var(--pm-accent);
|
||||||
|
color: var(--pm-accent-deep);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -40,6 +40,7 @@ const getStyle = (seed: string) => {
|
|||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
.repo-list {
|
.repo-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
justify-content: space-evenly;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
|
||||||
|
|||||||
@@ -1,20 +1,34 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
|
import { useRoute } from "vue-router"
|
||||||
|
|
||||||
|
import { GITHUB_OAUTH_RETURN_PATH_KEY } from "@/modules/user/service/oauthReturnPath"
|
||||||
|
|
||||||
const GITHUB_URL = "https://github.com/login/oauth/authorize"
|
const GITHUB_URL = "https://github.com/login/oauth/authorize"
|
||||||
|
|
||||||
const CLIENT_ID = "Iv1.12dc43d013ce3623"
|
const CLIENT_ID = "Iv1.12dc43d013ce3623"
|
||||||
const SCOPE = "repo%20workflow"
|
const SCOPE = "repo%20workflow"
|
||||||
const REDIRECT_URI = window.location.origin
|
const REDIRECT_URI = window.location.origin
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
|
||||||
const url = new URL(GITHUB_URL)
|
const url = new URL(GITHUB_URL)
|
||||||
url.searchParams.set("client_id", CLIENT_ID)
|
url.searchParams.set("client_id", CLIENT_ID)
|
||||||
url.searchParams.set("scope", SCOPE)
|
url.searchParams.set("scope", SCOPE)
|
||||||
url.searchParams.set("redirect_uri", REDIRECT_URI)
|
url.searchParams.set("redirect_uri", REDIRECT_URI)
|
||||||
|
|
||||||
const href = url.toString()
|
const href = url.toString()
|
||||||
|
|
||||||
|
const saveReturnPath = () => {
|
||||||
|
sessionStorage.setItem(GITHUB_OAUTH_RETURN_PATH_KEY, route.fullPath)
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<a :href="href" class="sign-in-github btn btn-sm btn-primary">
|
<a
|
||||||
|
:href="href"
|
||||||
|
class="sign-in-github btn btn-sm btn-primary"
|
||||||
|
@click="saveReturnPath"
|
||||||
|
>
|
||||||
Sign in with
|
Sign in with
|
||||||
<svg
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
import {
|
import {
|
||||||
computed,
|
computed,
|
||||||
defineAsyncComponent,
|
defineAsyncComponent,
|
||||||
nextTick,
|
|
||||||
onMounted,
|
onMounted,
|
||||||
ref,
|
ref,
|
||||||
watch
|
watch
|
||||||
@@ -11,20 +10,32 @@ import {
|
|||||||
import { useEditionMode } from "@/hooks/useEditionMode"
|
import { useEditionMode } from "@/hooks/useEditionMode"
|
||||||
import { useFile } from "@/hooks/useFile.hook"
|
import { useFile } from "@/hooks/useFile.hook"
|
||||||
import { useGitHubContent } from "@/hooks/useGitHubContent.hook"
|
import { useGitHubContent } from "@/hooks/useGitHubContent.hook"
|
||||||
import { useImages } from "@/hooks/useImages.hook"
|
import { useImageUpload } from "@/hooks/useImageUpload.hook"
|
||||||
import { useLinks } from "@/hooks/useLinks.hook"
|
import { useLinks } from "@/hooks/useLinks.hook"
|
||||||
import { runMermaid, useShikiji } from "@/hooks/useMarkdown.hook"
|
import { renderCodeFile } from "@/hooks/useMarkdown.hook"
|
||||||
|
import { useMarkdownPostRender } from "@/hooks/useMarkdownPostRender.hook"
|
||||||
|
import { useNoteFreshness } from "@/hooks/useNoteFreshness.hook"
|
||||||
import { useNoteOverlay } from "@/hooks/useNoteOverlay.hook"
|
import { useNoteOverlay } from "@/hooks/useNoteOverlay.hook"
|
||||||
import { useRouteQueryStackedNotes } from "@/hooks/useRouteQueryStackedNotes.hook"
|
import { useRouteQueryStackedNotes } from "@/hooks/useRouteQueryStackedNotes.hook"
|
||||||
import { useTitleNotes } from "@/hooks/useTitleNotes.hook"
|
import { useTitleNotes } from "@/hooks/useTitleNotes.hook"
|
||||||
import { useUserRepoStore } from "@/modules/repo/store/userRepo.store"
|
import { useUserRepoStore } from "@/modules/repo/store/userRepo.store"
|
||||||
import { encodeUTF8ToBase64 } from "@/utils/decodeBase64ToUTF8"
|
import { encodeUTF8ToBase64 } from "@/utils/decodeBase64ToUTF8"
|
||||||
|
import { getFileLanguage, isMarkdownPath } from "@/utils/fileLanguage"
|
||||||
import { filenameToNoteTitle } from "@/utils/noteTitle"
|
import { filenameToNoteTitle } from "@/utils/noteTitle"
|
||||||
|
import { errorMessage } from "@/utils/notif"
|
||||||
|
|
||||||
const LinkedNotes = defineAsyncComponent(
|
const LinkedNotes = defineAsyncComponent(
|
||||||
() => import("@/components/LinkedNotes.vue")
|
() => import("@/components/LinkedNotes.vue")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const NoteFreshnessBadge = defineAsyncComponent(
|
||||||
|
() => import("@/components/NoteFreshnessBadge.vue")
|
||||||
|
)
|
||||||
|
|
||||||
|
const NoteConflictModal = defineAsyncComponent(
|
||||||
|
() => import("@/components/NoteConflictModal.vue")
|
||||||
|
)
|
||||||
|
|
||||||
const EditNote = defineAsyncComponent(
|
const EditNote = defineAsyncComponent(
|
||||||
() => import("@/modules/note/components/EditNote.vue")
|
() => import("@/modules/note/components/EditNote.vue")
|
||||||
)
|
)
|
||||||
@@ -53,6 +64,35 @@ const {
|
|||||||
getEditedSha
|
getEditedSha
|
||||||
} = useFile(sha)
|
} = useFile(sha)
|
||||||
const initialRawContent = ref<string | null>(null)
|
const initialRawContent = ref<string | null>(null)
|
||||||
|
const isMarkdown = computed(() =>
|
||||||
|
path.value ? isMarkdownPath(path.value) : true
|
||||||
|
)
|
||||||
|
const displayedContent = ref("")
|
||||||
|
|
||||||
|
watch(
|
||||||
|
[rawContent, isMarkdown, path],
|
||||||
|
async ([raw, isMd, p]) => {
|
||||||
|
if (!raw) {
|
||||||
|
displayedContent.value = ""
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (isMd) {
|
||||||
|
displayedContent.value = content.value
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const lang = p ? getFileLanguage(p) : null
|
||||||
|
const filename = p?.split("/").pop()
|
||||||
|
const result = await renderCodeFile({ rawContent: raw, lang, filename })
|
||||||
|
if (rawContent.value === raw) {
|
||||||
|
displayedContent.value = result
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(content, (c) => {
|
||||||
|
if (isMarkdown.value) displayedContent.value = c
|
||||||
|
})
|
||||||
const className = computed(() => `stacked-note-${props.index}`)
|
const className = computed(() => `stacked-note-${props.index}`)
|
||||||
const { listenToClick } = useLinks(className.value, sha)
|
const { listenToClick } = useLinks(className.value, sha)
|
||||||
const titleClassName = computed(() => `title-${className.value}`)
|
const titleClassName = computed(() => `title-${className.value}`)
|
||||||
@@ -60,6 +100,7 @@ useTitleNotes(repo)
|
|||||||
|
|
||||||
const store = useUserRepoStore()
|
const store = useUserRepoStore()
|
||||||
const hasBacklinks = computed(() => store.userSettings?.backlink)
|
const hasBacklinks = computed(() => store.userSettings?.backlink)
|
||||||
|
const canPush = computed(() => store.canPush)
|
||||||
|
|
||||||
const { displayNoteOverlay } = useNoteOverlay(className.value, index)
|
const { displayNoteOverlay } = useNoteOverlay(className.value, index)
|
||||||
const displayedTitle = computed(() => filenameToNoteTitle(props.title ?? ""))
|
const displayedTitle = computed(() => filenameToNoteTitle(props.title ?? ""))
|
||||||
@@ -70,35 +111,111 @@ const { updateFile } = useGitHubContent({
|
|||||||
repo: repo.value
|
repo: repo.value
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const { uploadImage } = useImageUpload({
|
||||||
|
user: user.value,
|
||||||
|
repo: repo.value,
|
||||||
|
notePath: path
|
||||||
|
})
|
||||||
|
|
||||||
|
const fileInput = ref<HTMLInputElement | null>(null)
|
||||||
|
const editKey = ref(0)
|
||||||
|
const isUploading = ref(false)
|
||||||
|
|
||||||
|
const onImagePicked = async (e: Event) => {
|
||||||
|
const input = e.target as HTMLInputElement
|
||||||
|
const file = input.files?.[0]
|
||||||
|
input.value = ""
|
||||||
|
if (!file || !path.value) return
|
||||||
|
isUploading.value = true
|
||||||
|
try {
|
||||||
|
const result = await uploadImage(file)
|
||||||
|
if (!result) return
|
||||||
|
const trimmed = rawContent.value.replace(/\n+$/, "")
|
||||||
|
const prefix = trimmed ? `${trimmed}\n\n` : ""
|
||||||
|
rawContent.value = `${prefix}\n`
|
||||||
|
editKey.value++
|
||||||
|
} finally {
|
||||||
|
isUploading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
status: freshnessStatus,
|
||||||
|
lastCheckedAt,
|
||||||
|
latestSha,
|
||||||
|
check: checkFreshness,
|
||||||
|
pullLatest
|
||||||
|
} = useNoteFreshness({
|
||||||
|
user: user.value,
|
||||||
|
repo: repo.value,
|
||||||
|
sha,
|
||||||
|
path,
|
||||||
|
getEditedSha
|
||||||
|
})
|
||||||
|
|
||||||
|
const conflictOpen = ref(false)
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
initialRawContent.value = await getRawContent()
|
initialRawContent.value = await getRawContent()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
path,
|
||||||
|
(p) => {
|
||||||
|
if (p) void checkFreshness()
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
)
|
||||||
|
|
||||||
const { mode, toggleMode } = useEditionMode()
|
const { mode, toggleMode } = useEditionMode()
|
||||||
|
|
||||||
watch([content, mode], () => {
|
useMarkdownPostRender(content, () => `.note-${sha.value}`, {
|
||||||
if (!content.value) {
|
onReady: () => listenToClick(),
|
||||||
|
tikz: true,
|
||||||
|
mermaid: () => rawContent.value.includes("```mermaid"),
|
||||||
|
shikiji: () => isMarkdown.value && rawContent.value.includes("```"),
|
||||||
|
images: () =>
|
||||||
|
/\!\[.*?\]\(.*?\)/.test(rawContent.value) ? props.sha : null,
|
||||||
|
triggers: [mode]
|
||||||
|
})
|
||||||
|
|
||||||
|
const performSave = async (overrideSha?: string) => {
|
||||||
|
if (!path.value) {
|
||||||
|
console.warn("no path found for this file")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
nextTick(() => {
|
const editedSha = overrideSha ?? (await getEditedSha()) ?? sha.value
|
||||||
listenToClick()
|
const { sha: newSha, conflict } = await updateFile({
|
||||||
|
content: rawContent.value,
|
||||||
if (/\!\[.*?\]\(.*?\)/.test(rawContent.value)) {
|
path: path.value,
|
||||||
useImages(props.sha)
|
sha: editedSha
|
||||||
}
|
|
||||||
|
|
||||||
if (rawContent.value.includes("```mermaid")) {
|
|
||||||
runMermaid(`.note-${sha.value} .mermaid`)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (rawContent.value.includes("```")) {
|
|
||||||
useShikiji()
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
})
|
|
||||||
|
if (conflict) {
|
||||||
|
await checkFreshness()
|
||||||
|
conflictOpen.value = true
|
||||||
|
if (mode.value === "read") toggleMode()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!newSha) {
|
||||||
|
console.warn("no new SHA found for this file")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await saveCacheNote(encodeUTF8ToBase64(rawContent.value), {
|
||||||
|
editedSha: newSha
|
||||||
|
})
|
||||||
|
initialRawContent.value = rawContent.value
|
||||||
|
}
|
||||||
|
|
||||||
watch(mode, async (newMode) => {
|
watch(mode, async (newMode) => {
|
||||||
|
if (newMode === "edit") {
|
||||||
|
void checkFreshness()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const hasUserFinishedToEdit =
|
const hasUserFinishedToEdit =
|
||||||
newMode === "read" && rawContent.value !== initialRawContent.value
|
newMode === "read" && rawContent.value !== initialRawContent.value
|
||||||
|
|
||||||
@@ -107,28 +224,66 @@ watch(mode, async (newMode) => {
|
|||||||
}
|
}
|
||||||
if (!path.value) {
|
if (!path.value) {
|
||||||
console.warn("no path found for this file")
|
console.warn("no path found for this file")
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const editedSha = (await getEditedSha()) ?? sha.value
|
await checkFreshness()
|
||||||
const newSha = await updateFile({
|
if (freshnessStatus.value === "outdated") {
|
||||||
content: rawContent.value,
|
conflictOpen.value = true
|
||||||
path: path.value,
|
|
||||||
sha: editedSha
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!newSha) {
|
|
||||||
console.warn("no new SHA found for this file")
|
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
await saveCacheNote(encodeUTF8ToBase64(rawContent.value), {
|
await performSave()
|
||||||
editedSha: newSha
|
|
||||||
})
|
|
||||||
initialRawContent.value = rawContent.value
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const onConflictDiscard = async () => {
|
||||||
|
const { raw } = await pullLatest()
|
||||||
|
if (raw !== null) {
|
||||||
|
rawContent.value = raw
|
||||||
|
initialRawContent.value = raw
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onConflictOverwrite = async () => {
|
||||||
|
if (latestSha.value) {
|
||||||
|
await performSave(latestSha.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onConflictCancel = () => {
|
||||||
|
if (mode.value === "read") toggleMode()
|
||||||
|
}
|
||||||
|
|
||||||
|
const onBadgeClick = async () => {
|
||||||
|
try {
|
||||||
|
if (freshnessStatus.value !== "outdated") {
|
||||||
|
await checkFreshness()
|
||||||
|
if (freshnessStatus.value === "unauthorized") {
|
||||||
|
errorMessage("🔐 GitHub auth expired — please sign in again")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasUnsavedEdits = rawContent.value !== initialRawContent.value
|
||||||
|
if (hasUnsavedEdits) {
|
||||||
|
conflictOpen.value = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const { raw, failureStatus } = await pullLatest()
|
||||||
|
if (raw !== null) {
|
||||||
|
rawContent.value = raw
|
||||||
|
initialRawContent.value = raw
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (failureStatus === "unauthorized") {
|
||||||
|
errorMessage("🔐 GitHub auth expired — please sign in again")
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("freshness badge click failed", error)
|
||||||
|
errorMessage("❌ Couldn't pull latest from GitHub")
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -140,23 +295,16 @@ watch(mode, async (newMode) => {
|
|||||||
[`note-${sha}`]: true
|
[`note-${sha}`]: true
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<a
|
<div class="title-stacked-note breadcrumbs text-sm" :class="titleClassName">
|
||||||
class="title-stacked-note-link"
|
<div class="action-bar">
|
||||||
@click.prevent="scrollToFocusedNote(props.sha)"
|
<note-freshness-badge
|
||||||
>
|
:status="freshnessStatus"
|
||||||
<div
|
:last-checked-at="lastCheckedAt"
|
||||||
class="title-stacked-note breadcrumbs text-sm"
|
@click="onBadgeClick"
|
||||||
:class="titleClassName"
|
class="action"
|
||||||
>
|
/>
|
||||||
<ul>
|
|
||||||
<li v-for="(part, i) in breadcrumbs" :key="i">
|
|
||||||
{{ part }}
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
<section class="text-content">
|
|
||||||
<button
|
<button
|
||||||
|
v-if="isMarkdown && canPush"
|
||||||
class="action button is-text is-light"
|
class="action button is-text is-light"
|
||||||
:class="{ 'is-link': mode === 'edit' }"
|
:class="{ 'is-link': mode === 'edit' }"
|
||||||
:style="mode === 'edit' ? 'color: var(--color-primary)' : ''"
|
:style="mode === 'edit' ? 'color: var(--color-primary)' : ''"
|
||||||
@@ -205,12 +353,77 @@ watch(mode, async (newMode) => {
|
|||||||
<path d="M14 4l0 4l-6 0l0 -4" />
|
<path d="M14 4l0 4l-6 0l0 -4" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<div v-if="mode === 'edit'" class="edit">
|
<button
|
||||||
<edit-note v-model="rawContent" />
|
v-if="isMarkdown && mode === 'edit' && canPush"
|
||||||
|
class="action button is-text is-light"
|
||||||
|
:title="isUploading ? 'Uploading…' : 'Upload image'"
|
||||||
|
:disabled="isUploading"
|
||||||
|
@click="fileInput?.click()"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
v-if="isUploading"
|
||||||
|
class="loading loading-spinner loading-sm"
|
||||||
|
></span>
|
||||||
|
<svg
|
||||||
|
v-else
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="icon icon-tabler icon-tabler-photo-plus"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke-width="1.5"
|
||||||
|
stroke="currentColor"
|
||||||
|
fill="none"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||||
|
<path d="M15 8h.01" />
|
||||||
|
<path
|
||||||
|
d="M12.5 21h-6.5a3 3 0 0 1 -3 -3v-12a3 3 0 0 1 3 -3h12a3 3 0 0 1 3 3v6.5"
|
||||||
|
/>
|
||||||
|
<path d="M3 16l5 -5c.928 -.893 2.072 -.893 3 0l4 4" />
|
||||||
|
<path d="M14 14l1 -1c.928 -.893 2.072 -.893 3 0l2 2" />
|
||||||
|
<path d="M16 19h6" />
|
||||||
|
<path d="M19 16v6" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
ref="fileInput"
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
class="hidden-input"
|
||||||
|
@change="onImagePicked"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="mode === 'read'" class="note-content" v-html="content"></div>
|
<a
|
||||||
|
class="title-stacked-note-link"
|
||||||
|
@click.prevent="scrollToFocusedNote({ noteId: props.sha })"
|
||||||
|
>
|
||||||
|
<ul>
|
||||||
|
<li v-for="(part, i) in breadcrumbs" :key="i">
|
||||||
|
{{ part }}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<section class="text-content">
|
||||||
|
<div v-if="mode === 'edit' && isMarkdown" class="edit">
|
||||||
|
<edit-note :key="editKey" v-model="rawContent" />
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="mode === 'read'"
|
||||||
|
class="note-content"
|
||||||
|
v-html="displayedContent"
|
||||||
|
></div>
|
||||||
</section>
|
</section>
|
||||||
<linked-notes v-if="hasBacklinks && content" :sha="sha" />
|
<linked-notes v-if="hasBacklinks && content" :sha="sha" />
|
||||||
|
<note-conflict-modal
|
||||||
|
v-model:open="conflictOpen"
|
||||||
|
@discard="onConflictDiscard"
|
||||||
|
@overwrite="onConflictOverwrite"
|
||||||
|
@cancel="onConflictCancel"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -228,7 +441,7 @@ $border-color: rgba(18, 19, 58, 0.2);
|
|||||||
}
|
}
|
||||||
|
|
||||||
section {
|
section {
|
||||||
padding: 0 0.5rem 2rem;
|
padding: 0 0.5rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -242,7 +455,6 @@ $border-color: rgba(18, 19, 58, 0.2);
|
|||||||
background-color: var(--color-base-100);
|
background-color: var(--color-base-100);
|
||||||
color: var(--color-base-content);
|
color: var(--color-base-content);
|
||||||
font-size: 0.8em;
|
font-size: 0.8em;
|
||||||
overflow: hidden;
|
|
||||||
|
|
||||||
ul,
|
ul,
|
||||||
li {
|
li {
|
||||||
@@ -257,14 +469,29 @@ $border-color: rgba(18, 19, 58, 0.2);
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
scrollbar-width: none;
|
scrollbar-width: none;
|
||||||
|
|
||||||
div {
|
> .edit,
|
||||||
|
> .note-content {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.action-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hidden-input {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
.action {
|
.action {
|
||||||
float: right;
|
margin: 0;
|
||||||
margin: 0.2rem;
|
|
||||||
|
&:hover {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
img {
|
img {
|
||||||
vertical-align: bottom;
|
vertical-align: bottom;
|
||||||
@@ -274,9 +501,10 @@ $border-color: rgba(18, 19, 58, 0.2);
|
|||||||
@media screen and (max-width: 768px) {
|
@media screen and (max-width: 768px) {
|
||||||
.stacked-note {
|
.stacked-note {
|
||||||
padding: 0 0.75rem 1rem;
|
padding: 0 0.75rem 1rem;
|
||||||
|
height: 100svh;
|
||||||
|
|
||||||
section {
|
section {
|
||||||
padding: 1rem 0 2rem;
|
padding: 1rem 0;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -291,10 +519,12 @@ $border-color: rgba(18, 19, 58, 0.2);
|
|||||||
.stacked-note {
|
.stacked-note {
|
||||||
border-top: 0;
|
border-top: 0;
|
||||||
border-left: 1px solid $border-color;
|
border-left: 1px solid $border-color;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.title-stacked-note {
|
.title-stacked-note {
|
||||||
padding: 0 1rem;
|
padding: 0;
|
||||||
transform-origin: 0 0;
|
transform-origin: 0 0;
|
||||||
transform: rotate(90deg);
|
transform: rotate(90deg);
|
||||||
}
|
}
|
||||||
@@ -302,6 +532,12 @@ $border-color: rgba(18, 19, 58, 0.2);
|
|||||||
a {
|
a {
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.action-bar {
|
||||||
|
.action {
|
||||||
|
transform: rotate(-90deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media print {
|
@media print {
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { computedAsync } from "@vueuse/core"
|
import { computedAsync } from "@vueuse/core"
|
||||||
import { computed, nextTick, ref, watch } from "vue"
|
import { computed, ref, watch } from "vue"
|
||||||
import { useRoute } from "vue-router"
|
import { useRoute } from "vue-router"
|
||||||
|
|
||||||
import SkeletonLoader from "@/components/SkeletonLoader.vue"
|
import SkeletonLoader from "@/components/SkeletonLoader.vue"
|
||||||
import { useATProtoLinks } from "@/hooks/useATProtoLinks.hook"
|
import { useATProtoLinks } from "@/hooks/useATProtoLinks.hook"
|
||||||
import { markdownBuilder } from "@/hooks/useMarkdown.hook"
|
import { markdownBuilder } from "@/hooks/useMarkdown.hook"
|
||||||
|
import { useMarkdownPostRender } from "@/hooks/useMarkdownPostRender.hook"
|
||||||
import { useNoteOverlay } from "@/hooks/useNoteOverlay.hook"
|
import { useNoteOverlay } from "@/hooks/useNoteOverlay.hook"
|
||||||
import { useRouteQueryStackedNotes } from "@/hooks/useRouteQueryStackedNotes.hook"
|
import { useRouteQueryStackedNotes } from "@/hooks/useRouteQueryStackedNotes.hook"
|
||||||
import { getAuthor } from "@/modules/atproto/getAuthor"
|
import { getAuthor } from "@/modules/atproto/getAuthor"
|
||||||
@@ -76,14 +77,10 @@ const content = computed(() =>
|
|||||||
: ""
|
: ""
|
||||||
)
|
)
|
||||||
|
|
||||||
watch(
|
useMarkdownPostRender(content, () => `.note-${classNameId.value}`, {
|
||||||
content,
|
onReady: () => listenToClick(),
|
||||||
async () => {
|
tikz: true
|
||||||
await nextTick()
|
})
|
||||||
listenToClick()
|
|
||||||
},
|
|
||||||
{ immediate: true }
|
|
||||||
)
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -97,7 +94,7 @@ watch(
|
|||||||
>
|
>
|
||||||
<a
|
<a
|
||||||
class="title-stacked-note-link"
|
class="title-stacked-note-link"
|
||||||
@click.prevent="scrollToFocusedNote(didrkey)"
|
@click.prevent="scrollToFocusedNote({ noteId: didrkey })"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="title-stacked-note breadcrumbs text-sm"
|
class="title-stacked-note breadcrumbs text-sm"
|
||||||
@@ -130,7 +127,7 @@ $border-color: rgba(18, 19, 58, 0.2);
|
|||||||
}
|
}
|
||||||
|
|
||||||
section {
|
section {
|
||||||
padding: 0 0.5rem 2rem;
|
padding: 0 0.5rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -144,7 +141,6 @@ $border-color: rgba(18, 19, 58, 0.2);
|
|||||||
background-color: var(--color-base-100);
|
background-color: var(--color-base-100);
|
||||||
color: var(--color-base-content);
|
color: var(--color-base-content);
|
||||||
font-size: 0.8em;
|
font-size: 0.8em;
|
||||||
overflow: hidden;
|
|
||||||
|
|
||||||
ul,
|
ul,
|
||||||
li {
|
li {
|
||||||
@@ -178,7 +174,7 @@ $border-color: rgba(18, 19, 58, 0.2);
|
|||||||
padding: 0 0.75rem 1rem;
|
padding: 0 0.75rem 1rem;
|
||||||
|
|
||||||
section {
|
section {
|
||||||
padding: 1rem 0 2rem;
|
padding: 1rem 0;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -193,6 +189,8 @@ $border-color: rgba(18, 19, 58, 0.2);
|
|||||||
.stacked-note {
|
.stacked-note {
|
||||||
border-top: 0;
|
border-top: 0;
|
||||||
border-left: 1px solid $border-color;
|
border-left: 1px solid $border-color;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.title-stacked-note {
|
.title-stacked-note {
|
||||||
|
|||||||
582
src/components/TodoTxtItem.vue
Normal file
582
src/components/TodoTxtItem.vue
Normal file
@@ -0,0 +1,582 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onClickOutside } from "@vueuse/core"
|
||||||
|
import { computed, nextTick, ref, useTemplateRef, watch } from "vue"
|
||||||
|
|
||||||
|
import {
|
||||||
|
removeTagFromBody,
|
||||||
|
removeTokenFromBody,
|
||||||
|
segmentBody,
|
||||||
|
Task,
|
||||||
|
toggleCompleted
|
||||||
|
} from "@/utils/todotxt"
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
task: Task
|
||||||
|
canEdit: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
update: [task: Task]
|
||||||
|
delete: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const segments = computed(() => segmentBody(props.task.body))
|
||||||
|
|
||||||
|
const PRIORITIES = ["A", "B", "C", "D"] as const
|
||||||
|
|
||||||
|
const priorityBadgeClassFor = (priority?: string): string => {
|
||||||
|
const preClass = "badge badge-soft"
|
||||||
|
|
||||||
|
switch (priority) {
|
||||||
|
case "A":
|
||||||
|
return `${preClass} badge-error`
|
||||||
|
case "B":
|
||||||
|
return `${preClass} badge-warning`
|
||||||
|
case "C":
|
||||||
|
return `${preClass} badge-info`
|
||||||
|
default:
|
||||||
|
return `${preClass} badge-ghost`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const priorityBadgeClass = computed(() =>
|
||||||
|
priorityBadgeClassFor(props.task.priority)
|
||||||
|
)
|
||||||
|
|
||||||
|
const toggle = () => {
|
||||||
|
if (!props.canEdit) return
|
||||||
|
emit("update", toggleCompleted(props.task))
|
||||||
|
}
|
||||||
|
|
||||||
|
const priorityOpen = ref(false)
|
||||||
|
const priorityDropdown = useTemplateRef<HTMLDivElement>("priorityDropdown")
|
||||||
|
onClickOutside(priorityDropdown, () => {
|
||||||
|
priorityOpen.value = false
|
||||||
|
})
|
||||||
|
|
||||||
|
const togglePriorityMenu = () => {
|
||||||
|
if (!props.canEdit) return
|
||||||
|
priorityOpen.value = !priorityOpen.value
|
||||||
|
}
|
||||||
|
|
||||||
|
const setPriority = (priority: string | undefined) => {
|
||||||
|
if (!props.canEdit) return
|
||||||
|
emit("update", { ...props.task, priority })
|
||||||
|
priorityOpen.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeToken = (kind: "project" | "context", value: string) => {
|
||||||
|
if (!props.canEdit) return
|
||||||
|
const newBody = removeTokenFromBody(
|
||||||
|
props.task.body,
|
||||||
|
kind === "project" ? "+" : "@",
|
||||||
|
value
|
||||||
|
)
|
||||||
|
emit("update", { ...props.task, body: newBody })
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeTag = (key: "due" | "rec", value: string) => {
|
||||||
|
if (!props.canEdit) return
|
||||||
|
const newBody = removeTagFromBody(props.task.body, key, value)
|
||||||
|
emit("update", { ...props.task, body: newBody })
|
||||||
|
}
|
||||||
|
|
||||||
|
const startOfToday = (): Date => {
|
||||||
|
const now = new Date()
|
||||||
|
return new Date(now.getFullYear(), now.getMonth(), now.getDate())
|
||||||
|
}
|
||||||
|
|
||||||
|
const parseDueDate = (value: string): Date | null => {
|
||||||
|
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return null
|
||||||
|
const [y, m, d] = value.split("-").map(Number)
|
||||||
|
return new Date(y, m - 1, d)
|
||||||
|
}
|
||||||
|
|
||||||
|
const dueClass = (value: string): string => {
|
||||||
|
const due = parseDueDate(value)
|
||||||
|
if (!due) return "badge badge-soft badge-accent"
|
||||||
|
const today = startOfToday().getTime()
|
||||||
|
if (due.getTime() < today) return "badge badge-soft badge-error"
|
||||||
|
if (due.getTime() === today) return "badge badge-soft badge-warning"
|
||||||
|
return "badge badge-soft badge-accent"
|
||||||
|
}
|
||||||
|
|
||||||
|
const MS_PER_DAY = 86_400_000
|
||||||
|
|
||||||
|
const formatDueDate = (value: string): string => {
|
||||||
|
const due = parseDueDate(value)
|
||||||
|
if (!due) return value
|
||||||
|
const diff = Math.round(
|
||||||
|
(due.getTime() - startOfToday().getTime()) / MS_PER_DAY
|
||||||
|
)
|
||||||
|
if (diff === 0) return "Today"
|
||||||
|
if (diff === 1) return "Tomorrow"
|
||||||
|
if (diff === -1) return "Yesterday"
|
||||||
|
return due.toLocaleDateString(undefined, {
|
||||||
|
year: "numeric",
|
||||||
|
month: "short",
|
||||||
|
day: "numeric"
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const dueTitle = (value: string): string => {
|
||||||
|
const due = parseDueDate(value)
|
||||||
|
if (!due) return `Due ${value}`
|
||||||
|
return `Due ${due.toLocaleDateString(undefined, {
|
||||||
|
weekday: "long",
|
||||||
|
year: "numeric",
|
||||||
|
month: "long",
|
||||||
|
day: "numeric"
|
||||||
|
})}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const REC_UNITS: Record<string, [string, string]> = {
|
||||||
|
d: ["day", "days"],
|
||||||
|
w: ["week", "weeks"],
|
||||||
|
m: ["month", "months"],
|
||||||
|
y: ["year", "years"]
|
||||||
|
}
|
||||||
|
const REC_ADVERBS: Record<string, string> = {
|
||||||
|
d: "Daily",
|
||||||
|
w: "Weekly",
|
||||||
|
m: "Monthly",
|
||||||
|
y: "Yearly"
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatRecurrence = (value: string): string => {
|
||||||
|
const m = value.match(/^\+?(\d+)([dwmyDWMY])$/)
|
||||||
|
if (!m) return value
|
||||||
|
const n = parseInt(m[1], 10)
|
||||||
|
const unit = m[2].toLowerCase()
|
||||||
|
if (n === 1) return REC_ADVERBS[unit] ?? value
|
||||||
|
const [, plural] = REC_UNITS[unit]
|
||||||
|
return `Every ${n} ${plural}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const recTitle = (value: string): string => {
|
||||||
|
const isStrict = value.startsWith("+")
|
||||||
|
const formatted = formatRecurrence(value)
|
||||||
|
return isStrict
|
||||||
|
? `${formatted} (from due date)`
|
||||||
|
: `${formatted} (from completion)`
|
||||||
|
}
|
||||||
|
|
||||||
|
const editing = ref(false)
|
||||||
|
const bodyDraft = ref("")
|
||||||
|
const inputRef = useTemplateRef<HTMLInputElement>("bodyInput")
|
||||||
|
|
||||||
|
const startEdit = () => {
|
||||||
|
if (!props.canEdit || props.task.completed) return
|
||||||
|
bodyDraft.value = props.task.body
|
||||||
|
editing.value = true
|
||||||
|
nextTick(() => {
|
||||||
|
inputRef.value?.focus()
|
||||||
|
inputRef.value?.select()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const commitEdit = () => {
|
||||||
|
if (!editing.value) return
|
||||||
|
const next = bodyDraft.value.trim()
|
||||||
|
editing.value = false
|
||||||
|
if (next !== props.task.body) {
|
||||||
|
emit("update", { ...props.task, body: next })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const cancelEdit = () => {
|
||||||
|
editing.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.task.body,
|
||||||
|
() => {
|
||||||
|
if (editing.value) editing.value = false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="todo-row group flex items-start gap-2 py-1 px-2 rounded hover:bg-base-200"
|
||||||
|
:class="{ 'opacity-60': task.completed }"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
class="checkbox checkbox-success checkbox-sm mt-1"
|
||||||
|
:checked="task.completed"
|
||||||
|
:disabled="!canEdit"
|
||||||
|
@change="toggle"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div
|
||||||
|
ref="priorityDropdown"
|
||||||
|
class="todo-priority"
|
||||||
|
:class="{ 'todo-priority--open': priorityOpen }"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
:class="priorityBadgeClass"
|
||||||
|
class="todo-priority-trigger cursor-pointer w-7 justify-center mt-0.5"
|
||||||
|
:disabled="!canEdit"
|
||||||
|
@click="togglePriorityMenu"
|
||||||
|
>
|
||||||
|
{{ task.priority ?? "—" }}
|
||||||
|
</button>
|
||||||
|
<div
|
||||||
|
v-if="canEdit && priorityOpen"
|
||||||
|
class="todo-priority-menu bg-base-200 rounded-box shadow p-2"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
v-for="p in PRIORITIES"
|
||||||
|
:key="p"
|
||||||
|
type="button"
|
||||||
|
:class="priorityBadgeClassFor(p)"
|
||||||
|
class="todo-priority-option w-10 justify-center cursor-pointer"
|
||||||
|
@click="setPriority(p)"
|
||||||
|
>
|
||||||
|
({{ p }})
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
:class="priorityBadgeClassFor(undefined)"
|
||||||
|
class="todo-priority-option w-10 justify-center cursor-pointer"
|
||||||
|
@click="setPriority(undefined)"
|
||||||
|
>
|
||||||
|
—
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<input
|
||||||
|
v-if="editing"
|
||||||
|
ref="bodyInput"
|
||||||
|
v-model="bodyDraft"
|
||||||
|
type="text"
|
||||||
|
class="input input-ghost input-sm w-full"
|
||||||
|
@blur="commitEdit"
|
||||||
|
@keydown.enter.prevent="commitEdit"
|
||||||
|
@keydown.escape.prevent="cancelEdit"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="body-display cursor-text"
|
||||||
|
:class="{ 'line-through': task.completed }"
|
||||||
|
@click="startEdit"
|
||||||
|
>
|
||||||
|
<template v-for="(seg, i) in segments" :key="i">
|
||||||
|
<span v-if="seg.kind === 'text'">{{ seg.value }}</span>
|
||||||
|
<span
|
||||||
|
v-else-if="seg.kind === 'project'"
|
||||||
|
class="todo-chip badge badge-soft badge-primary mx-0.5"
|
||||||
|
@click.stop
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="icon icon-tabler icon-tabler-plus"
|
||||||
|
width="14"
|
||||||
|
height="14"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke="currentColor"
|
||||||
|
fill="none"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||||
|
<path d="M12 5l0 14" />
|
||||||
|
<path d="M5 12l14 0" />
|
||||||
|
</svg>
|
||||||
|
{{ seg.value }}
|
||||||
|
<button
|
||||||
|
v-if="canEdit"
|
||||||
|
class="todo-chip-close"
|
||||||
|
type="button"
|
||||||
|
:aria-label="`Remove project ${seg.value}`"
|
||||||
|
@click.stop="removeToken('project', seg.value)"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="icon icon-tabler icon-tabler-x"
|
||||||
|
width="14"
|
||||||
|
height="14"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke="currentColor"
|
||||||
|
fill="none"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||||
|
<path d="M18 6l-12 12" />
|
||||||
|
<path d="M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
v-else-if="seg.kind === 'context'"
|
||||||
|
class="todo-chip badge badge-soft badge-secondary mx-0.5"
|
||||||
|
@click.stop
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="icon icon-tabler icon-tabler-at"
|
||||||
|
width="14"
|
||||||
|
height="14"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke-width="1.75"
|
||||||
|
stroke="currentColor"
|
||||||
|
fill="none"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||||
|
<path d="M12 12m-4 0a4 4 0 1 0 8 0a4 4 0 1 0 -8 0" />
|
||||||
|
<path d="M16 12v1.5a2.5 2.5 0 0 0 5 0v-1.5a9 9 0 1 0 -5.5 8.28" />
|
||||||
|
</svg>
|
||||||
|
{{ seg.value }}
|
||||||
|
<button
|
||||||
|
v-if="canEdit"
|
||||||
|
class="todo-chip-close"
|
||||||
|
type="button"
|
||||||
|
:aria-label="`Remove context ${seg.value}`"
|
||||||
|
@click.stop="removeToken('context', seg.value)"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="icon icon-tabler icon-tabler-x"
|
||||||
|
width="14"
|
||||||
|
height="14"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke="currentColor"
|
||||||
|
fill="none"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||||
|
<path d="M18 6l-12 12" />
|
||||||
|
<path d="M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
v-else-if="seg.kind === 'due'"
|
||||||
|
:class="dueClass(seg.value) + ' mx-0.5 todo-chip'"
|
||||||
|
:title="dueTitle(seg.value)"
|
||||||
|
@click.stop
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="icon icon-tabler icon-tabler-calendar"
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke-width="1.75"
|
||||||
|
stroke="currentColor"
|
||||||
|
fill="none"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||||
|
<path
|
||||||
|
d="M4 7a2 2 0 0 1 2 -2h12a2 2 0 0 1 2 2v12a2 2 0 0 1 -2 2h-12a2 2 0 0 1 -2 -2z"
|
||||||
|
/>
|
||||||
|
<path d="M16 3v4" />
|
||||||
|
<path d="M8 3v4" />
|
||||||
|
<path d="M4 11h16" />
|
||||||
|
</svg>
|
||||||
|
{{ formatDueDate(seg.value) }}
|
||||||
|
<button
|
||||||
|
v-if="canEdit"
|
||||||
|
class="todo-chip-close"
|
||||||
|
type="button"
|
||||||
|
:aria-label="`Remove due date ${seg.value}`"
|
||||||
|
@click.stop="removeTag('due', seg.value)"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="icon icon-tabler icon-tabler-x"
|
||||||
|
width="14"
|
||||||
|
height="14"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke="currentColor"
|
||||||
|
fill="none"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||||
|
<path d="M18 6l-12 12" />
|
||||||
|
<path d="M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
v-else
|
||||||
|
class="todo-chip badge badge-soft badge-info mx-0.5"
|
||||||
|
:title="recTitle(seg.value)"
|
||||||
|
@click.stop
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="icon icon-tabler icon-tabler-repeat"
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke-width="1.75"
|
||||||
|
stroke="currentColor"
|
||||||
|
fill="none"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||||
|
<path d="M4 12v-3a3 3 0 0 1 3 -3h13m-3 -3l3 3l-3 3" />
|
||||||
|
<path d="M20 12v3a3 3 0 0 1 -3 3h-13m3 3l-3 -3l3 -3" />
|
||||||
|
</svg>
|
||||||
|
{{ formatRecurrence(seg.value) }}
|
||||||
|
<button
|
||||||
|
v-if="canEdit"
|
||||||
|
class="todo-chip-close"
|
||||||
|
type="button"
|
||||||
|
:aria-label="`Remove recurrence ${seg.value}`"
|
||||||
|
@click.stop="removeTag('rec', seg.value)"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="icon icon-tabler icon-tabler-x"
|
||||||
|
width="14"
|
||||||
|
height="14"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke="currentColor"
|
||||||
|
fill="none"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||||
|
<path d="M18 6l-12 12" />
|
||||||
|
<path d="M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
<span v-if="task.body.length === 0" class="opacity-50 italic">
|
||||||
|
(empty task)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
v-if="canEdit"
|
||||||
|
type="button"
|
||||||
|
class="btn btn-ghost btn-xs opacity-0 group-hover:opacity-100 transition"
|
||||||
|
aria-label="Delete task"
|
||||||
|
@click="emit('delete')"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="icon icon-tabler icon-tabler-trash"
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke-width="1.75"
|
||||||
|
stroke="currentColor"
|
||||||
|
fill="none"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||||
|
<path d="M4 7h16" />
|
||||||
|
<path d="M10 11v6" />
|
||||||
|
<path d="M14 11v6" />
|
||||||
|
<path d="M5 7l1 12a2 2 0 0 0 2 2h8a2 2 0 0 0 2 -2l1 -12" />
|
||||||
|
<path d="M9 7v-3a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v3" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.body-display {
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-priority {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lift the whole priority widget above sibling rows while its menu is open,
|
||||||
|
// otherwise the absolutely-positioned menu paints behind row N+1's content.
|
||||||
|
// 1 is enough: no other element in this view sets a non-auto z-index.
|
||||||
|
.todo-priority--open {
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-priority-trigger {
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-priority-menu {
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
left: 0;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
min-width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-priority-option {
|
||||||
|
border: 0;
|
||||||
|
font: inherit;
|
||||||
|
transition:
|
||||||
|
transform 120ms ease,
|
||||||
|
filter 120ms ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
filter: brightness(1.1);
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus-visible {
|
||||||
|
outline: 2px solid var(--color-primary);
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.2rem;
|
||||||
|
vertical-align: baseline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-chip-close {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
cursor: pointer;
|
||||||
|
margin-left: 0.1rem;
|
||||||
|
padding: 0;
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
color: inherit;
|
||||||
|
opacity: 0.7;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
98
src/components/UserPill.spec.ts
Normal file
98
src/components/UserPill.spec.ts
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
import { mount } from "@vue/test-utils"
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
|
||||||
|
import { ref } from "vue"
|
||||||
|
|
||||||
|
const ghAccessToken = ref<string | null>(null)
|
||||||
|
const ghUsername = ref<string | null>(null)
|
||||||
|
const atIsLoggedIn = ref(false)
|
||||||
|
const atHandle = ref<string | null>(null)
|
||||||
|
const atAvatarUrl = ref<string | null>(null)
|
||||||
|
|
||||||
|
vi.mock("@/hooks/useGitHubLogin.hook", () => ({
|
||||||
|
useGitHubLogin: () => ({
|
||||||
|
username: ghUsername,
|
||||||
|
accessToken: ghAccessToken
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock("@/hooks/useATProtoLogin.hook", () => ({
|
||||||
|
useATProtoLogin: () => ({
|
||||||
|
isLoggedIn: atIsLoggedIn,
|
||||||
|
handle: atHandle,
|
||||||
|
avatarUrl: atAvatarUrl
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
|
||||||
|
import UserPill from "./UserPill.vue"
|
||||||
|
|
||||||
|
describe("UserPill", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
ghAccessToken.value = null
|
||||||
|
ghUsername.value = null
|
||||||
|
atIsLoggedIn.value = false
|
||||||
|
atHandle.value = null
|
||||||
|
atAvatarUrl.value = null
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("shows the ghost 'Sign in' pill when nobody is logged in", () => {
|
||||||
|
const wrapper = mount(UserPill)
|
||||||
|
expect(wrapper.text()).toContain("Sign in")
|
||||||
|
expect(wrapper.classes()).toContain("profile-chip--ghost")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("shows the GitHub username and initial when logged in via GitHub only", () => {
|
||||||
|
ghAccessToken.value = "tok"
|
||||||
|
ghUsername.value = "alice"
|
||||||
|
|
||||||
|
const wrapper = mount(UserPill)
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain("alice")
|
||||||
|
expect(wrapper.find(".profile-avatar-initial").text()).toBe("A")
|
||||||
|
expect(wrapper.find("img").exists()).toBe(false)
|
||||||
|
expect(wrapper.classes()).not.toContain("profile-chip--ghost")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("shows the ATProto handle and avatar when logged in via ATProto", () => {
|
||||||
|
atIsLoggedIn.value = true
|
||||||
|
atHandle.value = "alice.bsky.social"
|
||||||
|
atAvatarUrl.value = "https://cdn.example.com/avatar.jpg"
|
||||||
|
|
||||||
|
const wrapper = mount(UserPill)
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain("alice.bsky.social")
|
||||||
|
const img = wrapper.find("img")
|
||||||
|
expect(img.exists()).toBe(true)
|
||||||
|
expect(img.attributes("src")).toBe("https://cdn.example.com/avatar.jpg")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("prefers the ATProto handle over the GitHub username when both are present", () => {
|
||||||
|
ghAccessToken.value = "tok"
|
||||||
|
ghUsername.value = "alice-gh"
|
||||||
|
atIsLoggedIn.value = true
|
||||||
|
atHandle.value = "alice.bsky.social"
|
||||||
|
|
||||||
|
const wrapper = mount(UserPill)
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain("alice.bsky.social")
|
||||||
|
expect(wrapper.text()).not.toContain("alice-gh")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("falls back to '?' initial when no name is available but a user is logged in", () => {
|
||||||
|
atIsLoggedIn.value = true
|
||||||
|
atHandle.value = ""
|
||||||
|
|
||||||
|
const wrapper = mount(UserPill)
|
||||||
|
|
||||||
|
expect(wrapper.find(".profile-avatar-initial").text()).toBe("?")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("emits click when clicked", async () => {
|
||||||
|
const wrapper = mount(UserPill)
|
||||||
|
await wrapper.trigger("click")
|
||||||
|
expect(wrapper.emitted("click")).toHaveLength(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
111
src/components/UserPill.vue
Normal file
111
src/components/UserPill.vue
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from "vue"
|
||||||
|
|
||||||
|
import { useATProtoLogin } from "@/hooks/useATProtoLogin.hook"
|
||||||
|
import { useGitHubLogin } from "@/hooks/useGitHubLogin.hook"
|
||||||
|
|
||||||
|
const { username, accessToken } = useGitHubLogin()
|
||||||
|
const { isLoggedIn: isATProtoLoggedIn, handle, avatarUrl } = useATProtoLogin()
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
click: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const isGitHubLoggedIn = computed(() => !!accessToken.value)
|
||||||
|
const isAnyUserLoggedIn = computed(
|
||||||
|
() => isGitHubLoggedIn.value || isATProtoLoggedIn.value
|
||||||
|
)
|
||||||
|
const displayUsername = computed(() => handle.value || username.value || "")
|
||||||
|
const displayInitial = computed(() =>
|
||||||
|
(displayUsername.value[0] || "?").toUpperCase()
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<button
|
||||||
|
v-if="isAnyUserLoggedIn"
|
||||||
|
class="profile-chip"
|
||||||
|
type="button"
|
||||||
|
@click="$emit('click')"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
v-if="isATProtoLoggedIn && avatarUrl"
|
||||||
|
:src="avatarUrl"
|
||||||
|
class="profile-avatar-small"
|
||||||
|
alt="Profile"
|
||||||
|
/>
|
||||||
|
<span v-else class="profile-avatar-small profile-avatar-initial">
|
||||||
|
{{ displayInitial }}
|
||||||
|
</span>
|
||||||
|
<span class="profile-name">{{ displayUsername }}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-else
|
||||||
|
class="profile-chip profile-chip--ghost"
|
||||||
|
type="button"
|
||||||
|
@click="$emit('click')"
|
||||||
|
>
|
||||||
|
Sign in
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.profile-chip {
|
||||||
|
--pill-rule: color-mix(
|
||||||
|
in oklch,
|
||||||
|
var(--color-base-content) 12%,
|
||||||
|
var(--color-base-200)
|
||||||
|
);
|
||||||
|
--pill-accent: #e36598;
|
||||||
|
--pill-accent-wash: color-mix(
|
||||||
|
in oklch,
|
||||||
|
#e36598 12%,
|
||||||
|
var(--color-base-200)
|
||||||
|
);
|
||||||
|
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.25rem 0.75rem 0.25rem 0.25rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid var(--pill-rule);
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--color-base-content);
|
||||||
|
transition:
|
||||||
|
border-color 0.15s,
|
||||||
|
background 0.15s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: var(--pill-accent);
|
||||||
|
background: var(--pill-accent-wash);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.profile-chip--ghost {
|
||||||
|
padding: 0.35rem 0.9rem;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-avatar-small {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 50%;
|
||||||
|
object-fit: cover;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-avatar-initial {
|
||||||
|
background: var(--pill-accent, #e36598);
|
||||||
|
color: white;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-name {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -10,3 +10,7 @@ export const getNoteWidth = () => {
|
|||||||
}
|
}
|
||||||
return cached
|
return cached
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const resetNoteWidthCache = () => {
|
||||||
|
cached = undefined
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,5 +7,6 @@ export enum DataType {
|
|||||||
RepetitionCard = "RepetitionCard",
|
RepetitionCard = "RepetitionCard",
|
||||||
History = "History",
|
History = "History",
|
||||||
UserSettings = "UserSettings",
|
UserSettings = "UserSettings",
|
||||||
AtprotoSession = "AtprotoSession"
|
AtprotoSession = "AtprotoSession",
|
||||||
|
TikzCache = "TikzCache"
|
||||||
}
|
}
|
||||||
|
|||||||
172
src/data/data.ts
172
src/data/data.ts
@@ -1,166 +1,34 @@
|
|||||||
|
import { wrap } from "comlink"
|
||||||
import { nanoid } from "nanoid"
|
import { nanoid } from "nanoid"
|
||||||
import indexedDb from "pouchdb-adapter-indexeddb"
|
|
||||||
import PouchDb from "pouchdb-browser"
|
|
||||||
|
|
||||||
import { DataType } from "./DataType.enum"
|
import { DataType } from "./DataType.enum"
|
||||||
import { Model } from "./models/Model"
|
import { Model } from "./models/Model"
|
||||||
|
|
||||||
PouchDb.plugin(indexedDb)
|
export interface DataApi {
|
||||||
|
add<DT extends DataType>(model: Model<DT>): Promise<boolean>
|
||||||
interface GetAllParams {
|
update<DT extends DataType, T extends Model<DT>>(model: T): Promise<boolean>
|
||||||
|
bulkUpdate<DT extends DataType, T extends Model<DT>>(
|
||||||
|
models: T[]
|
||||||
|
): Promise<boolean>
|
||||||
|
remove(id: string): Promise<boolean>
|
||||||
|
get<DT extends DataType, T extends Model<DT>>(id: string): Promise<T | null>
|
||||||
|
getOrCreate<DT extends DataType, T extends Model<DT>>(
|
||||||
|
id: string,
|
||||||
|
initialValue: T
|
||||||
|
): Promise<T>
|
||||||
|
getAll<DT extends DataType, T extends Model<DT>>(params: {
|
||||||
prefix?: string
|
prefix?: string
|
||||||
includeDocs?: boolean
|
includeDocs?: boolean
|
||||||
includeAttachments?: boolean
|
includeAttachments?: boolean
|
||||||
keys?: string[]
|
keys?: string[]
|
||||||
|
}): Promise<T[]>
|
||||||
}
|
}
|
||||||
|
|
||||||
class Data {
|
export const generateId = (type?: DataType | string, id?: string): string => {
|
||||||
// oxlint-disable-next-line typescript/ban-types
|
if (!type) return id || nanoid()
|
||||||
private readonly locale: PouchDB.Database<{}> | null = null
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
try {
|
|
||||||
this.locale = new PouchDb("remanso", {
|
|
||||||
adapter: "indexeddb"
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
console.warn("data error", error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async add<DT extends DataType>(model: Model<DT>): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
const result = await this.locale?.put(model)
|
|
||||||
return result?.ok ?? false
|
|
||||||
} catch (error) {
|
|
||||||
console.warn(error)
|
|
||||||
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async update<DT extends DataType, T extends Model<DT>>(
|
|
||||||
model: T
|
|
||||||
): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
if (!model._id) {
|
|
||||||
const result = await this.locale?.put(model)
|
|
||||||
return result?.ok ?? false
|
|
||||||
}
|
|
||||||
|
|
||||||
const oldModel = await this.get(model._id)
|
|
||||||
if (oldModel) {
|
|
||||||
const result = await this.locale?.put({ ...oldModel, ...model })
|
|
||||||
return result?.ok ?? false
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await this.locale?.put(model)
|
|
||||||
return result?.ok ?? false
|
|
||||||
} catch (error) {
|
|
||||||
console.warn(error)
|
|
||||||
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async remove(id: string): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
const doc = await this.get(id)
|
|
||||||
if (!doc) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
const result = await this.locale?.put({
|
|
||||||
...doc,
|
|
||||||
_deleted: true
|
|
||||||
})
|
|
||||||
return result?.ok ?? false
|
|
||||||
} catch {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async get<DT extends DataType, T extends Model<DT>>(
|
|
||||||
id: string
|
|
||||||
): Promise<T | null> {
|
|
||||||
try {
|
|
||||||
return ((await this.locale?.get(id)) as T) || null
|
|
||||||
} catch {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async getOrCreate<DT extends DataType, T extends Model<DT>>(
|
|
||||||
id: string,
|
|
||||||
initialValue: T
|
|
||||||
): Promise<T> {
|
|
||||||
const element = await this.get<DT, T>(id)
|
|
||||||
|
|
||||||
if (element) {
|
|
||||||
return element
|
|
||||||
}
|
|
||||||
|
|
||||||
await data.add<DT>({ ...initialValue, _id: id })
|
|
||||||
|
|
||||||
return this.getOrCreate(id, initialValue)
|
|
||||||
}
|
|
||||||
|
|
||||||
public async getAll<DT extends DataType, T extends Model<DT>>({
|
|
||||||
prefix,
|
|
||||||
includeDocs = true,
|
|
||||||
includeAttachments = false,
|
|
||||||
keys = []
|
|
||||||
}: GetAllParams): Promise<T[]> {
|
|
||||||
if (!this.locale) {
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
|
|
||||||
if (keys.length) {
|
|
||||||
const response = await this.locale.allDocs({
|
|
||||||
include_docs: includeDocs,
|
|
||||||
attachments: includeAttachments,
|
|
||||||
keys: keys.map((key) => this.generateId(prefix, key))
|
|
||||||
})
|
|
||||||
|
|
||||||
if (includeDocs) {
|
|
||||||
return response.rows
|
|
||||||
.map((row) => {
|
|
||||||
if ("error" in row) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
return row.doc
|
|
||||||
})
|
|
||||||
.filter(Boolean) as T[]
|
|
||||||
} else {
|
|
||||||
return response.rows
|
|
||||||
.map((row) => {
|
|
||||||
if ("error" in row) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
return { _id: row.id }
|
|
||||||
})
|
|
||||||
.filter(Boolean) as T[]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await this.locale.allDocs({
|
|
||||||
include_docs: includeDocs,
|
|
||||||
attachments: includeAttachments,
|
|
||||||
startkey: prefix ? prefix : undefined,
|
|
||||||
endkey: prefix ? `${prefix}\ufff0` : undefined
|
|
||||||
})
|
|
||||||
|
|
||||||
return response.rows.map((row) => row.doc) as T[]
|
|
||||||
}
|
|
||||||
|
|
||||||
public generateId(type?: DataType | string, id?: string) {
|
|
||||||
if (!type) {
|
|
||||||
return id || nanoid()
|
|
||||||
}
|
|
||||||
|
|
||||||
return `${type}-${id || nanoid()}`
|
return `${type}-${id || nanoid()}`
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const data = new Data()
|
import DataWorker from "./data.worker?worker"
|
||||||
|
|
||||||
|
export const data = wrap(new DataWorker()) as unknown as DataApi
|
||||||
|
|||||||
169
src/data/data.worker.ts
Normal file
169
src/data/data.worker.ts
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
import { expose } from "comlink"
|
||||||
|
import { nanoid } from "nanoid"
|
||||||
|
import indexedDb from "pouchdb-adapter-indexeddb"
|
||||||
|
import PouchDb from "pouchdb-browser"
|
||||||
|
|
||||||
|
import { DataType } from "./DataType.enum"
|
||||||
|
import { Model } from "./models/Model"
|
||||||
|
|
||||||
|
PouchDb.plugin(indexedDb)
|
||||||
|
|
||||||
|
interface GetAllParams {
|
||||||
|
prefix?: string
|
||||||
|
includeDocs?: boolean
|
||||||
|
includeAttachments?: boolean
|
||||||
|
keys?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
class Data {
|
||||||
|
// oxlint-disable-next-line typescript/ban-types
|
||||||
|
private readonly locale: PouchDB.Database<{}> | null = null
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
try {
|
||||||
|
this.locale = new PouchDb("remanso", {
|
||||||
|
adapter: "indexeddb"
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("data error", error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildId(type?: DataType | string, id?: string): string {
|
||||||
|
if (!type) return id || nanoid()
|
||||||
|
return `${type}-${id || nanoid()}`
|
||||||
|
}
|
||||||
|
|
||||||
|
public async add<DT extends DataType>(model: Model<DT>): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const result = await this.locale?.put(model)
|
||||||
|
return result?.ok ?? false
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(error)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async update<DT extends DataType, T extends Model<DT>>(
|
||||||
|
model: T
|
||||||
|
): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
if (!model._id) {
|
||||||
|
const result = await this.locale?.put(model)
|
||||||
|
return result?.ok ?? false
|
||||||
|
}
|
||||||
|
|
||||||
|
const oldModel = await this.get(model._id)
|
||||||
|
if (oldModel) {
|
||||||
|
const result = await this.locale?.put({ ...oldModel, ...model })
|
||||||
|
return result?.ok ?? false
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await this.locale?.put(model)
|
||||||
|
return result?.ok ?? false
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(error)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async bulkUpdate<DT extends DataType, T extends Model<DT>>(
|
||||||
|
models: T[]
|
||||||
|
): Promise<boolean> {
|
||||||
|
if (!this.locale || !models.length) return false
|
||||||
|
try {
|
||||||
|
const result = await this.locale.bulkDocs(models)
|
||||||
|
return result.every((r) => "ok" in r && r.ok)
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(error)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async remove(id: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const doc = await this.get(id)
|
||||||
|
if (!doc) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
const result = await this.locale?.put({
|
||||||
|
...doc,
|
||||||
|
_deleted: true
|
||||||
|
})
|
||||||
|
return result?.ok ?? false
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async get<DT extends DataType, T extends Model<DT>>(
|
||||||
|
id: string
|
||||||
|
): Promise<T | null> {
|
||||||
|
try {
|
||||||
|
return ((await this.locale?.get(id)) as T) || null
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getOrCreate<DT extends DataType, T extends Model<DT>>(
|
||||||
|
id: string,
|
||||||
|
initialValue: T
|
||||||
|
): Promise<T> {
|
||||||
|
const element = await this.get<DT, T>(id)
|
||||||
|
|
||||||
|
if (element) {
|
||||||
|
return element
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.add<DT>({ ...initialValue, _id: id })
|
||||||
|
|
||||||
|
return this.getOrCreate(id, initialValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getAll<DT extends DataType, T extends Model<DT>>({
|
||||||
|
prefix,
|
||||||
|
includeDocs = true,
|
||||||
|
includeAttachments = false,
|
||||||
|
keys = []
|
||||||
|
}: GetAllParams): Promise<T[]> {
|
||||||
|
if (!this.locale) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
if (keys.length) {
|
||||||
|
const response = await this.locale.allDocs({
|
||||||
|
include_docs: includeDocs,
|
||||||
|
attachments: includeAttachments,
|
||||||
|
keys: keys.map((key) => this.buildId(prefix, key))
|
||||||
|
})
|
||||||
|
|
||||||
|
if (includeDocs) {
|
||||||
|
return response.rows
|
||||||
|
.map((row) => {
|
||||||
|
if ("error" in row) return null
|
||||||
|
return row.doc
|
||||||
|
})
|
||||||
|
.filter(Boolean) as T[]
|
||||||
|
} else {
|
||||||
|
return response.rows
|
||||||
|
.map((row) => {
|
||||||
|
if ("error" in row) return null
|
||||||
|
return { _id: row.id }
|
||||||
|
})
|
||||||
|
.filter(Boolean) as T[]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await this.locale.allDocs({
|
||||||
|
include_docs: includeDocs,
|
||||||
|
attachments: includeAttachments,
|
||||||
|
startkey: prefix ? prefix : undefined,
|
||||||
|
endkey: prefix ? `${prefix}\ufff0` : undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
return response.rows.map((row) => row.doc) as T[]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
expose(new Data())
|
||||||
6
src/data/models/TikzCache.ts
Normal file
6
src/data/models/TikzCache.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { DataType } from "@/data/DataType.enum"
|
||||||
|
import { Model } from "@/data/models/Model"
|
||||||
|
|
||||||
|
export interface TikzCache extends Model<DataType.TikzCache> {
|
||||||
|
svg: string
|
||||||
|
}
|
||||||
@@ -17,8 +17,8 @@ export const useATProtoLinks = (
|
|||||||
const { currentAtUri, mainNoteId } = options
|
const { currentAtUri, mainNoteId } = options
|
||||||
|
|
||||||
const linkNote = (event: Event) => {
|
const linkNote = (event: Event) => {
|
||||||
const target = event.target as HTMLElement
|
const anchor = (event.target as HTMLElement).closest("a")
|
||||||
const href = target.getAttribute("href")
|
const href = anchor?.getAttribute("href")
|
||||||
|
|
||||||
if (!href) {
|
if (!href) {
|
||||||
return
|
return
|
||||||
@@ -50,7 +50,7 @@ export const useATProtoLinks = (
|
|||||||
: `${params.shortDid}-${params.rkey}`
|
: `${params.shortDid}-${params.rkey}`
|
||||||
|
|
||||||
if (noteId === toValue(mainNoteId)) {
|
if (noteId === toValue(mainNoteId)) {
|
||||||
scrollToFocusedNote(null)
|
scrollToFocusedNote()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,7 +67,7 @@ export const useATProtoLinks = (
|
|||||||
const noteId = `${toShortDid(did)}-${rkey}`
|
const noteId = `${toShortDid(did)}-${rkey}`
|
||||||
|
|
||||||
if (noteId === toValue(mainNoteId)) {
|
if (noteId === toValue(mainNoteId)) {
|
||||||
scrollToFocusedNote(null)
|
scrollToFocusedNote()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,14 +14,32 @@ import {
|
|||||||
|
|
||||||
const did = ref<string | null>(null)
|
const did = ref<string | null>(null)
|
||||||
const handle = ref<string | null>(null)
|
const handle = ref<string | null>(null)
|
||||||
|
const avatarUrl = ref<string | null>(null)
|
||||||
|
|
||||||
let init = true
|
let init = true
|
||||||
|
|
||||||
|
const fetchAvatar = async (actorDid: string) => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(actorDid)}`
|
||||||
|
)
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json()
|
||||||
|
avatarUrl.value = data.avatar ?? null
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
avatarUrl.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const initializeAuth = async () => {
|
const initializeAuth = async () => {
|
||||||
// Load cached session from IndexedDB first (fast, local) so the UI can render immediately
|
// Load cached session from IndexedDB first (fast, local) so the UI can render immediately
|
||||||
const stored = await loadSession()
|
const stored = await loadSession()
|
||||||
did.value = stored?.did ?? ""
|
did.value = stored?.did ?? ""
|
||||||
handle.value = stored?.handle ?? ""
|
handle.value = stored?.handle ?? ""
|
||||||
|
if (stored?.did) {
|
||||||
|
fetchAvatar(stored.did)
|
||||||
|
}
|
||||||
|
|
||||||
// Then restore OAuth session in the background (may involve network)
|
// Then restore OAuth session in the background (may involve network)
|
||||||
const session = await restoreSession()
|
const session = await restoreSession()
|
||||||
@@ -32,6 +50,7 @@ const initializeAuth = async () => {
|
|||||||
did.value = session.did
|
did.value = session.did
|
||||||
handle.value = resolvedHandle
|
handle.value = resolvedHandle
|
||||||
await saveSession(session.did, resolvedHandle)
|
await saveSession(session.did, resolvedHandle)
|
||||||
|
fetchAvatar(session.did)
|
||||||
|
|
||||||
window.history.replaceState(
|
window.history.replaceState(
|
||||||
null,
|
null,
|
||||||
@@ -61,11 +80,13 @@ export const useATProtoLogin = () => {
|
|||||||
await clearSession()
|
await clearSession()
|
||||||
did.value = ""
|
did.value = ""
|
||||||
handle.value = ""
|
handle.value = ""
|
||||||
|
avatarUrl.value = null
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
did,
|
did,
|
||||||
handle,
|
handle,
|
||||||
|
avatarUrl,
|
||||||
isLoggedIn,
|
isLoggedIn,
|
||||||
isATProtoReady,
|
isATProtoReady,
|
||||||
signIn,
|
signIn,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useAsyncState } from "@vueuse/core"
|
|||||||
import { ComputedRef, onUnmounted, toValue } from "vue"
|
import { ComputedRef, onUnmounted, toValue } from "vue"
|
||||||
|
|
||||||
import { backlinkEventBus } from "@/bus/backlinkEventBus"
|
import { backlinkEventBus } from "@/bus/backlinkEventBus"
|
||||||
import { data } from "@/data/data"
|
import { data, generateId } from "@/data/data"
|
||||||
import { DataType } from "@/data/DataType.enum"
|
import { DataType } from "@/data/DataType.enum"
|
||||||
import { BacklinkNote } from "@/modules/note/models/BacklinkNote"
|
import { BacklinkNote } from "@/modules/note/models/BacklinkNote"
|
||||||
|
|
||||||
@@ -11,7 +11,7 @@ export const useBacklinks = (sha: string | ComputedRef<string>) => {
|
|||||||
|
|
||||||
const { state: backlink, execute } = useAsyncState(
|
const { state: backlink, execute } = useAsyncState(
|
||||||
data.get<DataType.BacklinkNote, BacklinkNote>(
|
data.get<DataType.BacklinkNote, BacklinkNote>(
|
||||||
data.generateId(DataType.BacklinkNote, sha)
|
generateId(DataType.BacklinkNote, sha)
|
||||||
),
|
),
|
||||||
null,
|
null,
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -35,7 +35,8 @@ export const useCheckboxCommit = ({
|
|||||||
initialContent,
|
initialContent,
|
||||||
initialSha,
|
initialSha,
|
||||||
containerSelector,
|
containerSelector,
|
||||||
debounceMs = 1000
|
debounceMs = 1000,
|
||||||
|
enabled = true
|
||||||
}: {
|
}: {
|
||||||
user: string
|
user: string
|
||||||
repo: string
|
repo: string
|
||||||
@@ -44,6 +45,7 @@ export const useCheckboxCommit = ({
|
|||||||
initialSha: Ref<string> | string
|
initialSha: Ref<string> | string
|
||||||
containerSelector: string
|
containerSelector: string
|
||||||
debounceMs?: number
|
debounceMs?: number
|
||||||
|
enabled?: Ref<boolean> | boolean
|
||||||
}) => {
|
}) => {
|
||||||
const { updateFile } = useGitHubContent({ user, repo })
|
const { updateFile } = useGitHubContent({ user, repo })
|
||||||
|
|
||||||
@@ -74,7 +76,7 @@ export const useCheckboxCommit = ({
|
|||||||
|
|
||||||
isCommitting.value = true
|
isCommitting.value = true
|
||||||
|
|
||||||
const newSha = await updateFile({
|
const { sha: newSha } = await updateFile({
|
||||||
content: pendingContent.value,
|
content: pendingContent.value,
|
||||||
path: pathValue,
|
path: pathValue,
|
||||||
sha: currentSha.value
|
sha: currentSha.value
|
||||||
@@ -91,6 +93,8 @@ export const useCheckboxCommit = ({
|
|||||||
const debouncedCommit = useDebounceFn(commitChanges, debounceMs)
|
const debouncedCommit = useDebounceFn(commitChanges, debounceMs)
|
||||||
|
|
||||||
const handleCheckboxChange = (event: Event) => {
|
const handleCheckboxChange = (event: Event) => {
|
||||||
|
if (!toValue(enabled)) return
|
||||||
|
|
||||||
const target = event.target as HTMLInputElement
|
const target = event.target as HTMLInputElement
|
||||||
|
|
||||||
if (target.tagName !== "INPUT" || target.type !== "checkbox") {
|
if (target.tagName !== "INPUT" || target.type !== "checkbox") {
|
||||||
@@ -128,8 +132,16 @@ export const useCheckboxCommit = ({
|
|||||||
const listenToCheckboxes = () => {
|
const listenToCheckboxes = () => {
|
||||||
removeListeners()
|
removeListeners()
|
||||||
const container = document.querySelector(containerSelector)
|
const container = document.querySelector(containerSelector)
|
||||||
if (container) {
|
if (!container) return
|
||||||
|
|
||||||
|
if (toValue(enabled)) {
|
||||||
container.addEventListener("change", handleCheckboxChange)
|
container.addEventListener("change", handleCheckboxChange)
|
||||||
|
} else {
|
||||||
|
container
|
||||||
|
.querySelectorAll<HTMLInputElement>('input[type="checkbox"]')
|
||||||
|
.forEach((checkbox) => {
|
||||||
|
checkbox.disabled = true
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { watch } from "vue"
|
import { watch } from "vue"
|
||||||
|
|
||||||
import { backlinkEventBus } from "@/bus/backlinkEventBus"
|
import { backlinkEventBus } from "@/bus/backlinkEventBus"
|
||||||
import { data } from "@/data/data"
|
import { data, generateId } from "@/data/data"
|
||||||
import { DataType } from "@/data/DataType.enum"
|
import { DataType } from "@/data/DataType.enum"
|
||||||
import { useFile } from "@/hooks/useFile.hook"
|
import { useFile } from "@/hooks/useFile.hook"
|
||||||
import { Backlink } from "@/modules/note/models/Backlink"
|
import { Backlink } from "@/modules/note/models/Backlink"
|
||||||
@@ -14,10 +14,21 @@ import { confirmMessage } from "@/utils/notif"
|
|||||||
|
|
||||||
const isMarkdown = (filename?: string) => filename?.endsWith(".md") ?? false
|
const isMarkdown = (filename?: string) => filename?.endsWith(".md") ?? false
|
||||||
|
|
||||||
|
const yieldToMain = () =>
|
||||||
|
"scheduler" in globalThis
|
||||||
|
? (
|
||||||
|
globalThis as unknown as { scheduler: { yield: () => Promise<void> } }
|
||||||
|
).scheduler.yield()
|
||||||
|
: new Promise<void>((r) => setTimeout(r, 0))
|
||||||
|
|
||||||
export const useComputeBacklinks = () => {
|
export const useComputeBacklinks = () => {
|
||||||
const store = useUserRepoStore()
|
const store = useUserRepoStore()
|
||||||
|
|
||||||
watch(store, async () => {
|
watch(
|
||||||
|
() => store.files,
|
||||||
|
async () => {
|
||||||
|
await new Promise<void>((r) => setTimeout(r, 300))
|
||||||
|
|
||||||
if (!store.userSettings?.backlink) {
|
if (!store.userSettings?.backlink) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -27,14 +38,17 @@ export const useComputeBacklinks = () => {
|
|||||||
const backlinks: Map<string, Backlink[]> = new Map()
|
const backlinks: Map<string, Backlink[]> = new Map()
|
||||||
|
|
||||||
for (const file of store.files) {
|
for (const file of store.files) {
|
||||||
|
await yieldToMain()
|
||||||
|
|
||||||
if (!isMarkdown(file.path) || !file.sha) {
|
if (!isMarkdown(file.path) || !file.sha) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
const fileBacklinkId = data.generateId(DataType.BacklinkNote, file.sha)
|
const fileBacklinkId = generateId(DataType.BacklinkNote, file.sha)
|
||||||
const fileBacklink = await data.get<DataType.BacklinkNote, BacklinkNote>(
|
const fileBacklink = await data.get<
|
||||||
fileBacklinkId
|
DataType.BacklinkNote,
|
||||||
)
|
BacklinkNote
|
||||||
|
>(fileBacklinkId)
|
||||||
if (fileBacklink) {
|
if (fileBacklink) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -91,7 +105,7 @@ export const useComputeBacklinks = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const [sha, fileBacklinks] of backlinks) {
|
for (const [sha, fileBacklinks] of backlinks) {
|
||||||
const fileBacklinkId = data.generateId(DataType.BacklinkNote, sha)
|
const fileBacklinkId = generateId(DataType.BacklinkNote, sha)
|
||||||
const backlinkNote: BacklinkNote = {
|
const backlinkNote: BacklinkNote = {
|
||||||
_id: fileBacklinkId,
|
_id: fileBacklinkId,
|
||||||
$type: DataType.BacklinkNote,
|
$type: DataType.BacklinkNote,
|
||||||
@@ -102,5 +116,6 @@ export const useComputeBacklinks = () => {
|
|||||||
await data.update(backlinkNote)
|
await data.update(backlinkNote)
|
||||||
backlinkEventBus.emit({ fileSha: sha })
|
backlinkEventBus.emit({ fileSha: sha })
|
||||||
}
|
}
|
||||||
})
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
76
src/hooks/useEditionMode.spec.ts
Normal file
76
src/hooks/useEditionMode.spec.ts
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import { mount } from "@vue/test-utils"
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest"
|
||||||
|
import { defineComponent, ref } from "vue"
|
||||||
|
|
||||||
|
const escape = ref(false)
|
||||||
|
vi.mock("@vueuse/core", () => ({
|
||||||
|
useMagicKeys: () => ({ escape })
|
||||||
|
}))
|
||||||
|
|
||||||
|
import { useEditionMode } from "./useEditionMode"
|
||||||
|
|
||||||
|
const host = (slot: (api: ReturnType<typeof useEditionMode>) => void) =>
|
||||||
|
mount(
|
||||||
|
defineComponent({
|
||||||
|
template: "<div/>",
|
||||||
|
setup() {
|
||||||
|
const api = useEditionMode()
|
||||||
|
slot(api)
|
||||||
|
return api
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
describe("useEditionMode", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
escape.value = false
|
||||||
|
})
|
||||||
|
|
||||||
|
it("starts in read mode", () => {
|
||||||
|
let mode: unknown
|
||||||
|
host((api) => {
|
||||||
|
mode = api.mode.value
|
||||||
|
})
|
||||||
|
expect(mode).toBe("read")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("toggleMode flips read ↔ edit", () => {
|
||||||
|
let api: ReturnType<typeof useEditionMode> | undefined
|
||||||
|
host((a) => {
|
||||||
|
api = a
|
||||||
|
})
|
||||||
|
|
||||||
|
api!.toggleMode()
|
||||||
|
expect(api!.mode.value).toBe("edit")
|
||||||
|
|
||||||
|
api!.toggleMode()
|
||||||
|
expect(api!.mode.value).toBe("read")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("escape key exits edit mode", async () => {
|
||||||
|
let api: ReturnType<typeof useEditionMode> | undefined
|
||||||
|
host((a) => {
|
||||||
|
a.toggleMode()
|
||||||
|
api = a
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(api!.mode.value).toBe("edit")
|
||||||
|
|
||||||
|
escape.value = true
|
||||||
|
await new Promise((r) => setTimeout(r, 0))
|
||||||
|
|
||||||
|
expect(api!.mode.value).toBe("read")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("escape key is a no-op when already in read mode", async () => {
|
||||||
|
let api: ReturnType<typeof useEditionMode> | undefined
|
||||||
|
host((a) => {
|
||||||
|
api = a
|
||||||
|
})
|
||||||
|
|
||||||
|
escape.value = true
|
||||||
|
await new Promise((r) => setTimeout(r, 0))
|
||||||
|
|
||||||
|
expect(api!.mode.value).toBe("read")
|
||||||
|
})
|
||||||
|
})
|
||||||
67
src/hooks/useForm.hook.spec.ts
Normal file
67
src/hooks/useForm.hook.spec.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import { mount } from "@vue/test-utils"
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest"
|
||||||
|
import { defineComponent } from "vue"
|
||||||
|
|
||||||
|
const push = vi.fn()
|
||||||
|
vi.mock("vue-router", () => ({
|
||||||
|
useRouter: () => ({ push })
|
||||||
|
}))
|
||||||
|
|
||||||
|
import { useForm } from "./useForm.hook"
|
||||||
|
|
||||||
|
const host = () => {
|
||||||
|
let api!: ReturnType<typeof useForm>
|
||||||
|
mount(
|
||||||
|
defineComponent({
|
||||||
|
template: "<div/>",
|
||||||
|
setup() {
|
||||||
|
api = useForm()
|
||||||
|
return api
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
return api
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("useForm", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
push.mockReset()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("starts with empty user and repo inputs", () => {
|
||||||
|
const api = host()
|
||||||
|
expect(api.userInput.value).toBe("")
|
||||||
|
expect(api.repoInput.value).toBe("")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("submit is a no-op when userInput is empty", () => {
|
||||||
|
const api = host()
|
||||||
|
api.repoInput.value = "notes"
|
||||||
|
|
||||||
|
api.submit()
|
||||||
|
|
||||||
|
expect(push).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("submit is a no-op when repoInput is empty", () => {
|
||||||
|
const api = host()
|
||||||
|
api.userInput.value = "alice"
|
||||||
|
|
||||||
|
api.submit()
|
||||||
|
|
||||||
|
expect(push).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("submit pushes the FluxNoteView route with user/repo params", () => {
|
||||||
|
const api = host()
|
||||||
|
api.userInput.value = "alice"
|
||||||
|
api.repoInput.value = "notes"
|
||||||
|
|
||||||
|
api.submit()
|
||||||
|
|
||||||
|
expect(push).toHaveBeenCalledWith({
|
||||||
|
name: "FluxNoteView",
|
||||||
|
params: { user: "alice", repo: "notes" }
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
197
src/hooks/useGitHubContent.hook.spec.ts
Normal file
197
src/hooks/useGitHubContent.hook.spec.ts
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
|
||||||
|
|
||||||
|
vi.mock("@/modules/repo/services/octo", () => ({
|
||||||
|
getOctokit: vi.fn(),
|
||||||
|
runWithAuthRetry: vi.fn()
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock("@/utils/notif", () => ({
|
||||||
|
confirmMessage: vi.fn(),
|
||||||
|
errorMessage: vi.fn()
|
||||||
|
}))
|
||||||
|
|
||||||
|
import { getOctokit, runWithAuthRetry } from "@/modules/repo/services/octo"
|
||||||
|
import { confirmMessage, errorMessage } from "@/utils/notif"
|
||||||
|
|
||||||
|
import { useGitHubContent } from "./useGitHubContent.hook"
|
||||||
|
|
||||||
|
const make = () => useGitHubContent({ user: "alice", repo: "notes" })
|
||||||
|
|
||||||
|
describe("useGitHubContent.fetchLatestSha", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.mocked(runWithAuthRetry).mockReset()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns the sha from a file response", async () => {
|
||||||
|
vi.mocked(runWithAuthRetry).mockResolvedValue({
|
||||||
|
data: { sha: "abc123" }
|
||||||
|
} as never)
|
||||||
|
|
||||||
|
expect(await make().fetchLatestSha("a.md")).toEqual({
|
||||||
|
kind: "ok",
|
||||||
|
sha: "abc123"
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns sha=null when the path resolves to a directory (array response)", async () => {
|
||||||
|
vi.mocked(runWithAuthRetry).mockResolvedValue({ data: [] } as never)
|
||||||
|
|
||||||
|
expect(await make().fetchLatestSha("dir/")).toEqual({
|
||||||
|
kind: "ok",
|
||||||
|
sha: null
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns sha=null when the data object has no sha field", async () => {
|
||||||
|
vi.mocked(runWithAuthRetry).mockResolvedValue({
|
||||||
|
data: { type: "submodule" }
|
||||||
|
} as never)
|
||||||
|
|
||||||
|
expect(await make().fetchLatestSha("path")).toEqual({
|
||||||
|
kind: "ok",
|
||||||
|
sha: null
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns kind=unauthorized on 401", async () => {
|
||||||
|
vi.mocked(runWithAuthRetry).mockRejectedValue(
|
||||||
|
Object.assign(new Error("Unauthorized"), { status: 401 })
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(await make().fetchLatestSha("path")).toEqual({
|
||||||
|
kind: "unauthorized"
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns kind=offline on non-401 errors (404, network, etc.)", async () => {
|
||||||
|
vi.mocked(runWithAuthRetry).mockRejectedValue(
|
||||||
|
Object.assign(new Error("Not found"), { status: 404 })
|
||||||
|
)
|
||||||
|
expect(await make().fetchLatestSha("path")).toEqual({ kind: "offline" })
|
||||||
|
|
||||||
|
vi.mocked(runWithAuthRetry).mockRejectedValue(new Error("network"))
|
||||||
|
expect(await make().fetchLatestSha("path")).toEqual({ kind: "offline" })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("useGitHubContent.updateFile / createFile (putFile)", () => {
|
||||||
|
const mockOctokitRequest = vi.fn()
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockOctokitRequest.mockReset()
|
||||||
|
vi.mocked(getOctokit).mockResolvedValue({
|
||||||
|
request: mockOctokitRequest
|
||||||
|
} as never)
|
||||||
|
vi.mocked(confirmMessage).mockReset()
|
||||||
|
vi.mocked(errorMessage).mockReset()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns the new sha and calls confirmMessage on success", async () => {
|
||||||
|
mockOctokitRequest.mockResolvedValue({
|
||||||
|
data: { content: { sha: "new-sha" } }
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await make().updateFile({
|
||||||
|
content: "# hello",
|
||||||
|
path: "a.md",
|
||||||
|
sha: "old-sha"
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result).toEqual({ sha: "new-sha", conflict: false })
|
||||||
|
expect(confirmMessage).toHaveBeenCalledWith("✅ Note saved")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("flags conflict=true on 409 and 422 responses", async () => {
|
||||||
|
vi.spyOn(console, "warn").mockImplementation(() => {})
|
||||||
|
|
||||||
|
for (const status of [409, 422]) {
|
||||||
|
mockOctokitRequest.mockRejectedValueOnce(
|
||||||
|
Object.assign(new Error("Conflict"), { status })
|
||||||
|
)
|
||||||
|
const result = await make().createFile({ content: "x", path: "a.md" })
|
||||||
|
expect(result).toEqual({ sha: null, conflict: true })
|
||||||
|
}
|
||||||
|
expect(errorMessage).toHaveBeenCalledWith(
|
||||||
|
"⚠ Conflict: this note changed on GitHub"
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns conflict=false and calls errorMessage on non-conflict errors", async () => {
|
||||||
|
vi.spyOn(console, "warn").mockImplementation(() => {})
|
||||||
|
mockOctokitRequest.mockRejectedValue(
|
||||||
|
Object.assign(new Error("Server error"), { status: 500 })
|
||||||
|
)
|
||||||
|
|
||||||
|
const result = await make().updateFile({
|
||||||
|
content: "x",
|
||||||
|
path: "a.md",
|
||||||
|
sha: "old"
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result).toEqual({ sha: null, conflict: false })
|
||||||
|
expect(errorMessage).toHaveBeenCalledWith("❌ Note could not be saved")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("useGitHubContent.uploadBinaryFile", () => {
|
||||||
|
const mockOctokitRequest = vi.fn()
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockOctokitRequest.mockReset()
|
||||||
|
vi.mocked(getOctokit).mockResolvedValue({
|
||||||
|
request: mockOctokitRequest
|
||||||
|
} as never)
|
||||||
|
vi.mocked(errorMessage).mockReset()
|
||||||
|
vi.mocked(confirmMessage).mockReset()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("uploads pre-base64-encoded content without re-encoding", async () => {
|
||||||
|
mockOctokitRequest.mockResolvedValue({
|
||||||
|
data: { content: { sha: "img-sha" } }
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await make().uploadBinaryFile({
|
||||||
|
base64: "ZGF0YQ==",
|
||||||
|
path: "img/cover.png"
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result).toEqual({ sha: "img-sha", conflict: false })
|
||||||
|
expect(mockOctokitRequest).toHaveBeenCalledWith(
|
||||||
|
"PUT /repos/{owner}/{repo}/contents/{+path}",
|
||||||
|
expect.objectContaining({
|
||||||
|
path: "img/cover.png",
|
||||||
|
content: "ZGF0YQ=="
|
||||||
|
})
|
||||||
|
)
|
||||||
|
expect(confirmMessage).toHaveBeenCalledWith("✅ Image uploaded")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("surfaces a conflict on existing file (422)", async () => {
|
||||||
|
vi.spyOn(console, "warn").mockImplementation(() => {})
|
||||||
|
mockOctokitRequest.mockRejectedValue(
|
||||||
|
Object.assign(new Error("Exists"), { status: 422 })
|
||||||
|
)
|
||||||
|
|
||||||
|
const result = await make().uploadBinaryFile({
|
||||||
|
base64: "Zm9v",
|
||||||
|
path: "img.png"
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result).toEqual({ sha: null, conflict: true })
|
||||||
|
expect(errorMessage).toHaveBeenCalledWith(
|
||||||
|
"⚠ A file already exists at this path on GitHub"
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,7 +1,15 @@
|
|||||||
import { getOctokit } from "@/modules/repo/services/octo"
|
import { getOctokit, runWithAuthRetry } from "@/modules/repo/services/octo"
|
||||||
import { encodeUTF8ToBase64 } from "@/utils/decodeBase64ToUTF8"
|
import { encodeUTF8ToBase64 } from "@/utils/decodeBase64ToUTF8"
|
||||||
import { confirmMessage, errorMessage } from "@/utils/notif"
|
import { confirmMessage, errorMessage } from "@/utils/notif"
|
||||||
|
|
||||||
|
const isConflictStatus = (status: number) => status === 409 || status === 422
|
||||||
|
const isUnauthorizedStatus = (status: number | undefined) => status === 401
|
||||||
|
|
||||||
|
export type FetchShaResult =
|
||||||
|
| { kind: "ok"; sha: string | null }
|
||||||
|
| { kind: "unauthorized" }
|
||||||
|
| { kind: "offline" }
|
||||||
|
|
||||||
export const useGitHubContent = ({
|
export const useGitHubContent = ({
|
||||||
user,
|
user,
|
||||||
repo
|
repo
|
||||||
@@ -9,6 +17,74 @@ export const useGitHubContent = ({
|
|||||||
user: string
|
user: string
|
||||||
repo: string
|
repo: string
|
||||||
}) => {
|
}) => {
|
||||||
|
const fetchLatestSha = async (path: string): Promise<FetchShaResult> => {
|
||||||
|
try {
|
||||||
|
const response = await runWithAuthRetry((octokit) =>
|
||||||
|
octokit.request("GET /repos/{owner}/{repo}/contents/{+path}", {
|
||||||
|
owner: user,
|
||||||
|
repo,
|
||||||
|
path,
|
||||||
|
headers: { "X-GitHub-Api-Version": "2026-03-10" }
|
||||||
|
})
|
||||||
|
)
|
||||||
|
const data = response?.data
|
||||||
|
if (Array.isArray(data) || !data) return { kind: "ok", sha: null }
|
||||||
|
return { kind: "ok", sha: "sha" in data ? data.sha : null }
|
||||||
|
} catch (error) {
|
||||||
|
const status = (error as { status?: number })?.status
|
||||||
|
if (isUnauthorizedStatus(status)) return { kind: "unauthorized" }
|
||||||
|
return { kind: "offline" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const putRaw = async ({
|
||||||
|
contentBase64,
|
||||||
|
path,
|
||||||
|
sha,
|
||||||
|
message,
|
||||||
|
successMessage,
|
||||||
|
conflictMessage,
|
||||||
|
failureMessage
|
||||||
|
}: {
|
||||||
|
contentBase64: string
|
||||||
|
path: string
|
||||||
|
sha?: string
|
||||||
|
message: string
|
||||||
|
successMessage: string
|
||||||
|
conflictMessage: string
|
||||||
|
failureMessage: string
|
||||||
|
}): Promise<{ sha: string | null; conflict: boolean }> => {
|
||||||
|
try {
|
||||||
|
const octokit = await getOctokit()
|
||||||
|
|
||||||
|
const response = await octokit.request(
|
||||||
|
"PUT /repos/{owner}/{repo}/contents/{+path}",
|
||||||
|
{
|
||||||
|
owner: user,
|
||||||
|
repo,
|
||||||
|
path,
|
||||||
|
message,
|
||||||
|
content: contentBase64,
|
||||||
|
sha
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
confirmMessage(successMessage)
|
||||||
|
|
||||||
|
return { sha: response?.data.content?.sha ?? null, conflict: false }
|
||||||
|
} catch (error) {
|
||||||
|
const status = (error as { status?: number })?.status
|
||||||
|
if (status && isConflictStatus(status)) {
|
||||||
|
errorMessage(conflictMessage)
|
||||||
|
console.warn(error)
|
||||||
|
return { sha: null, conflict: true }
|
||||||
|
}
|
||||||
|
errorMessage(failureMessage)
|
||||||
|
console.warn(error)
|
||||||
|
return { sha: null, conflict: false }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const putFile = async ({
|
const putFile = async ({
|
||||||
content,
|
content,
|
||||||
path,
|
path,
|
||||||
@@ -17,37 +93,42 @@ export const useGitHubContent = ({
|
|||||||
content: string
|
content: string
|
||||||
path: string
|
path: string
|
||||||
sha?: string
|
sha?: string
|
||||||
}) => {
|
}): Promise<{ sha: string | null; conflict: boolean }> =>
|
||||||
try {
|
putRaw({
|
||||||
const octokit = await getOctokit()
|
contentBase64: encodeUTF8ToBase64(content),
|
||||||
|
|
||||||
const response = await octokit.request(
|
|
||||||
`PUT /repos/{owner}/{repo}/contents/{path}`,
|
|
||||||
{
|
|
||||||
owner: user,
|
|
||||||
repo,
|
|
||||||
path,
|
path,
|
||||||
|
sha,
|
||||||
message: `Updating ${path} from Remanso`,
|
message: `Updating ${path} from Remanso`,
|
||||||
content: encodeUTF8ToBase64(content),
|
successMessage: "✅ Note saved",
|
||||||
sha
|
conflictMessage: "⚠ Conflict: this note changed on GitHub",
|
||||||
}
|
failureMessage: "❌ Note could not be saved"
|
||||||
)
|
})
|
||||||
|
|
||||||
confirmMessage("✅ Note saved")
|
const uploadBinaryFile = async ({
|
||||||
|
base64,
|
||||||
return response?.data.content?.sha ?? null
|
path
|
||||||
} catch (error) {
|
}: {
|
||||||
errorMessage("❌ Note could not be saved")
|
base64: string
|
||||||
console.warn(error)
|
path: string
|
||||||
}
|
}): Promise<{ sha: string | null; conflict: boolean }> =>
|
||||||
|
putRaw({
|
||||||
return null
|
contentBase64: base64,
|
||||||
}
|
path,
|
||||||
|
message: `Uploading ${path} from Remanso`,
|
||||||
|
successMessage: "✅ Image uploaded",
|
||||||
|
conflictMessage: "⚠ A file already exists at this path on GitHub",
|
||||||
|
failureMessage: "❌ Image could not be uploaded"
|
||||||
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
updateFile: async (props: { content: string; path: string; sha: string }) =>
|
fetchLatestSha,
|
||||||
putFile(props),
|
updateFile: async (props: {
|
||||||
|
content: string
|
||||||
|
path: string
|
||||||
|
sha: string
|
||||||
|
}) => putFile(props),
|
||||||
createFile: async (props: { content: string; path: string }) =>
|
createFile: async (props: { content: string; path: string }) =>
|
||||||
putFile(props)
|
putFile(props),
|
||||||
|
uploadBinaryFile
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
164
src/hooks/useImageUpload.hook.spec.ts
Normal file
164
src/hooks/useImageUpload.hook.spec.ts
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
import { createPinia, setActivePinia } from "pinia"
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
|
||||||
|
|
||||||
|
const uploadBinaryFile = vi.fn()
|
||||||
|
const registerUploadedFile = vi.fn()
|
||||||
|
|
||||||
|
vi.mock("@/hooks/useGitHubContent.hook", () => ({
|
||||||
|
useGitHubContent: () => ({ uploadBinaryFile })
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock("@/modules/repo/store/userRepo.store", () => ({
|
||||||
|
useUserRepoStore: () => ({
|
||||||
|
files: [{ path: "alice/notes/already-here.png", sha: "x" }],
|
||||||
|
registerUploadedFile
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock("@/utils/notif", () => ({
|
||||||
|
errorMessage: vi.fn()
|
||||||
|
}))
|
||||||
|
|
||||||
|
import { errorMessage } from "@/utils/notif"
|
||||||
|
|
||||||
|
import { useImageUpload } from "./useImageUpload.hook"
|
||||||
|
|
||||||
|
const makeFile = (name: string, body = "fake-image-bytes") =>
|
||||||
|
new File([body], name, { type: "image/png" })
|
||||||
|
|
||||||
|
describe("useImageUpload", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
setActivePinia(createPinia())
|
||||||
|
uploadBinaryFile.mockReset()
|
||||||
|
registerUploadedFile.mockReset()
|
||||||
|
vi.mocked(errorMessage).mockReset()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns null and shows an error when notePath is missing", async () => {
|
||||||
|
const { uploadImage } = useImageUpload({
|
||||||
|
user: "alice",
|
||||||
|
repo: "notes",
|
||||||
|
notePath: undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(await uploadImage(makeFile("img.png"))).toBeNull()
|
||||||
|
expect(errorMessage).toHaveBeenCalledWith("❌ Image upload failed")
|
||||||
|
expect(uploadBinaryFile).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("uploads to the note's directory using the note basename and the file extension", async () => {
|
||||||
|
uploadBinaryFile.mockResolvedValue({ sha: "new-sha", conflict: false })
|
||||||
|
|
||||||
|
const { uploadImage } = useImageUpload({
|
||||||
|
user: "alice",
|
||||||
|
repo: "notes",
|
||||||
|
notePath: "alice/notes/my-note.md"
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await uploadImage(makeFile("photo.PNG"))
|
||||||
|
|
||||||
|
expect(result).toEqual({ filename: "my-note.png" })
|
||||||
|
expect(uploadBinaryFile).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
path: "alice/notes/my-note.png"
|
||||||
|
})
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("deduplicates filenames against existing files in the store", async () => {
|
||||||
|
uploadBinaryFile.mockResolvedValue({ sha: "new-sha", conflict: false })
|
||||||
|
|
||||||
|
const { uploadImage } = useImageUpload({
|
||||||
|
user: "alice",
|
||||||
|
repo: "notes",
|
||||||
|
notePath: "alice/notes/already-here.md"
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await uploadImage(makeFile("photo.png"))
|
||||||
|
|
||||||
|
expect(result?.filename).toBe("already-here-2.png")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns null on conflict and does NOT register the file", async () => {
|
||||||
|
uploadBinaryFile.mockResolvedValue({ sha: null, conflict: true })
|
||||||
|
|
||||||
|
const { uploadImage } = useImageUpload({
|
||||||
|
user: "alice",
|
||||||
|
repo: "notes",
|
||||||
|
notePath: "x/note.md"
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(await uploadImage(makeFile("img.png"))).toBeNull()
|
||||||
|
expect(registerUploadedFile).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("registers the uploaded file in the store on success", async () => {
|
||||||
|
uploadBinaryFile.mockResolvedValue({ sha: "abc", conflict: false })
|
||||||
|
const file = makeFile("img.png", "data")
|
||||||
|
|
||||||
|
const { uploadImage } = useImageUpload({
|
||||||
|
user: "alice",
|
||||||
|
repo: "notes",
|
||||||
|
notePath: "x/note.md"
|
||||||
|
})
|
||||||
|
|
||||||
|
await uploadImage(file)
|
||||||
|
|
||||||
|
expect(registerUploadedFile).toHaveBeenCalledWith({
|
||||||
|
path: "x/note.png",
|
||||||
|
sha: "abc",
|
||||||
|
type: "blob",
|
||||||
|
size: file.size
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("falls back to .png when the file name has no extension", async () => {
|
||||||
|
uploadBinaryFile.mockResolvedValue({ sha: "abc", conflict: false })
|
||||||
|
|
||||||
|
const { uploadImage } = useImageUpload({
|
||||||
|
user: "alice",
|
||||||
|
repo: "notes",
|
||||||
|
notePath: "x/note.md"
|
||||||
|
})
|
||||||
|
|
||||||
|
await uploadImage(makeFile("noextension"))
|
||||||
|
|
||||||
|
expect(uploadBinaryFile).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ path: "x/note.png" })
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("uses the root path when the note is at the repo root", async () => {
|
||||||
|
uploadBinaryFile.mockResolvedValue({ sha: "abc", conflict: false })
|
||||||
|
|
||||||
|
const { uploadImage } = useImageUpload({
|
||||||
|
user: "alice",
|
||||||
|
repo: "notes",
|
||||||
|
notePath: "root.md"
|
||||||
|
})
|
||||||
|
|
||||||
|
await uploadImage(makeFile("img.png"))
|
||||||
|
|
||||||
|
expect(uploadBinaryFile).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ path: "root.png" })
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns null and notifies on unexpected errors", async () => {
|
||||||
|
vi.spyOn(console, "warn").mockImplementation(() => {})
|
||||||
|
uploadBinaryFile.mockRejectedValue(new Error("boom"))
|
||||||
|
|
||||||
|
const { uploadImage } = useImageUpload({
|
||||||
|
user: "alice",
|
||||||
|
repo: "notes",
|
||||||
|
notePath: "x/note.md"
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(await uploadImage(makeFile("img.png"))).toBeNull()
|
||||||
|
expect(errorMessage).toHaveBeenCalledWith("❌ Image upload failed")
|
||||||
|
})
|
||||||
|
})
|
||||||
104
src/hooks/useImageUpload.hook.ts
Normal file
104
src/hooks/useImageUpload.hook.ts
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
import { Ref, toValue } from "vue"
|
||||||
|
|
||||||
|
import { useGitHubContent } from "@/hooks/useGitHubContent.hook"
|
||||||
|
import { useUserRepoStore } from "@/modules/repo/store/userRepo.store"
|
||||||
|
import { errorMessage } from "@/utils/notif"
|
||||||
|
import { uniqueFilename } from "@/utils/uniqueFilename"
|
||||||
|
|
||||||
|
const arrayBufferToBase64 = (buffer: ArrayBuffer): string => {
|
||||||
|
const bytes = new Uint8Array(buffer)
|
||||||
|
const chunkSize = 0x8000
|
||||||
|
let binary = ""
|
||||||
|
for (let i = 0; i < bytes.length; i += chunkSize) {
|
||||||
|
const chunk = bytes.subarray(i, i + chunkSize)
|
||||||
|
binary += String.fromCharCode(...chunk)
|
||||||
|
}
|
||||||
|
return btoa(binary)
|
||||||
|
}
|
||||||
|
|
||||||
|
const splitPath = (fullPath: string): { directory: string; filename: string } => {
|
||||||
|
const lastSlash = fullPath.lastIndexOf("/")
|
||||||
|
if (lastSlash === -1) return { directory: "", filename: fullPath }
|
||||||
|
return {
|
||||||
|
directory: fullPath.slice(0, lastSlash),
|
||||||
|
filename: fullPath.slice(lastSlash + 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const stripMarkdownExtension = (filename: string): string =>
|
||||||
|
filename.replace(/\.(md|markdown|mdx)$/i, "")
|
||||||
|
|
||||||
|
const extractExtension = (filename: string): string => {
|
||||||
|
const dot = filename.lastIndexOf(".")
|
||||||
|
if (dot <= 0) return ".png"
|
||||||
|
return filename.slice(dot).toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useImageUpload = ({
|
||||||
|
user,
|
||||||
|
repo,
|
||||||
|
notePath
|
||||||
|
}: {
|
||||||
|
user: string
|
||||||
|
repo: string
|
||||||
|
notePath: Ref<string | undefined> | string | undefined
|
||||||
|
}) => {
|
||||||
|
const store = useUserRepoStore()
|
||||||
|
const { uploadBinaryFile } = useGitHubContent({ user, repo })
|
||||||
|
|
||||||
|
const uploadImage = async (
|
||||||
|
file: File
|
||||||
|
): Promise<{ filename: string } | null> => {
|
||||||
|
const currentNotePath = toValue(notePath)
|
||||||
|
if (!currentNotePath) {
|
||||||
|
errorMessage("❌ Image upload failed")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { directory, filename: noteFilename } = splitPath(currentNotePath)
|
||||||
|
const basename = stripMarkdownExtension(noteFilename)
|
||||||
|
const extension = extractExtension(file.name)
|
||||||
|
|
||||||
|
const existingPaths = store.files
|
||||||
|
.map((f) => f.path)
|
||||||
|
.filter((p): p is string => typeof p === "string")
|
||||||
|
|
||||||
|
const filename = uniqueFilename({
|
||||||
|
basename,
|
||||||
|
extension,
|
||||||
|
existingPaths,
|
||||||
|
directory
|
||||||
|
})
|
||||||
|
|
||||||
|
const targetPath = directory ? `${directory}/${filename}` : filename
|
||||||
|
|
||||||
|
const buffer = await file.arrayBuffer()
|
||||||
|
const base64 = arrayBufferToBase64(buffer)
|
||||||
|
|
||||||
|
const { sha, conflict } = await uploadBinaryFile({
|
||||||
|
base64,
|
||||||
|
path: targetPath
|
||||||
|
})
|
||||||
|
|
||||||
|
if (conflict || !sha) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
store.registerUploadedFile({
|
||||||
|
path: targetPath,
|
||||||
|
sha,
|
||||||
|
type: "blob",
|
||||||
|
size: file.size
|
||||||
|
})
|
||||||
|
|
||||||
|
return { filename }
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("image upload failed", error)
|
||||||
|
errorMessage("❌ Image upload failed")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { uploadImage }
|
||||||
|
}
|
||||||
@@ -11,14 +11,23 @@ export const useLinks = (
|
|||||||
const store = useUserRepoStore()
|
const store = useUserRepoStore()
|
||||||
|
|
||||||
const linkNote: EventListener = (event) => {
|
const linkNote: EventListener = (event) => {
|
||||||
const target = event.target as HTMLElement
|
const anchor = (event.target as HTMLElement).closest("a")
|
||||||
const href = target.getAttribute("href")
|
const href = anchor?.getAttribute("href")
|
||||||
|
|
||||||
if (!href) {
|
if (!href) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (href.startsWith("#")) {
|
if (href.startsWith("#")) {
|
||||||
|
event.preventDefault()
|
||||||
|
const id = href.slice(1)
|
||||||
|
const container = document.querySelector(`.${toValue(className)}`)
|
||||||
|
const heading = container?.querySelector(`#${CSS.escape(id)}`)
|
||||||
|
heading?.scrollIntoView({
|
||||||
|
block: "start",
|
||||||
|
inline: "nearest",
|
||||||
|
behavior: "smooth"
|
||||||
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,8 +39,13 @@ export const useLinks = (
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const hashIndex = href.indexOf("#")
|
||||||
|
const path = hashIndex === -1 ? href : href.slice(0, hashIndex)
|
||||||
|
const hash = hashIndex === -1 ? undefined : href.slice(hashIndex + 1)
|
||||||
|
|
||||||
noteEventBus.emit({
|
noteEventBus.emit({
|
||||||
path: href,
|
path,
|
||||||
|
hash,
|
||||||
currentNoteSHA: toValue(sha),
|
currentNoteSHA: toValue(sha),
|
||||||
user: store.user,
|
user: store.user,
|
||||||
repo: store.repo
|
repo: store.repo
|
||||||
|
|||||||
@@ -1,19 +1,37 @@
|
|||||||
|
import type { MarkdownItTabData, MarkdownItTabInfo } from "@mdit/plugin-tab"
|
||||||
|
import { tab } from "@mdit/plugin-tab"
|
||||||
import markdownItKatex from "@vscode/markdown-it-katex"
|
import markdownItKatex from "@vscode/markdown-it-katex"
|
||||||
|
import GithubSlugger from "github-slugger"
|
||||||
import MarkdownIt, { Options } from "markdown-it"
|
import MarkdownIt, { Options } from "markdown-it"
|
||||||
import Renderer, { type RenderRuleRecord } from "markdown-it/lib/renderer.mjs"
|
import Renderer, { type RenderRuleRecord } from "markdown-it/lib/renderer.mjs"
|
||||||
import type Token from "markdown-it/lib/token.mjs"
|
import type Token from "markdown-it/lib/token.mjs"
|
||||||
|
import markdownItAnchor from "markdown-it-anchor"
|
||||||
import blockEmbedPlugin from "markdown-it-block-embed"
|
import blockEmbedPlugin from "markdown-it-block-embed"
|
||||||
import markdownItCheckbox from "markdown-it-checkbox"
|
import markdownItCheckbox from "markdown-it-checkbox"
|
||||||
import MarkdownItGitHubAlerts from "markdown-it-github-alerts"
|
import MarkdownItGitHubAlerts from "markdown-it-github-alerts"
|
||||||
import markdownItIframe from "markdown-it-iframe"
|
import markdownItIframe from "markdown-it-iframe"
|
||||||
import Shikiji from "markdown-it-shikiji"
|
import Shikiji from "markdown-it-shikiji"
|
||||||
import mermaid from "mermaid"
|
import mermaid from "mermaid"
|
||||||
import { Ref, toValue } from "vue"
|
import type { LanguageRegistration } from "shikiji-core"
|
||||||
|
import { Ref, ref, toValue } from "vue"
|
||||||
|
|
||||||
import { decodeBase64ToUTF8 } from "@/utils/decodeBase64ToUTF8"
|
import { data } from "@/data/data"
|
||||||
|
import { DataType } from "@/data/DataType.enum"
|
||||||
|
import type { TikzCache } from "@/data/models/TikzCache"
|
||||||
|
import alloyGrammar from "@/utils/alloy.tmLanguage.json"
|
||||||
|
import {
|
||||||
|
decodeBase64ToUTF8,
|
||||||
|
encodeUTF8ToBase64
|
||||||
|
} from "@/utils/decodeBase64ToUTF8"
|
||||||
import { html5Media } from "@/utils/markdown/markdown-html5-media"
|
import { html5Media } from "@/utils/markdown/markdown-html5-media"
|
||||||
import { markdownItTablerIcons } from "@/utils/markdown/markdown-it-tabler-icons"
|
import { markdownItTablerIcons } from "@/utils/markdown/markdown-it-tabler-icons"
|
||||||
|
|
||||||
|
const TIKZ_BUNDLE_URL =
|
||||||
|
"https://cdn.jsdelivr.net/gh/artisticat1/obsidian-tikzjax@0.5.2/tikzjax.js"
|
||||||
|
const TIKZ_STYLES_URL =
|
||||||
|
"https://cdn.jsdelivr.net/gh/artisticat1/obsidian-tikzjax@0.5.2/styles.css"
|
||||||
|
const TIKZ_RENDER_TIMEOUT_MS = 30000
|
||||||
|
|
||||||
const markdownItMermaidExtractor = (md: MarkdownIt) => {
|
const markdownItMermaidExtractor = (md: MarkdownIt) => {
|
||||||
const defaultFence =
|
const defaultFence =
|
||||||
md.renderer.rules.fence ||
|
md.renderer.rules.fence ||
|
||||||
@@ -45,11 +63,49 @@ const markdownItMermaidExtractor = (md: MarkdownIt) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const markdownItTikzExtractor = (md: MarkdownIt) => {
|
||||||
|
const defaultFence =
|
||||||
|
md.renderer.rules.fence ||
|
||||||
|
function (
|
||||||
|
tokens: Array<Token>,
|
||||||
|
index: number,
|
||||||
|
options: Options,
|
||||||
|
_: unknown,
|
||||||
|
self: Renderer
|
||||||
|
) {
|
||||||
|
return self.renderToken(tokens, index, options)
|
||||||
|
}
|
||||||
|
|
||||||
|
md.renderer.rules.fence = function (
|
||||||
|
tokens: Array<Token>,
|
||||||
|
index: number,
|
||||||
|
options: Options,
|
||||||
|
env: unknown,
|
||||||
|
self: Renderer
|
||||||
|
) {
|
||||||
|
const token = tokens[index]
|
||||||
|
|
||||||
|
if (token.info.trim() === "tikz") {
|
||||||
|
const encoded = encodeUTF8ToBase64(token.content)
|
||||||
|
return `<pre class="tikz" data-tikz-source="${encoded}"><span class="tikz-loading">Rendering TikZ…</span></pre>\n`
|
||||||
|
}
|
||||||
|
|
||||||
|
return defaultFence(tokens, index, options, env, self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const slugger = new GithubSlugger()
|
||||||
|
|
||||||
|
let tabGroupCounter = 0
|
||||||
|
let currentTabGroup = 0
|
||||||
|
let currentTabActiveSet = false
|
||||||
|
|
||||||
const md = new MarkdownIt({
|
const md = new MarkdownIt({
|
||||||
typographer: true,
|
typographer: true,
|
||||||
quotes: ["«\xA0", "\xA0»", "‹\xA0", "\xA0›"]
|
quotes: ["«\xA0", "\xA0»", "‹\xA0", "\xA0›"]
|
||||||
})
|
})
|
||||||
.use(markdownItMermaidExtractor)
|
.use(markdownItMermaidExtractor)
|
||||||
|
.use(markdownItTikzExtractor)
|
||||||
.use(html5Media)
|
.use(html5Media)
|
||||||
.use(blockEmbedPlugin, {
|
.use(blockEmbedPlugin, {
|
||||||
youtube: {
|
youtube: {
|
||||||
@@ -64,17 +120,33 @@ const md = new MarkdownIt({
|
|||||||
})
|
})
|
||||||
.use(MarkdownItGitHubAlerts)
|
.use(MarkdownItGitHubAlerts)
|
||||||
.use(markdownItTablerIcons)
|
.use(markdownItTablerIcons)
|
||||||
|
.use(tab, {
|
||||||
|
name: "tabs",
|
||||||
|
openRender: (info: MarkdownItTabInfo) => {
|
||||||
|
currentTabGroup = ++tabGroupCounter
|
||||||
|
currentTabActiveSet = info.active >= 0
|
||||||
|
return '<div class="tabs tabs-box">\n'
|
||||||
|
},
|
||||||
|
closeRender: () => "</div>\n",
|
||||||
|
tabOpenRender: (data: MarkdownItTabData) => {
|
||||||
|
const isChecked =
|
||||||
|
data.isActive || (!currentTabActiveSet && data.index === 0)
|
||||||
|
const checked = isChecked ? " checked" : ""
|
||||||
|
const title = data.title.replace(/"/g, """)
|
||||||
|
return `<input type="radio" name="md-tabs-${currentTabGroup}" class="tab" aria-label="${title}"${checked}>\n<div class="tab-content bg-base-100 border-base-300 rounded-box p-2">\n`
|
||||||
|
},
|
||||||
|
tabCloseRender: () => "</div>\n"
|
||||||
|
})
|
||||||
|
.use(markdownItAnchor, {
|
||||||
|
slugify: (s: string) => slugger.slug(s)
|
||||||
|
})
|
||||||
|
|
||||||
let shikijiInitialized = false
|
let shikijiPromise: Promise<void> | null = null
|
||||||
|
const shikijiReady = ref(false)
|
||||||
|
|
||||||
export const useShikiji = async () => {
|
export const useShikiji = (): Promise<void> => {
|
||||||
if (shikijiInitialized) {
|
if (!shikijiPromise) {
|
||||||
return
|
shikijiPromise = Shikiji({
|
||||||
}
|
|
||||||
|
|
||||||
shikijiInitialized = true
|
|
||||||
md.use(
|
|
||||||
await Shikiji({
|
|
||||||
themes: {
|
themes: {
|
||||||
light: "vitesse-light",
|
light: "vitesse-light",
|
||||||
dark: "vitesse-black"
|
dark: "vitesse-black"
|
||||||
@@ -83,19 +155,29 @@ export const useShikiji = async () => {
|
|||||||
"bash",
|
"bash",
|
||||||
"javascript",
|
"javascript",
|
||||||
"typescript",
|
"typescript",
|
||||||
|
"tsx",
|
||||||
"markdown",
|
"markdown",
|
||||||
"mermaid",
|
"mermaid",
|
||||||
"html",
|
"html",
|
||||||
"css",
|
"css",
|
||||||
"json"
|
"json",
|
||||||
|
{
|
||||||
|
...alloyGrammar,
|
||||||
|
name: "alloy",
|
||||||
|
aliases: ["als"]
|
||||||
|
} as unknown as LanguageRegistration
|
||||||
]
|
]
|
||||||
|
}).then((plugin) => {
|
||||||
|
md.use(plugin)
|
||||||
|
shikijiReady.value = true
|
||||||
})
|
})
|
||||||
)
|
}
|
||||||
|
return shikijiPromise
|
||||||
}
|
}
|
||||||
|
|
||||||
let mermaidInitialized = false
|
let mermaidInitialized = false
|
||||||
|
|
||||||
export const runMermaid = (querySelector: string) => {
|
export const runMermaid = (querySelector: string): Promise<void> => {
|
||||||
if (!mermaidInitialized) {
|
if (!mermaidInitialized) {
|
||||||
mermaidInitialized = true
|
mermaidInitialized = true
|
||||||
mermaid.initialize({
|
mermaid.initialize({
|
||||||
@@ -105,11 +187,198 @@ export const runMermaid = (querySelector: string) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
mermaid.run({
|
return mermaid.run({
|
||||||
querySelector
|
querySelector
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let tikzBundlePromise: Promise<void> | null = null
|
||||||
|
let tikzStylesPromise: Promise<void> | null = null
|
||||||
|
let domPurifyPromise: Promise<typeof import("dompurify")> | null = null
|
||||||
|
|
||||||
|
const ensureTikzBundle = (): Promise<void> => {
|
||||||
|
if (tikzBundlePromise) return tikzBundlePromise
|
||||||
|
|
||||||
|
tikzBundlePromise = new Promise<void>((resolve, reject) => {
|
||||||
|
const existing = document.getElementById("tikzjax-bundle")
|
||||||
|
if (existing) {
|
||||||
|
resolve()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const script = document.createElement("script")
|
||||||
|
script.id = "tikzjax-bundle"
|
||||||
|
script.src = TIKZ_BUNDLE_URL
|
||||||
|
script.async = true
|
||||||
|
script.crossOrigin = "anonymous"
|
||||||
|
script.addEventListener("load", () => resolve())
|
||||||
|
script.addEventListener("error", () => {
|
||||||
|
tikzBundlePromise = null
|
||||||
|
reject(new Error("Failed to load TikZ engine"))
|
||||||
|
})
|
||||||
|
document.head.appendChild(script)
|
||||||
|
})
|
||||||
|
|
||||||
|
return tikzBundlePromise
|
||||||
|
}
|
||||||
|
|
||||||
|
const ensureTikzStyles = (): Promise<void> => {
|
||||||
|
if (tikzStylesPromise) return tikzStylesPromise
|
||||||
|
|
||||||
|
tikzStylesPromise = new Promise<void>((resolve, reject) => {
|
||||||
|
const existing = document.getElementById("tikzjax-styles")
|
||||||
|
if (existing) {
|
||||||
|
resolve()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const link = document.createElement("link")
|
||||||
|
link.id = "tikzjax-styles"
|
||||||
|
link.rel = "stylesheet"
|
||||||
|
link.href = TIKZ_STYLES_URL
|
||||||
|
link.crossOrigin = "anonymous"
|
||||||
|
link.addEventListener("load", () => resolve())
|
||||||
|
link.addEventListener("error", () => {
|
||||||
|
tikzStylesPromise = null
|
||||||
|
reject(new Error("Failed to load TikZ font styles"))
|
||||||
|
})
|
||||||
|
document.head.appendChild(link)
|
||||||
|
})
|
||||||
|
|
||||||
|
return tikzStylesPromise
|
||||||
|
}
|
||||||
|
|
||||||
|
const ensureDomPurify = (): Promise<typeof import("dompurify")> => {
|
||||||
|
if (!domPurifyPromise) domPurifyPromise = import("dompurify")
|
||||||
|
return domPurifyPromise
|
||||||
|
}
|
||||||
|
|
||||||
|
const sha256Hex = async (text: string): Promise<string> => {
|
||||||
|
const buf = await crypto.subtle.digest(
|
||||||
|
"SHA-256",
|
||||||
|
new TextEncoder().encode(text)
|
||||||
|
)
|
||||||
|
return Array.from(new Uint8Array(buf))
|
||||||
|
.map((b) => b.toString(16).padStart(2, "0"))
|
||||||
|
.join("")
|
||||||
|
}
|
||||||
|
|
||||||
|
const sanitizeSvg = async (svg: string): Promise<string> => {
|
||||||
|
const DOMPurify = (await ensureDomPurify()).default
|
||||||
|
return DOMPurify.sanitize(svg, {
|
||||||
|
USE_PROFILES: { svg: true, svgFilters: true }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const tidyTikzSource = (s: string): string =>
|
||||||
|
s
|
||||||
|
.replaceAll(" ", "")
|
||||||
|
.split("\n")
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.filter((line) => line)
|
||||||
|
.join("\n")
|
||||||
|
|
||||||
|
const renderTikzError = (err: unknown, source: string): string => {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err)
|
||||||
|
return (
|
||||||
|
`<div class="tikz-error"><strong>TikZ error</strong>` +
|
||||||
|
`<pre>${md.utils.escapeHtml(msg)}</pre>` +
|
||||||
|
`<details><summary>source</summary>` +
|
||||||
|
`<pre>${md.utils.escapeHtml(source)}</pre></details></div>`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderOneTikzBlock = (
|
||||||
|
el: HTMLElement,
|
||||||
|
source: string
|
||||||
|
): Promise<string> => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
let settled = false
|
||||||
|
const timer = window.setTimeout(() => {
|
||||||
|
if (settled) return
|
||||||
|
settled = true
|
||||||
|
el.removeEventListener("tikzjax-load-finished", handler)
|
||||||
|
reject(new Error("TikZ render timed out"))
|
||||||
|
}, TIKZ_RENDER_TIMEOUT_MS)
|
||||||
|
|
||||||
|
const handler = (e: Event) => {
|
||||||
|
if (settled) return
|
||||||
|
settled = true
|
||||||
|
window.clearTimeout(timer)
|
||||||
|
el.removeEventListener("tikzjax-load-finished", handler)
|
||||||
|
const target = e.target as Element | null
|
||||||
|
if (!target) {
|
||||||
|
reject(new Error("TikZ produced no output"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resolve(target.outerHTML)
|
||||||
|
}
|
||||||
|
|
||||||
|
el.addEventListener("tikzjax-load-finished", handler)
|
||||||
|
|
||||||
|
while (el.firstChild) el.removeChild(el.firstChild)
|
||||||
|
const script = document.createElement("script")
|
||||||
|
script.type = "text/tikz"
|
||||||
|
script.textContent = source
|
||||||
|
el.appendChild(script)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const runTikz = async (querySelector: string): Promise<void> => {
|
||||||
|
const elements = Array.from(
|
||||||
|
document.querySelectorAll<HTMLElement>(querySelector)
|
||||||
|
)
|
||||||
|
if (elements.length === 0) return
|
||||||
|
|
||||||
|
void ensureTikzStyles().catch(() => undefined)
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
elements.map(async (el) => {
|
||||||
|
if (el.dataset.tikzRendered) return
|
||||||
|
el.dataset.tikzRendered = "pending"
|
||||||
|
|
||||||
|
const encoded = el.dataset.tikzSource
|
||||||
|
if (!encoded) {
|
||||||
|
el.dataset.tikzRendered = "error"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const source = tidyTikzSource(decodeBase64ToUTF8(encoded))
|
||||||
|
|
||||||
|
let hash: string
|
||||||
|
try {
|
||||||
|
hash = await sha256Hex(source)
|
||||||
|
} catch (err) {
|
||||||
|
el.innerHTML = renderTikzError(err, source)
|
||||||
|
el.dataset.tikzRendered = "error"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const cacheId = `${DataType.TikzCache}-${hash}`
|
||||||
|
const cached = await data.get<DataType.TikzCache, TikzCache>(cacheId)
|
||||||
|
if (cached?.svg) {
|
||||||
|
el.innerHTML = cached.svg
|
||||||
|
el.dataset.tikzRendered = "true"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await ensureTikzBundle()
|
||||||
|
const rawSvg = await renderOneTikzBlock(el, source)
|
||||||
|
const sanitized = await sanitizeSvg(rawSvg)
|
||||||
|
el.innerHTML = sanitized
|
||||||
|
el.dataset.tikzRendered = "true"
|
||||||
|
void data.add<DataType.TikzCache>({
|
||||||
|
_id: cacheId,
|
||||||
|
$type: DataType.TikzCache,
|
||||||
|
svg: sanitized
|
||||||
|
} as TikzCache)
|
||||||
|
} catch (err) {
|
||||||
|
el.innerHTML = renderTikzError(err, source)
|
||||||
|
el.dataset.tikzRendered = "error"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const rules: RenderRuleRecord = {
|
const rules: RenderRuleRecord = {
|
||||||
table_open: () =>
|
table_open: () =>
|
||||||
'<div class="overflow-x-auto"><table class="table table-zebra">',
|
'<div class="overflow-x-auto"><table class="table table-zebra">',
|
||||||
@@ -123,11 +392,38 @@ const stripFrontmatter = (content: string): string => {
|
|||||||
return match ? content.slice(match[0].length) : content
|
return match ? content.slice(match[0].length) : content
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const renderMarkdown = (content: string, env?: Record<string, unknown>) => {
|
||||||
|
// Track Shikiji readiness so reactive consumers (e.g. the `content`
|
||||||
|
// computed in useFile) re-render once `useShikiji()` mutates the
|
||||||
|
// singleton `md` — otherwise the first render on page load stays
|
||||||
|
// unhighlighted because nothing else invalidates the computed.
|
||||||
|
if (content.includes("```")) void shikijiReady.value
|
||||||
|
slugger.reset()
|
||||||
|
return env ? md.render(content, env) : md.render(content)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const renderCodeFile = async ({
|
||||||
|
rawContent,
|
||||||
|
lang,
|
||||||
|
filename
|
||||||
|
}: {
|
||||||
|
rawContent: string
|
||||||
|
lang: string | null
|
||||||
|
filename?: string
|
||||||
|
}): Promise<string> => {
|
||||||
|
await useShikiji()
|
||||||
|
const heading = filename ? `# ${filename}\n\n` : ""
|
||||||
|
if (lang !== null) {
|
||||||
|
return renderMarkdown(`${heading}\`\`\`\`${lang}\n${rawContent}\n\`\`\`\``)
|
||||||
|
}
|
||||||
|
return `${renderMarkdown(heading)}<pre><code>${md.utils.escapeHtml(rawContent)}</code></pre>`
|
||||||
|
}
|
||||||
|
|
||||||
export const markdownBuilder = (defaultPrefix?: Ref<string> | string) => {
|
export const markdownBuilder = (defaultPrefix?: Ref<string> | string) => {
|
||||||
const getRawContent = (content: string) => decodeBase64ToUTF8(content)
|
const getRawContent = (content: string) => decodeBase64ToUTF8(content)
|
||||||
const renderFromUTF8 = (content: string, prefix?: string) => {
|
const renderFromUTF8 = (content: string, prefix?: string) => {
|
||||||
return content
|
return content
|
||||||
? md.render(stripFrontmatter(content), {
|
? renderMarkdown(stripFrontmatter(content), {
|
||||||
docId: defaultPrefix ? toValue(defaultPrefix) : (prefix ?? "")
|
docId: defaultPrefix ? toValue(defaultPrefix) : (prefix ?? "")
|
||||||
})
|
})
|
||||||
: ""
|
: ""
|
||||||
@@ -135,7 +431,7 @@ export const markdownBuilder = (defaultPrefix?: Ref<string> | string) => {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
toHTML: (content: string) =>
|
toHTML: (content: string) =>
|
||||||
content ? md.render(stripFrontmatter(content)) : "",
|
content ? renderMarkdown(stripFrontmatter(content)) : "",
|
||||||
render: (content: string, prefix?: string) =>
|
render: (content: string, prefix?: string) =>
|
||||||
renderFromUTF8(decodeBase64ToUTF8(content), prefix),
|
renderFromUTF8(decodeBase64ToUTF8(content), prefix),
|
||||||
renderFromUTF8,
|
renderFromUTF8,
|
||||||
|
|||||||
64
src/hooks/useMarkdownPostRender.hook.ts
Normal file
64
src/hooks/useMarkdownPostRender.hook.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import { nextTick, type Ref, toValue, watch } from "vue"
|
||||||
|
|
||||||
|
import { useImages } from "@/hooks/useImages.hook"
|
||||||
|
import {
|
||||||
|
runMermaid,
|
||||||
|
runTikz,
|
||||||
|
useShikiji
|
||||||
|
} from "@/hooks/useMarkdown.hook"
|
||||||
|
import { attachSvgDownloads } from "@/utils/svgDownload"
|
||||||
|
|
||||||
|
interface MarkdownPostRenderOptions {
|
||||||
|
onReady?: () => void
|
||||||
|
tikz?: boolean
|
||||||
|
mermaid?: () => boolean
|
||||||
|
shikiji?: () => boolean
|
||||||
|
images?: () => string | null | undefined
|
||||||
|
triggers?: Ref<unknown>[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useMarkdownPostRender = (
|
||||||
|
contentRef: Ref<unknown>,
|
||||||
|
scopeSelector: () => string,
|
||||||
|
options: MarkdownPostRenderOptions = {}
|
||||||
|
) => {
|
||||||
|
const sources = [contentRef, ...(options.triggers ?? [])]
|
||||||
|
|
||||||
|
watch(
|
||||||
|
sources,
|
||||||
|
async () => {
|
||||||
|
if (!toValue(contentRef)) return
|
||||||
|
await nextTick()
|
||||||
|
options.onReady?.()
|
||||||
|
|
||||||
|
const scope = scopeSelector()
|
||||||
|
const wantsTikz = !!options.tikz
|
||||||
|
const wantsMermaid = !!options.mermaid?.()
|
||||||
|
|
||||||
|
const renderJobs: Promise<unknown>[] = []
|
||||||
|
if (wantsTikz) {
|
||||||
|
renderJobs.push(runTikz(`${scope} .tikz`))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (wantsMermaid) {
|
||||||
|
renderJobs.push(runMermaid(`${scope} .mermaid`))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.shikiji?.()) {
|
||||||
|
void useShikiji()
|
||||||
|
}
|
||||||
|
|
||||||
|
const imagesSha = options.images?.()
|
||||||
|
if (imagesSha) {
|
||||||
|
useImages(imagesSha)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (wantsTikz || wantsMermaid) {
|
||||||
|
await Promise.allSettled(renderJobs)
|
||||||
|
await nextTick()
|
||||||
|
attachSvgDownloads(document.querySelector(scope))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
)
|
||||||
|
}
|
||||||
234
src/hooks/useNoteFreshness.hook.spec.ts
Normal file
234
src/hooks/useNoteFreshness.hook.spec.ts
Normal file
@@ -0,0 +1,234 @@
|
|||||||
|
import { createPinia, setActivePinia } from "pinia"
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
|
||||||
|
import { ref } from "vue"
|
||||||
|
|
||||||
|
const {
|
||||||
|
fetchLatestSha,
|
||||||
|
addFile,
|
||||||
|
getRawContent,
|
||||||
|
saveCacheNote,
|
||||||
|
queryFileContent
|
||||||
|
} = vi.hoisted(() => ({
|
||||||
|
fetchLatestSha: vi.fn(),
|
||||||
|
addFile: vi.fn(),
|
||||||
|
getRawContent: vi.fn((s: string) => s),
|
||||||
|
saveCacheNote: vi.fn(),
|
||||||
|
queryFileContent: vi.fn()
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock("@/hooks/useGitHubContent.hook", () => ({
|
||||||
|
useGitHubContent: () => ({ fetchLatestSha })
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock("@/hooks/useMarkdown.hook", () => ({
|
||||||
|
markdownBuilder: () => ({ getRawContent, render: (s: string) => s })
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock("@/modules/note/cache/prepareNoteCache", () => ({
|
||||||
|
prepareNoteCache: () => ({
|
||||||
|
getCachedNote: async () => ({ note: null }),
|
||||||
|
saveCacheNote
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock("@/modules/repo/services/repo", () => ({
|
||||||
|
queryFileContent
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock("@/modules/repo/store/userRepo.store", () => ({
|
||||||
|
useUserRepoStore: () => ({ addFile })
|
||||||
|
}))
|
||||||
|
|
||||||
|
import { useNoteFreshness } from "./useNoteFreshness.hook"
|
||||||
|
|
||||||
|
const setup = (overrides: { sha?: string; path?: string; edited?: string | null } = {}) => {
|
||||||
|
setActivePinia(createPinia())
|
||||||
|
const sha = ref(overrides.sha ?? "local-sha")
|
||||||
|
const path = ref(overrides.path ?? "note.md")
|
||||||
|
const getEditedSha = vi.fn().mockResolvedValue(overrides.edited ?? null)
|
||||||
|
return useNoteFreshness({
|
||||||
|
user: "alice",
|
||||||
|
repo: "notes",
|
||||||
|
sha,
|
||||||
|
path,
|
||||||
|
getEditedSha
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make performance.now() return values where elapsed > MIN_SPINNER_MS so the
|
||||||
|
// 400ms spinner-min-time wait is skipped.
|
||||||
|
const skipSpinnerWait = () => {
|
||||||
|
let call = 0
|
||||||
|
vi.spyOn(performance, "now").mockImplementation(() =>
|
||||||
|
call++ === 0 ? 0 : 10_000
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("useNoteFreshness.check", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
fetchLatestSha.mockReset()
|
||||||
|
addFile.mockReset()
|
||||||
|
saveCacheNote.mockReset()
|
||||||
|
queryFileContent.mockReset()
|
||||||
|
skipSpinnerWait()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("does nothing when path is empty", async () => {
|
||||||
|
const { check, status } = setup({ path: "" })
|
||||||
|
|
||||||
|
await check()
|
||||||
|
|
||||||
|
expect(status.value).toBe("unknown")
|
||||||
|
expect(fetchLatestSha).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("sets status to 'verified' when remote sha matches local sha", async () => {
|
||||||
|
fetchLatestSha.mockResolvedValue({ kind: "ok", sha: "local-sha" })
|
||||||
|
|
||||||
|
const { check, status, latestSha, lastCheckedAt } = setup()
|
||||||
|
await check()
|
||||||
|
|
||||||
|
expect(status.value).toBe("verified")
|
||||||
|
expect(latestSha.value).toBe("local-sha")
|
||||||
|
expect(lastCheckedAt.value).toBeInstanceOf(Date)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("sets status to 'outdated' when remote sha differs from local sha", async () => {
|
||||||
|
fetchLatestSha.mockResolvedValue({ kind: "ok", sha: "remote-sha" })
|
||||||
|
|
||||||
|
const { check, status } = setup()
|
||||||
|
await check()
|
||||||
|
|
||||||
|
expect(status.value).toBe("outdated")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("prefers the edited sha over the live sha when comparing", async () => {
|
||||||
|
fetchLatestSha.mockResolvedValue({ kind: "ok", sha: "edited-sha" })
|
||||||
|
|
||||||
|
const { check, status } = setup({ sha: "live-sha", edited: "edited-sha" })
|
||||||
|
await check()
|
||||||
|
|
||||||
|
expect(status.value).toBe("verified")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("sets status to 'unauthorized' on auth failure", async () => {
|
||||||
|
fetchLatestSha.mockResolvedValue({ kind: "unauthorized" })
|
||||||
|
|
||||||
|
const { check, status } = setup()
|
||||||
|
await check()
|
||||||
|
|
||||||
|
expect(status.value).toBe("unauthorized")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("sets status to 'offline' on network failure", async () => {
|
||||||
|
fetchLatestSha.mockResolvedValue({ kind: "offline" })
|
||||||
|
|
||||||
|
const { check, status } = setup()
|
||||||
|
await check()
|
||||||
|
|
||||||
|
expect(status.value).toBe("offline")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("sets status to 'offline' when remote returns sha=null (e.g. directory)", async () => {
|
||||||
|
fetchLatestSha.mockResolvedValue({ kind: "ok", sha: null })
|
||||||
|
|
||||||
|
const { check, status } = setup()
|
||||||
|
await check()
|
||||||
|
|
||||||
|
expect(status.value).toBe("offline")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("useNoteFreshness.pullLatest", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
fetchLatestSha.mockReset()
|
||||||
|
addFile.mockReset()
|
||||||
|
saveCacheNote.mockReset()
|
||||||
|
queryFileContent.mockReset()
|
||||||
|
getRawContent.mockClear()
|
||||||
|
skipSpinnerWait()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns raw=null with no failureStatus when path is empty", async () => {
|
||||||
|
const { pullLatest } = setup({ path: "" })
|
||||||
|
|
||||||
|
expect(await pullLatest()).toEqual({ raw: null, failureStatus: null })
|
||||||
|
expect(fetchLatestSha).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns the fetched content and updates state on success", async () => {
|
||||||
|
fetchLatestSha.mockResolvedValue({ kind: "ok", sha: "remote-sha" })
|
||||||
|
queryFileContent.mockResolvedValue("BASE64")
|
||||||
|
getRawContent.mockReturnValue("# raw")
|
||||||
|
|
||||||
|
const { pullLatest, status, latestSha, lastCheckedAt } = setup()
|
||||||
|
const result = await pullLatest()
|
||||||
|
|
||||||
|
expect(result).toEqual({ raw: "# raw", failureStatus: null })
|
||||||
|
expect(status.value).toBe("verified")
|
||||||
|
expect(latestSha.value).toBe("remote-sha")
|
||||||
|
expect(lastCheckedAt.value).toBeInstanceOf(Date)
|
||||||
|
expect(addFile).toHaveBeenCalledWith({ path: "note.md", sha: "remote-sha" })
|
||||||
|
expect(saveCacheNote).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("surfaces 'unauthorized' failureStatus when remote resolve is unauthorized", async () => {
|
||||||
|
vi.spyOn(console, "warn").mockImplementation(() => {})
|
||||||
|
fetchLatestSha.mockResolvedValue({ kind: "unauthorized" })
|
||||||
|
|
||||||
|
const { pullLatest, status } = setup()
|
||||||
|
expect(await pullLatest()).toEqual({
|
||||||
|
raw: null,
|
||||||
|
failureStatus: "unauthorized"
|
||||||
|
})
|
||||||
|
expect(status.value).toBe("unauthorized")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("surfaces 'offline' failureStatus when remote resolve is offline", async () => {
|
||||||
|
vi.spyOn(console, "warn").mockImplementation(() => {})
|
||||||
|
fetchLatestSha.mockResolvedValue({ kind: "offline" })
|
||||||
|
|
||||||
|
const { pullLatest, status } = setup()
|
||||||
|
expect(await pullLatest()).toEqual({
|
||||||
|
raw: null,
|
||||||
|
failureStatus: "offline"
|
||||||
|
})
|
||||||
|
expect(status.value).toBe("offline")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("surfaces 'offline' failureStatus when blob content fetch fails", async () => {
|
||||||
|
vi.spyOn(console, "warn").mockImplementation(() => {})
|
||||||
|
fetchLatestSha.mockResolvedValue({ kind: "ok", sha: "remote-sha" })
|
||||||
|
queryFileContent.mockResolvedValue(null)
|
||||||
|
|
||||||
|
const { pullLatest, status } = setup()
|
||||||
|
expect(await pullLatest()).toEqual({
|
||||||
|
raw: null,
|
||||||
|
failureStatus: "offline"
|
||||||
|
})
|
||||||
|
expect(status.value).toBe("offline")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("uses the cached latestSha to skip a redundant fetchLatestSha call", async () => {
|
||||||
|
fetchLatestSha.mockResolvedValueOnce({ kind: "ok", sha: "cached-sha" })
|
||||||
|
queryFileContent.mockResolvedValue("BASE64")
|
||||||
|
|
||||||
|
const api = setup()
|
||||||
|
await api.check() // populates latestSha
|
||||||
|
expect(fetchLatestSha).toHaveBeenCalledTimes(1)
|
||||||
|
|
||||||
|
fetchLatestSha.mockClear()
|
||||||
|
await api.pullLatest()
|
||||||
|
|
||||||
|
expect(fetchLatestSha).not.toHaveBeenCalled()
|
||||||
|
expect(queryFileContent).toHaveBeenCalledWith("alice", "notes", "cached-sha")
|
||||||
|
})
|
||||||
|
})
|
||||||
124
src/hooks/useNoteFreshness.hook.ts
Normal file
124
src/hooks/useNoteFreshness.hook.ts
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
import { Ref, ref } from "vue"
|
||||||
|
|
||||||
|
import { useGitHubContent } from "@/hooks/useGitHubContent.hook"
|
||||||
|
import { markdownBuilder } from "@/hooks/useMarkdown.hook"
|
||||||
|
import { prepareNoteCache } from "@/modules/note/cache/prepareNoteCache"
|
||||||
|
import { queryFileContent } from "@/modules/repo/services/repo"
|
||||||
|
import { useUserRepoStore } from "@/modules/repo/store/userRepo.store"
|
||||||
|
|
||||||
|
export type FreshnessStatus =
|
||||||
|
| "unknown"
|
||||||
|
| "checking"
|
||||||
|
| "verified"
|
||||||
|
| "outdated"
|
||||||
|
| "offline"
|
||||||
|
| "unauthorized"
|
||||||
|
|
||||||
|
const MIN_SPINNER_MS = 400
|
||||||
|
|
||||||
|
export const useNoteFreshness = ({
|
||||||
|
user,
|
||||||
|
repo,
|
||||||
|
sha,
|
||||||
|
path,
|
||||||
|
getEditedSha
|
||||||
|
}: {
|
||||||
|
user: string
|
||||||
|
repo: string
|
||||||
|
sha: Ref<string>
|
||||||
|
path: Ref<string | undefined>
|
||||||
|
getEditedSha: () => Promise<string | null>
|
||||||
|
}) => {
|
||||||
|
const store = useUserRepoStore()
|
||||||
|
const { fetchLatestSha } = useGitHubContent({ user, repo })
|
||||||
|
|
||||||
|
const status = ref<FreshnessStatus>("unknown")
|
||||||
|
const lastCheckedAt = ref<Date | null>(null)
|
||||||
|
const latestSha = ref<string | null>(null)
|
||||||
|
|
||||||
|
const expectedSha = async () => (await getEditedSha()) ?? sha.value
|
||||||
|
|
||||||
|
const check = async () => {
|
||||||
|
if (!path.value) return
|
||||||
|
status.value = "checking"
|
||||||
|
const startedAt = performance.now()
|
||||||
|
|
||||||
|
let next: FreshnessStatus
|
||||||
|
const result = await fetchLatestSha(path.value)
|
||||||
|
if (result.kind === "unauthorized") {
|
||||||
|
next = "unauthorized"
|
||||||
|
} else if (result.kind === "offline" || result.sha === null) {
|
||||||
|
next = "offline"
|
||||||
|
} else {
|
||||||
|
latestSha.value = result.sha
|
||||||
|
lastCheckedAt.value = new Date()
|
||||||
|
const local = await expectedSha()
|
||||||
|
next = result.sha === local ? "verified" : "outdated"
|
||||||
|
}
|
||||||
|
|
||||||
|
const elapsed = performance.now() - startedAt
|
||||||
|
if (elapsed < MIN_SPINNER_MS) {
|
||||||
|
await new Promise((r) => setTimeout(r, MIN_SPINNER_MS - elapsed))
|
||||||
|
}
|
||||||
|
status.value = next
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolveRemoteSha = async (
|
||||||
|
path: string
|
||||||
|
): Promise<{ sha: string | null; failureStatus: FreshnessStatus | null }> => {
|
||||||
|
if (latestSha.value) return { sha: latestSha.value, failureStatus: null }
|
||||||
|
const result = await fetchLatestSha(path)
|
||||||
|
if (result.kind === "unauthorized") {
|
||||||
|
return { sha: null, failureStatus: "unauthorized" }
|
||||||
|
}
|
||||||
|
if (result.kind === "offline" || result.sha === null) {
|
||||||
|
return { sha: null, failureStatus: "offline" }
|
||||||
|
}
|
||||||
|
return { sha: result.sha, failureStatus: null }
|
||||||
|
}
|
||||||
|
|
||||||
|
const pullLatest = async (): Promise<{
|
||||||
|
raw: string | null
|
||||||
|
failureStatus: FreshnessStatus | null
|
||||||
|
}> => {
|
||||||
|
if (!path.value) return { raw: null, failureStatus: null }
|
||||||
|
const usedCachedSha = latestSha.value !== null
|
||||||
|
const { sha: remoteSha, failureStatus } = await resolveRemoteSha(path.value)
|
||||||
|
if (!remoteSha) {
|
||||||
|
console.warn("pullLatest: could not resolve remote sha", { path: path.value })
|
||||||
|
if (failureStatus) status.value = failureStatus
|
||||||
|
return { raw: null, failureStatus }
|
||||||
|
}
|
||||||
|
const fileContent = await queryFileContent(user, repo, remoteSha)
|
||||||
|
if (!fileContent) {
|
||||||
|
console.warn("pullLatest: failed to fetch blob content", {
|
||||||
|
path: path.value,
|
||||||
|
remoteSha,
|
||||||
|
usedCachedSha
|
||||||
|
})
|
||||||
|
// Cached SHA may be stale — clear so the next click re-resolves it.
|
||||||
|
if (usedCachedSha) latestSha.value = null
|
||||||
|
status.value = "offline"
|
||||||
|
return { raw: null, failureStatus: "offline" }
|
||||||
|
}
|
||||||
|
const { saveCacheNote } = prepareNoteCache(sha.value, path.value)
|
||||||
|
await saveCacheNote(fileContent, {
|
||||||
|
editedSha: remoteSha,
|
||||||
|
path: path.value
|
||||||
|
})
|
||||||
|
store.addFile({ path: path.value, sha: remoteSha })
|
||||||
|
latestSha.value = remoteSha
|
||||||
|
lastCheckedAt.value = new Date()
|
||||||
|
status.value = "verified"
|
||||||
|
const { getRawContent } = markdownBuilder(sha.value)
|
||||||
|
return { raw: getRawContent(fileContent), failureStatus: null }
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
status,
|
||||||
|
lastCheckedAt,
|
||||||
|
latestSha,
|
||||||
|
check,
|
||||||
|
pullLatest
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,7 +25,7 @@ export const useNoteView = () => {
|
|||||||
)
|
)
|
||||||
|
|
||||||
const unsubscribeLink = noteEventBus.addEventBusListener(
|
const unsubscribeLink = noteEventBus.addEventBusListener(
|
||||||
({ path, currentNoteSHA }) => {
|
({ path, hash, currentNoteSHA }) => {
|
||||||
const currentFile = store.files.find(
|
const currentFile = store.files.find(
|
||||||
(file) => file.sha === currentNoteSHA
|
(file) => file.sha === currentNoteSHA
|
||||||
)
|
)
|
||||||
@@ -38,7 +38,7 @@ export const useNoteView = () => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
addStackedNote(currentNoteSHA ?? "", file.sha)
|
addStackedNote(currentNoteSHA ?? "", file.sha, undefined, hash)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1,18 +1,65 @@
|
|||||||
import { useAsyncState } from "@vueuse/core"
|
import { useAsyncState } from "@vueuse/core"
|
||||||
import { computed, ref } from "vue"
|
import { computed, ref } from "vue"
|
||||||
|
|
||||||
import { data } from "@/data/data"
|
import { data, generateId } from "@/data/data"
|
||||||
import { DataType } from "@/data/DataType.enum"
|
import { DataType } from "@/data/DataType.enum"
|
||||||
import { prepareNoteCache } from "@/modules/note/cache/prepareNoteCache"
|
import { buildNoteDocs } from "@/modules/note/cache/prepareNoteCache"
|
||||||
import { Note } from "@/modules/note/models/Note"
|
import { Note } from "@/modules/note/models/Note"
|
||||||
import { queryFileContent } from "@/modules/repo/services/repo"
|
import { getMainReadme, queryFileContent } from "@/modules/repo/services/repo"
|
||||||
import { useUserRepoStore } from "@/modules/repo/store/userRepo.store"
|
import { useUserRepoStore } from "@/modules/repo/store/userRepo.store"
|
||||||
|
|
||||||
|
const CONCURRENCY = 8
|
||||||
|
const BULK_FLUSH_SIZE = 50
|
||||||
|
|
||||||
|
export type CacheFailureKind =
|
||||||
|
| "fetch"
|
||||||
|
| "build"
|
||||||
|
| "save"
|
||||||
|
| "readme"
|
||||||
|
| "unknown"
|
||||||
|
|
||||||
|
export type CacheFailure = {
|
||||||
|
kind: CacheFailureKind
|
||||||
|
path?: string
|
||||||
|
sha?: string
|
||||||
|
message?: string
|
||||||
|
docIds?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const describeError = (error: unknown): string => {
|
||||||
|
if (error instanceof Error) return error.message
|
||||||
|
return String(error)
|
||||||
|
}
|
||||||
|
|
||||||
|
const runWithConcurrency = async <T>(
|
||||||
|
items: T[],
|
||||||
|
limit: number,
|
||||||
|
worker: (item: T) => Promise<void>
|
||||||
|
) => {
|
||||||
|
let cursor = 0
|
||||||
|
const next = async (): Promise<void> => {
|
||||||
|
const i = cursor++
|
||||||
|
if (i >= items.length) return
|
||||||
|
await worker(items[i])
|
||||||
|
return next()
|
||||||
|
}
|
||||||
|
await Promise.all(
|
||||||
|
Array.from({ length: Math.min(limit, items.length) }, next)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export const useOfflineNotes = () => {
|
export const useOfflineNotes = () => {
|
||||||
const store = useUserRepoStore()
|
const store = useUserRepoStore()
|
||||||
const totalOfNotes = computed(() => store.files.length)
|
const totalOfNotes = computed(() => store.files.length)
|
||||||
|
|
||||||
const noteCompleted = ref(0)
|
const noteCompleted = ref(0)
|
||||||
|
const failures = ref<CacheFailure[]>([])
|
||||||
|
const failedNotes = computed(() => failures.value.length)
|
||||||
|
|
||||||
|
const recordFailure = (failure: CacheFailure) => {
|
||||||
|
failures.value.push(failure)
|
||||||
|
console.warn("[offline-cache] failure", failure)
|
||||||
|
}
|
||||||
|
|
||||||
const cacheAllNotes = async () => {
|
const cacheAllNotes = async () => {
|
||||||
const isInitialized = store.user && store.repo && totalOfNotes.value > 0
|
const isInitialized = store.user && store.repo && totalOfNotes.value > 0
|
||||||
@@ -29,32 +76,84 @@ export const useOfflineNotes = () => {
|
|||||||
|
|
||||||
const cachedNotesSet = new Set(cachedNotesFromSha.map((note) => note._id))
|
const cachedNotesSet = new Set(cachedNotesFromSha.map((note) => note._id))
|
||||||
|
|
||||||
noteCompleted.value = 0
|
const filesToFetch = store.files.filter(
|
||||||
|
(file) =>
|
||||||
for (const file of store.files) {
|
file.sha && !cachedNotesSet.has(generateId(DataType.Note, file.sha))
|
||||||
noteCompleted.value++
|
|
||||||
|
|
||||||
if (
|
|
||||||
!file.sha ||
|
|
||||||
cachedNotesSet.has(data.generateId(DataType.Note, file.sha))
|
|
||||||
) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
const { saveCacheNote } = prepareNoteCache(file.sha, file.path)
|
|
||||||
|
|
||||||
const contentFile = await queryFileContent(
|
|
||||||
store.user,
|
|
||||||
store.repo,
|
|
||||||
file.sha
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if (!contentFile) {
|
noteCompleted.value = store.files.length - filesToFetch.length
|
||||||
return null
|
failures.value = []
|
||||||
|
|
||||||
|
const pendingDocs: Note[] = []
|
||||||
|
const flush = async () => {
|
||||||
|
if (!pendingDocs.length) return
|
||||||
|
const batch = pendingDocs.splice(0, pendingDocs.length)
|
||||||
|
try {
|
||||||
|
await data.bulkUpdate(batch)
|
||||||
|
} catch (error) {
|
||||||
|
recordFailure({
|
||||||
|
kind: "save",
|
||||||
|
message: describeError(error),
|
||||||
|
docIds: batch
|
||||||
|
.map((doc) => doc._id)
|
||||||
|
.filter((id): id is string => Boolean(id))
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
saveCacheNote(contentFile)
|
const fetchWork = runWithConcurrency(
|
||||||
|
filesToFetch,
|
||||||
|
CONCURRENCY,
|
||||||
|
async (file) => {
|
||||||
|
try {
|
||||||
|
const content = await queryFileContent(
|
||||||
|
store.user,
|
||||||
|
store.repo,
|
||||||
|
file.sha!
|
||||||
|
)
|
||||||
|
if (content === null) {
|
||||||
|
recordFailure({
|
||||||
|
kind: "fetch",
|
||||||
|
path: file.path,
|
||||||
|
sha: file.sha!
|
||||||
|
})
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
let docs: Note[]
|
||||||
|
try {
|
||||||
|
docs = buildNoteDocs(file.sha!, file.path, content)
|
||||||
|
} catch (error) {
|
||||||
|
recordFailure({
|
||||||
|
kind: "build",
|
||||||
|
path: file.path,
|
||||||
|
sha: file.sha!,
|
||||||
|
message: describeError(error)
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pendingDocs.push(...docs)
|
||||||
|
if (pendingDocs.length >= BULK_FLUSH_SIZE) {
|
||||||
|
await flush()
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
recordFailure({
|
||||||
|
kind: "unknown",
|
||||||
|
path: file.path,
|
||||||
|
sha: file.sha,
|
||||||
|
message: describeError(error)
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
noteCompleted.value++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const readmeWork = getMainReadme(store.user, store.repo).catch((error) => {
|
||||||
|
recordFailure({ kind: "readme", message: describeError(error) })
|
||||||
|
})
|
||||||
|
|
||||||
|
await Promise.all([fetchWork, readmeWork])
|
||||||
|
await flush()
|
||||||
}
|
}
|
||||||
const { execute, isLoading } = useAsyncState(cacheAllNotes, null, {
|
const { execute, isLoading } = useAsyncState(cacheAllNotes, null, {
|
||||||
immediate: false
|
immediate: false
|
||||||
@@ -64,6 +163,8 @@ export const useOfflineNotes = () => {
|
|||||||
cacheAllNotes: execute,
|
cacheAllNotes: execute,
|
||||||
isLoading,
|
isLoading,
|
||||||
totalOfNotes,
|
totalOfNotes,
|
||||||
noteCompleted
|
noteCompleted,
|
||||||
|
failedNotes,
|
||||||
|
failures
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,31 +10,28 @@ export const useOverlay = (listen = true) => {
|
|||||||
const isMobile = computed(() => width.value <= MOBILE_BREAKPOINT)
|
const isMobile = computed(() => width.value <= MOBILE_BREAKPOINT)
|
||||||
|
|
||||||
if (listen) {
|
if (listen) {
|
||||||
// In Firefox/Chrome, body is the horizontal scroll container (body has
|
|
||||||
// computed overflow-x: auto from overflow-y: hidden). In Safari, the
|
|
||||||
// viewport (documentElement) is used instead. Listen on both.
|
|
||||||
const updateScroll = () => {
|
const updateScroll = () => {
|
||||||
x.value = document.body.scrollLeft || window.scrollX
|
const mainApp = document.getElementById("main-app")
|
||||||
y.value = document.body.scrollTop || window.scrollY
|
x.value = mainApp?.scrollLeft ?? 0
|
||||||
|
y.value = mainApp?.scrollTop ?? 0
|
||||||
}
|
}
|
||||||
useEventListener(window, "scroll", updateScroll, {
|
useEventListener(
|
||||||
passive: true,
|
() => document.getElementById("main-app"),
|
||||||
capture: false
|
"scroll",
|
||||||
})
|
updateScroll,
|
||||||
useEventListener(document.body, "scroll", updateScroll, {
|
{ passive: true }
|
||||||
passive: true,
|
)
|
||||||
capture: false
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const scrollToNote = (to: number) => {
|
const scrollToNote = (to: number) => {
|
||||||
const go = () => {
|
const go = () => {
|
||||||
|
const mainApp = document.getElementById("main-app")
|
||||||
|
if (!mainApp) return
|
||||||
|
|
||||||
if (isMobile.value) {
|
if (isMobile.value) {
|
||||||
document.body.scrollTop = to
|
mainApp.scrollTo({ top: to, behavior: "smooth" })
|
||||||
document.documentElement.scrollTop = to
|
|
||||||
} else {
|
} else {
|
||||||
document.body.scrollLeft = to
|
mainApp.scrollTo({ left: to, behavior: "smooth" })
|
||||||
document.documentElement.scrollLeft = to
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,10 +40,22 @@ export const useOverlay = (listen = true) => {
|
|||||||
}, 80)
|
}, 80)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const scrollToElement = (element: HTMLElement, anchorTop?: number) => {
|
||||||
|
const mainApp = document.getElementById("main-app")
|
||||||
|
if (mainApp && anchorTop !== undefined) {
|
||||||
|
mainApp.scrollTop = anchorTop
|
||||||
|
}
|
||||||
|
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
element.scrollIntoView({ behavior: "smooth", block: "start" })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
x,
|
x,
|
||||||
y,
|
y,
|
||||||
isMobile,
|
isMobile,
|
||||||
scrollToNote
|
scrollToNote,
|
||||||
|
scrollToElement
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,34 +1,94 @@
|
|||||||
import { useAsyncState } from "@vueuse/core"
|
import { computed, ref, watch } from "vue"
|
||||||
|
|
||||||
import { useGitHubLogin } from "@/hooks/useGitHubLogin.hook"
|
import { useGitHubLogin } from "@/hooks/useGitHubLogin.hook"
|
||||||
import { RepoBase } from "@/modules/repo/interfaces/RepoBase"
|
import { RepoBase } from "@/modules/repo/interfaces/RepoBase"
|
||||||
import { getOctokit } from "@/modules/repo/services/octo"
|
import { getOctokit } from "@/modules/repo/services/octo"
|
||||||
|
|
||||||
export const useRepos = () => {
|
const PER_PAGE = 30
|
||||||
const { username, accessToken } = useGitHubLogin()
|
const STALE_TIME_MS = 20 * 60 * 1000
|
||||||
const repos = useAsyncState<RepoBase[]>(async () => {
|
|
||||||
|
const repos = ref<RepoBase[]>([])
|
||||||
|
const isReady = ref(false)
|
||||||
|
const isLoading = ref(false)
|
||||||
|
const hasCredentialError = ref(false)
|
||||||
|
const currentPage = ref(0)
|
||||||
|
const hasMore = ref(true)
|
||||||
|
let lastFetchedAt = 0
|
||||||
|
|
||||||
|
const { username, accessToken } = useGitHubLogin()
|
||||||
|
|
||||||
|
const resetState = () => {
|
||||||
|
repos.value = []
|
||||||
|
currentPage.value = 0
|
||||||
|
hasMore.value = true
|
||||||
|
isReady.value = false
|
||||||
|
isLoading.value = false
|
||||||
|
hasCredentialError.value = false
|
||||||
|
lastFetchedAt = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadMore = async () => {
|
||||||
if (!accessToken.value || !username.value) {
|
if (!accessToken.value || !username.value) {
|
||||||
return []
|
isReady.value = true
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
if (isLoading.value || !hasMore.value) return
|
||||||
|
isLoading.value = true
|
||||||
|
try {
|
||||||
const octokit = await getOctokit()
|
const octokit = await getOctokit()
|
||||||
|
const nextPage = currentPage.value + 1
|
||||||
const repoList = await octokit.request("GET /search/repositories", {
|
const repoList = await octokit.request("GET /user/repos", {
|
||||||
q: `user:${username.value}`,
|
sort: "full_name",
|
||||||
per_page: 100
|
direction: "asc",
|
||||||
|
affiliation: "owner",
|
||||||
|
per_page: PER_PAGE,
|
||||||
|
page: nextPage
|
||||||
})
|
})
|
||||||
|
currentPage.value = nextPage
|
||||||
return repoList.data.items
|
hasMore.value = repoList.data.length === PER_PAGE
|
||||||
.map((item) => ({
|
const newItems = repoList.data.map((item) => ({
|
||||||
id: `${item.id}`,
|
id: `${item.id}`,
|
||||||
name: item.name,
|
name: item.name,
|
||||||
isPrivate: item.private
|
isPrivate: item.private ?? false
|
||||||
}))
|
}))
|
||||||
.sort((a, b) => (a.name < b.name ? -1 : 1))
|
repos.value = [...repos.value, ...newItems]
|
||||||
}, [])
|
} catch (err: unknown) {
|
||||||
|
if (
|
||||||
return {
|
typeof err === "object" &&
|
||||||
repos: repos.state,
|
err !== null &&
|
||||||
isReady: repos.isReady
|
"status" in err &&
|
||||||
|
(err as { status: number }).status === 401
|
||||||
|
) {
|
||||||
|
hasCredentialError.value = true
|
||||||
|
} else {
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
isReady.value = true
|
||||||
|
isLoading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
watch(accessToken, (next, prev) => {
|
||||||
|
if (next === prev) return
|
||||||
|
resetState()
|
||||||
|
if (next && username.value) {
|
||||||
|
lastFetchedAt = Date.now()
|
||||||
|
loadMore()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
export const useRepos = () => {
|
||||||
|
const canLoadMore = computed(() => !isLoading.value && hasMore.value)
|
||||||
|
|
||||||
|
const isStale = Date.now() - lastFetchedAt > STALE_TIME_MS
|
||||||
|
if (!isReady.value || isStale) {
|
||||||
|
if (isStale && isReady.value) {
|
||||||
|
resetState()
|
||||||
|
}
|
||||||
|
lastFetchedAt = Date.now()
|
||||||
|
loadMore()
|
||||||
|
}
|
||||||
|
|
||||||
|
return { repos, isReady, hasCredentialError, canLoadMore, loadMore }
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { onMounted, type Ref, watch } from "vue"
|
import { onMounted, onUnmounted, type Ref, watch } from "vue"
|
||||||
|
|
||||||
import { getNoteWidth } from "@/constants/note-width"
|
import { getNoteWidth } from "@/constants/note-width"
|
||||||
import { useOverlay } from "@/hooks/useOverlay.hook"
|
import { useOverlay } from "@/hooks/useOverlay.hook"
|
||||||
@@ -19,9 +19,9 @@ export const useResizeContainer = (
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isMobile.value) {
|
if (isMobile.value) {
|
||||||
container.style.height = `${(stackedNotes.value.length + 1) * 100}vh`
|
container.style.height = `${(stackedNotes.value.length + 1) * 100}svh`
|
||||||
} else {
|
} else {
|
||||||
container.style.width = `${
|
container.style.minWidth = `${
|
||||||
getNoteWidth() * (stackedNotes.value.length + 1)
|
getNoteWidth() * (stackedNotes.value.length + 1)
|
||||||
}px`
|
}px`
|
||||||
}
|
}
|
||||||
@@ -29,6 +29,11 @@ export const useResizeContainer = (
|
|||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
resizeContainer()
|
resizeContainer()
|
||||||
|
window.addEventListener("resize", resizeContainer)
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
window.removeEventListener("resize", resizeContainer)
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(stackedNotes, resizeContainer, {
|
watch(stackedNotes, resizeContainer, {
|
||||||
|
|||||||
@@ -18,24 +18,81 @@ export const useRouteQueryStackedNotes = () => {
|
|||||||
})
|
})
|
||||||
const { height } = useWindowSize()
|
const { height } = useWindowSize()
|
||||||
|
|
||||||
const { scrollToNote, isMobile } = useOverlay(false)
|
const { scrollToNote, scrollToElement, isMobile } = useOverlay(false)
|
||||||
|
|
||||||
const scrollToFocusedNote = (
|
const scrollToHashInNote = (
|
||||||
noteId: string | null = null,
|
cleanSha: string,
|
||||||
notes: string[] = stackedNotes.value
|
hash: string,
|
||||||
|
smooth: boolean,
|
||||||
|
attempts = 30
|
||||||
) => {
|
) => {
|
||||||
|
if (attempts <= 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const heading = document.querySelector(
|
||||||
|
`.note-${cleanSha} #${CSS.escape(hash)}`
|
||||||
|
)
|
||||||
|
if (heading) {
|
||||||
|
heading.scrollIntoView({
|
||||||
|
block: "start",
|
||||||
|
inline: "nearest",
|
||||||
|
behavior: smooth ? "smooth" : "auto"
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
scrollToHashInNote(cleanSha, hash, smooth, attempts - 1)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const scrollToNoteElement = (
|
||||||
|
cleanNoteId: string,
|
||||||
|
index: number,
|
||||||
|
anchorTop?: number,
|
||||||
|
attempts = 30
|
||||||
|
) => {
|
||||||
|
const element = document.querySelector(
|
||||||
|
`.note-${cleanNoteId}`
|
||||||
|
) as HTMLElement | null
|
||||||
|
|
||||||
|
if (element) {
|
||||||
|
scrollToElement(element, anchorTop)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attempts <= 0) {
|
||||||
|
scrollToNote((index + 1) * height.value)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
scrollToNoteElement(cleanNoteId, index, anchorTop, attempts - 1)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
type ScrollToFocusedNoteOptions = {
|
||||||
|
noteId?: string | null
|
||||||
|
notes?: string[]
|
||||||
|
hash?: string
|
||||||
|
smoothHash?: boolean
|
||||||
|
anchorTop?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const scrollToFocusedNote = ({
|
||||||
|
noteId = null,
|
||||||
|
notes = stackedNotes.value,
|
||||||
|
hash,
|
||||||
|
smoothHash = false,
|
||||||
|
anchorTop
|
||||||
|
}: ScrollToFocusedNoteOptions = {}) => {
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
const index = noteId ? notes.findIndex((nid) => nid === noteId) : 0
|
const index = noteId ? notes.findIndex((nid) => nid === noteId) : 0
|
||||||
|
|
||||||
if (isMobile.value) {
|
if (isMobile.value) {
|
||||||
if (noteId) {
|
if (noteId) {
|
||||||
const cleanNoteId = noteId.replaceAll(":", "-")
|
scrollToNoteElement(noteId.replaceAll(":", "-"), index, anchorTop)
|
||||||
const element = document.querySelector(
|
|
||||||
`.note-${cleanNoteId}`
|
|
||||||
) as HTMLElement
|
|
||||||
|
|
||||||
const top = (index + 1) * (element?.clientHeight ?? height.value)
|
|
||||||
scrollToNote(top)
|
|
||||||
} else {
|
} else {
|
||||||
scrollToNote(0)
|
scrollToNote(0)
|
||||||
}
|
}
|
||||||
@@ -47,16 +104,29 @@ export const useRouteQueryStackedNotes = () => {
|
|||||||
scrollToNote(0)
|
scrollToNote(0)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (hash && noteId) {
|
||||||
|
scrollToHashInNote(noteId.replaceAll(":", "-"), hash, smoothHash)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const addStackedNote = (
|
const addStackedNote = (
|
||||||
currentSha: string,
|
currentSha: string,
|
||||||
sha: string,
|
sha: string,
|
||||||
selector?: string
|
selector?: string,
|
||||||
|
hash?: string
|
||||||
) => {
|
) => {
|
||||||
|
const anchorTop =
|
||||||
|
document.getElementById("main-app")?.scrollTop ?? undefined
|
||||||
|
|
||||||
if (stackedNotes.value.includes(sha)) {
|
if (stackedNotes.value.includes(sha)) {
|
||||||
scrollToFocusedNote(selector ?? sha)
|
scrollToFocusedNote({
|
||||||
|
noteId: selector ?? sha,
|
||||||
|
hash,
|
||||||
|
smoothHash: true,
|
||||||
|
anchorTop
|
||||||
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,7 +146,7 @@ export const useRouteQueryStackedNotes = () => {
|
|||||||
stackedNotes.value = newStackedNotes
|
stackedNotes.value = newStackedNotes
|
||||||
}
|
}
|
||||||
|
|
||||||
scrollToFocusedNote(selector ?? sha, stackedNotes.value)
|
scrollToFocusedNote({ noteId: selector ?? sha, hash, anchorTop })
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
87
src/hooks/useTodoTxtCommit.hook.ts
Normal file
87
src/hooks/useTodoTxtCommit.hook.ts
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import { useDebounceFn } from "@vueuse/core"
|
||||||
|
import { onUnmounted, Ref, ref, toValue } from "vue"
|
||||||
|
|
||||||
|
import { useGitHubContent } from "@/hooks/useGitHubContent.hook"
|
||||||
|
import { FileLine, parseFile, serializeFile } from "@/utils/todotxt"
|
||||||
|
|
||||||
|
export const useTodoTxtCommit = ({
|
||||||
|
user,
|
||||||
|
repo,
|
||||||
|
path,
|
||||||
|
initialContent,
|
||||||
|
initialSha,
|
||||||
|
debounceMs = 1000
|
||||||
|
}: {
|
||||||
|
user: string
|
||||||
|
repo: string
|
||||||
|
path: Ref<string | undefined> | string | undefined
|
||||||
|
initialContent: Ref<string> | string
|
||||||
|
initialSha: Ref<string> | string
|
||||||
|
debounceMs?: number
|
||||||
|
}) => {
|
||||||
|
const { updateFile } = useGitHubContent({ user, repo })
|
||||||
|
|
||||||
|
const items = ref<FileLine[]>(parseFile(toValue(initialContent)))
|
||||||
|
const currentSha = ref(toValue(initialSha))
|
||||||
|
const isCommitting = ref(false)
|
||||||
|
const hasPendingChanges = ref(false)
|
||||||
|
|
||||||
|
// Re-seed from a freshly fetched file. We must not blow away in-flight edits.
|
||||||
|
const syncContent = (content: string, sha: string) => {
|
||||||
|
if (!hasPendingChanges.value) {
|
||||||
|
items.value = parseFile(content)
|
||||||
|
currentSha.value = sha
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const commitChanges = async () => {
|
||||||
|
const pathValue = toValue(path)
|
||||||
|
if (!pathValue || !hasPendingChanges.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isCommitting.value) {
|
||||||
|
debouncedCommit()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
isCommitting.value = true
|
||||||
|
|
||||||
|
const { sha: newSha } = await updateFile({
|
||||||
|
content: serializeFile(items.value),
|
||||||
|
path: pathValue,
|
||||||
|
sha: currentSha.value
|
||||||
|
})
|
||||||
|
|
||||||
|
if (newSha) {
|
||||||
|
currentSha.value = newSha
|
||||||
|
hasPendingChanges.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
isCommitting.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const debouncedCommit = useDebounceFn(commitChanges, debounceMs)
|
||||||
|
|
||||||
|
const mutate = (mutator: (current: FileLine[]) => FileLine[]) => {
|
||||||
|
items.value = mutator(items.value)
|
||||||
|
hasPendingChanges.value = true
|
||||||
|
debouncedCommit()
|
||||||
|
}
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
// Flush any pending edit on unmount so navigation doesn't lose work.
|
||||||
|
if (hasPendingChanges.value) {
|
||||||
|
commitChanges()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
items,
|
||||||
|
currentSha,
|
||||||
|
isCommitting,
|
||||||
|
hasPendingChanges,
|
||||||
|
syncContent,
|
||||||
|
mutate
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,7 +2,6 @@ import "notyf/notyf.min.css"
|
|||||||
import "./styles/app.css"
|
import "./styles/app.css"
|
||||||
import "@/analytics/openpanel"
|
import "@/analytics/openpanel"
|
||||||
|
|
||||||
import { VueQueryPlugin } from "@tanstack/vue-query"
|
|
||||||
import { createPinia } from "pinia"
|
import { createPinia } from "pinia"
|
||||||
import { createApp } from "vue"
|
import { createApp } from "vue"
|
||||||
import { createI18n } from "vue-i18n"
|
import { createI18n } from "vue-i18n"
|
||||||
@@ -19,7 +18,6 @@ const i18n = createI18n({
|
|||||||
|
|
||||||
createApp(App)
|
createApp(App)
|
||||||
.use(router)
|
.use(router)
|
||||||
.use(VueQueryPlugin)
|
|
||||||
.use(i18n)
|
.use(i18n)
|
||||||
.use(createPinia())
|
.use(createPinia())
|
||||||
.mount("#app")
|
.mount("#app")
|
||||||
|
|||||||
53
src/modules/atproto/getUrl.spec.ts
Normal file
53
src/modules/atproto/getUrl.spec.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
|
||||||
|
|
||||||
|
vi.mock("@/modules/atproto/getAuthor", () => ({
|
||||||
|
getAuthor: vi.fn()
|
||||||
|
}))
|
||||||
|
|
||||||
|
import { getAuthor } from "@/modules/atproto/getAuthor"
|
||||||
|
|
||||||
|
import { getUrl } from "./getUrl"
|
||||||
|
|
||||||
|
describe("getUrl", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.mocked(getAuthor).mockReset()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns null when the author cannot be resolved", async () => {
|
||||||
|
vi.mocked(getAuthor).mockResolvedValue(null)
|
||||||
|
expect(
|
||||||
|
await getUrl({ did: "did:plc:abc", rkey: "r1" })
|
||||||
|
).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("builds a getRecord URL with the right query params on the author's PDS", async () => {
|
||||||
|
vi.mocked(getAuthor).mockResolvedValue({
|
||||||
|
handle: "alice.bsky.social",
|
||||||
|
pds: "https://pds.example.com"
|
||||||
|
})
|
||||||
|
|
||||||
|
const url = await getUrl({ did: "did:plc:abc", rkey: "rkey1" })
|
||||||
|
const parsed = new URL(url as string)
|
||||||
|
|
||||||
|
expect(parsed.origin).toBe("https://pds.example.com")
|
||||||
|
expect(parsed.pathname).toBe("/xrpc/com.atproto.repo.getRecord")
|
||||||
|
expect(parsed.searchParams.get("repo")).toBe("did:plc:abc")
|
||||||
|
expect(parsed.searchParams.get("collection")).toBe("space.remanso.note")
|
||||||
|
expect(parsed.searchParams.get("rkey")).toBe("rkey1")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("passes the did to getAuthor", async () => {
|
||||||
|
vi.mocked(getAuthor).mockResolvedValue({
|
||||||
|
handle: "h",
|
||||||
|
pds: "https://pds.example.com"
|
||||||
|
})
|
||||||
|
|
||||||
|
await getUrl({ did: "did:web:example.com", rkey: "r2" })
|
||||||
|
|
||||||
|
expect(getAuthor).toHaveBeenCalledWith("did:web:example.com")
|
||||||
|
})
|
||||||
|
})
|
||||||
54
src/modules/atproto/parseAtUri.spec.ts
Normal file
54
src/modules/atproto/parseAtUri.spec.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import { describe, expect, it } from "vitest"
|
||||||
|
|
||||||
|
import { parseAtUri } from "./parseAtUri"
|
||||||
|
|
||||||
|
describe("parseAtUri", () => {
|
||||||
|
it("parses a did:plc AT URI", () => {
|
||||||
|
expect(
|
||||||
|
parseAtUri("at://did:plc:abc123/app.bsky.feed.post/rkey-xyz")
|
||||||
|
).toEqual({
|
||||||
|
did: "did:plc:abc123",
|
||||||
|
rkey: "rkey-xyz"
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("parses a did:web AT URI", () => {
|
||||||
|
expect(
|
||||||
|
parseAtUri("at://did:web:example.com/space.remanso.note/note-1")
|
||||||
|
).toEqual({
|
||||||
|
did: "did:web:example.com",
|
||||||
|
rkey: "note-1"
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("treats rkeys with slashes as a single trailing segment", () => {
|
||||||
|
expect(
|
||||||
|
parseAtUri("at://did:plc:abc/space.remanso.note/multi/segment")
|
||||||
|
).toEqual({
|
||||||
|
did: "did:plc:abc",
|
||||||
|
rkey: "multi/segment"
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("throws when the URI does not start with at://", () => {
|
||||||
|
expect(() =>
|
||||||
|
parseAtUri("https://did:plc:abc/collection/rkey")
|
||||||
|
).toThrow(/Invalid AT URI/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("throws when the DID prefix is missing", () => {
|
||||||
|
expect(() => parseAtUri("at://abc/collection/rkey")).toThrow(
|
||||||
|
/Invalid AT URI/
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("throws when the collection or rkey is missing", () => {
|
||||||
|
expect(() => parseAtUri("at://did:plc:abc/onlycollection")).toThrow(
|
||||||
|
/Invalid AT URI/
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("throws on empty input", () => {
|
||||||
|
expect(() => parseAtUri("")).toThrow(/Invalid AT URI/)
|
||||||
|
})
|
||||||
|
})
|
||||||
41
src/modules/atproto/shortDid.spec.ts
Normal file
41
src/modules/atproto/shortDid.spec.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { describe, expect, it } from "vitest"
|
||||||
|
|
||||||
|
import { fromShortDid, toShortDid } from "./shortDid"
|
||||||
|
|
||||||
|
describe("toShortDid", () => {
|
||||||
|
it("strips did:plc: prefix", () => {
|
||||||
|
expect(toShortDid("did:plc:abc123")).toBe("abc123")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("strips did: prefix but keeps the method when non-plc", () => {
|
||||||
|
expect(toShortDid("did:web:example.com")).toBe("web:example.com")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns input unchanged when there is no did: prefix", () => {
|
||||||
|
expect(toShortDid("abc123")).toBe("abc123")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("fromShortDid", () => {
|
||||||
|
it("adds did:plc: prefix to bare identifiers", () => {
|
||||||
|
expect(fromShortDid("abc123")).toBe("did:plc:abc123")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("adds did: prefix when method is already present", () => {
|
||||||
|
expect(fromShortDid("web:example.com")).toBe("did:web:example.com")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("passes through fully-qualified DIDs unchanged", () => {
|
||||||
|
expect(fromShortDid("did:plc:abc123")).toBe("did:plc:abc123")
|
||||||
|
expect(fromShortDid("did:web:example.com")).toBe("did:web:example.com")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("round-trip toShortDid → fromShortDid", () => {
|
||||||
|
it.each(["did:plc:abc123", "did:web:example.com", "did:key:zXyZ"])(
|
||||||
|
"is identity for %s",
|
||||||
|
(did) => {
|
||||||
|
expect(fromShortDid(toShortDid(did))).toBe(did)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
})
|
||||||
55
src/modules/atproto/withATProtoImages.spec.ts
Normal file
55
src/modules/atproto/withATProtoImages.spec.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import { describe, expect, it } from "vitest"
|
||||||
|
|
||||||
|
import { withATProtoImages } from "./withATProtoImages"
|
||||||
|
|
||||||
|
const PDS = "https://pds.example.com"
|
||||||
|
const DID = "did:plc:abc123"
|
||||||
|
|
||||||
|
describe("withATProtoImages", () => {
|
||||||
|
it("rewrites a bafkrei CID image to a getBlob URL", () => {
|
||||||
|
const out = withATProtoImages(
|
||||||
|
"",
|
||||||
|
{ pds: PDS, did: DID }
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(out).toContain(
|
||||||
|
`${PDS}/xrpc/com.atproto.sync.getBlob`
|
||||||
|
)
|
||||||
|
expect(out).toContain("did=did%3Aplc%3Aabc123")
|
||||||
|
expect(out).toContain(
|
||||||
|
"cid=bafkreigh2akiscaildc7r4apx2t6q4t6n6kxjpw3xhqxkfvvbprdaezz4i"
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("preserves the alt text", () => {
|
||||||
|
const out = withATProtoImages("", {
|
||||||
|
pds: PDS,
|
||||||
|
did: DID
|
||||||
|
})
|
||||||
|
expect(out).toMatch(/!\[my cover\]/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("rewrites multiple images in one pass", () => {
|
||||||
|
const md = " and "
|
||||||
|
const out = withATProtoImages(md, { pds: PDS, did: DID })
|
||||||
|
|
||||||
|
expect(out.match(/com\.atproto\.sync\.getBlob/g)).toHaveLength(2)
|
||||||
|
expect(out).toContain("cid=bafkreiaaa")
|
||||||
|
expect(out).toContain("cid=bafkreibbb")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("leaves regular image URLs untouched", () => {
|
||||||
|
const md = ""
|
||||||
|
expect(withATProtoImages(md, { pds: PDS, did: DID })).toBe(md)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("leaves CIDs that don't match the bafkrei prefix untouched", () => {
|
||||||
|
const md = ""
|
||||||
|
expect(withATProtoImages(md, { pds: PDS, did: DID })).toBe(md)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("preserves an empty alt text", () => {
|
||||||
|
const out = withATProtoImages("", { pds: PDS, did: DID })
|
||||||
|
expect(out).toMatch(/^!\[\]/)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -4,7 +4,7 @@ import { useAsyncState } from "@vueuse/core"
|
|||||||
import { addDays, isAfter } from "date-fns"
|
import { addDays, isAfter } from "date-fns"
|
||||||
import { computed, nextTick, watch } from "vue"
|
import { computed, nextTick, watch } from "vue"
|
||||||
|
|
||||||
import { data } from "@/data/data"
|
import { data, generateId } from "@/data/data"
|
||||||
import { DataType } from "@/data/DataType.enum"
|
import { DataType } from "@/data/DataType.enum"
|
||||||
import { useFile } from "@/hooks/useFile.hook"
|
import { useFile } from "@/hooks/useFile.hook"
|
||||||
import { useLinks } from "@/hooks/useLinks.hook"
|
import { useLinks } from "@/hooks/useLinks.hook"
|
||||||
@@ -51,7 +51,7 @@ export const useSpacedRepetitionCards = () => {
|
|||||||
const repetition = await data.getOrCreate<
|
const repetition = await data.getOrCreate<
|
||||||
DataType.RepetitionCard,
|
DataType.RepetitionCard,
|
||||||
RepetitionCard
|
RepetitionCard
|
||||||
>(data.generateId(DataType.RepetitionCard, cardFile.path), {
|
>(generateId(DataType.RepetitionCard, cardFile.path), {
|
||||||
$type: DataType.RepetitionCard,
|
$type: DataType.RepetitionCard,
|
||||||
level: 1,
|
level: 1,
|
||||||
repeatDate: new Date(),
|
repeatDate: new Date(),
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
import { useAsyncState } from "@vueuse/core"
|
import { useAsyncState } from "@vueuse/core"
|
||||||
import { computed } from "vue"
|
import { computed } from "vue"
|
||||||
|
|
||||||
import { data } from "@/data/data"
|
import { data, generateId } from "@/data/data"
|
||||||
import { DataType } from "@/data/DataType.enum"
|
import { DataType } from "@/data/DataType.enum"
|
||||||
import { History } from "@/data/models/History"
|
import { History } from "@/data/models/History"
|
||||||
|
|
||||||
const HISTORY_ID = data.generateId(DataType.History, "history")
|
const HISTORY_ID = generateId(DataType.History, "history")
|
||||||
|
|
||||||
export const useLastVisitedRepos = () => {
|
export const useLastVisitedRepos = () => {
|
||||||
const history = useAsyncState(
|
const history = useAsyncState(
|
||||||
() =>
|
() =>
|
||||||
data.get<DataType.History, History>(
|
data.get<DataType.History, History>(
|
||||||
data.generateId(DataType.History, "history")
|
generateId(DataType.History, "history")
|
||||||
),
|
),
|
||||||
null
|
null
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { Ref, toValue } from "vue"
|
import { Ref, toValue } from "vue"
|
||||||
|
|
||||||
import { data } from "@/data/data"
|
import { data, generateId } from "@/data/data"
|
||||||
import { DataType } from "@/data/DataType.enum"
|
import { DataType } from "@/data/DataType.enum"
|
||||||
import { History } from "@/data/models/History"
|
import { History } from "@/data/models/History"
|
||||||
|
|
||||||
const HISTORY_ID = data.generateId(DataType.History, "history")
|
const HISTORY_ID = generateId(DataType.History, "history")
|
||||||
const MAX_REPO_HISTORY = 10
|
const MAX_REPO_HISTORY = 10
|
||||||
|
|
||||||
export const useVisitRepo = (newRepo: {
|
export const useVisitRepo = (newRepo: {
|
||||||
|
|||||||
23
src/modules/note/cache/prepareNoteCache.ts
vendored
23
src/modules/note/cache/prepareNoteCache.ts
vendored
@@ -1,4 +1,4 @@
|
|||||||
import { data } from "@/data/data"
|
import { data, generateId } from "@/data/data"
|
||||||
import { DataType } from "@/data/DataType.enum"
|
import { DataType } from "@/data/DataType.enum"
|
||||||
import { Note } from "@/modules/note/models/Note"
|
import { Note } from "@/modules/note/models/Note"
|
||||||
import { useUserRepoStore } from "@/modules/repo/store/userRepo.store"
|
import { useUserRepoStore } from "@/modules/repo/store/userRepo.store"
|
||||||
@@ -11,11 +11,28 @@ type NoteCacheResult =
|
|||||||
| { note: Note; from: "path" }
|
| { note: Note; from: "path" }
|
||||||
| { note: null; from: null }
|
| { note: null; from: null }
|
||||||
|
|
||||||
|
export const buildNoteDocs = (
|
||||||
|
sha: string,
|
||||||
|
path: string | undefined,
|
||||||
|
content: string,
|
||||||
|
editedSha?: string
|
||||||
|
): Note[] => {
|
||||||
|
const base: Note = {
|
||||||
|
_id: generateId(DataType.Note, sha),
|
||||||
|
$type: DataType.Note,
|
||||||
|
content,
|
||||||
|
editedSha
|
||||||
|
}
|
||||||
|
return path
|
||||||
|
? [base, { ...base, _id: generateId(DataType.Note, path) }]
|
||||||
|
: [base]
|
||||||
|
}
|
||||||
|
|
||||||
export const prepareNoteCache = (sha: string, path?: string) => {
|
export const prepareNoteCache = (sha: string, path?: string) => {
|
||||||
const store = useUserRepoStore()
|
const store = useUserRepoStore()
|
||||||
|
|
||||||
const noteId = data.generateId(DataType.Note, sha)
|
const noteId = generateId(DataType.Note, sha)
|
||||||
const notePath = path ? data.generateId(DataType.Note, path) : null
|
const notePath = path ? generateId(DataType.Note, path) : null
|
||||||
const getCachedNote = async (): Promise<NoteCacheResult> => {
|
const getCachedNote = async (): Promise<NoteCacheResult> => {
|
||||||
const note = await data.get<DataType.Note, Note>(noteId)
|
const note = await data.get<DataType.Note, Note>(noteId)
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,27 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useOfflineNotes } from "@/hooks/useOfflineNotes.hook"
|
import { useOfflineNotes } from "@/hooks/useOfflineNotes.hook"
|
||||||
import { confirmMessage } from "@/utils/notif"
|
import { confirmMessage, errorMessage } from "@/utils/notif"
|
||||||
|
|
||||||
const { cacheAllNotes, isLoading, totalOfNotes, noteCompleted } =
|
const {
|
||||||
useOfflineNotes()
|
cacheAllNotes,
|
||||||
|
isLoading,
|
||||||
|
totalOfNotes,
|
||||||
|
noteCompleted,
|
||||||
|
failedNotes,
|
||||||
|
failures
|
||||||
|
} = useOfflineNotes()
|
||||||
|
|
||||||
const confirmBeforeCachingAllNotes = async () => {
|
const confirmBeforeCachingAllNotes = async () => {
|
||||||
confirm("Do you want to cache all notes?")
|
confirm("Do you want to cache all notes?")
|
||||||
await cacheAllNotes()
|
await cacheAllNotes()
|
||||||
|
if (failedNotes.value > 0) {
|
||||||
|
console.table(failures.value)
|
||||||
|
errorMessage(
|
||||||
|
`${failedNotes.value} of ${totalOfNotes.value} note(s) could not be cached — try again`
|
||||||
|
)
|
||||||
|
} else {
|
||||||
confirmMessage("✅ All notes have been locally saved")
|
confirmMessage("✅ All notes have been locally saved")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -1,64 +0,0 @@
|
|||||||
import { initContract } from "@ts-rest/core"
|
|
||||||
import { initQueryClient } from "@ts-rest/vue-query"
|
|
||||||
import { type } from "arktype"
|
|
||||||
|
|
||||||
const PublicNoteListItem = type({
|
|
||||||
did: "string",
|
|
||||||
rkey: "string",
|
|
||||||
title: "string",
|
|
||||||
publishedAt: "string",
|
|
||||||
createdAt: "string"
|
|
||||||
})
|
|
||||||
|
|
||||||
export type PublicNoteListItem = typeof PublicNoteListItem.infer
|
|
||||||
|
|
||||||
const PublicNote = type({
|
|
||||||
did: "string",
|
|
||||||
rkey: "string",
|
|
||||||
title: "string",
|
|
||||||
content: "string",
|
|
||||||
publishedAt: "string",
|
|
||||||
createdAt: "string"
|
|
||||||
})
|
|
||||||
|
|
||||||
export type PublicNote = typeof PublicNote.infer
|
|
||||||
|
|
||||||
const contract = initContract()
|
|
||||||
|
|
||||||
export const noteRouter = contract.router({
|
|
||||||
noteLists: {
|
|
||||||
method: "GET",
|
|
||||||
path: "/notes",
|
|
||||||
query: type({
|
|
||||||
cursor: "string | undefined",
|
|
||||||
limit: "number | undefined"
|
|
||||||
}),
|
|
||||||
responses: {
|
|
||||||
200: type({
|
|
||||||
notes: PublicNoteListItem.array()
|
|
||||||
})
|
|
||||||
},
|
|
||||||
summary: "List all notes"
|
|
||||||
},
|
|
||||||
noteListsByDid: {
|
|
||||||
method: "GET",
|
|
||||||
path: "/:did/notes",
|
|
||||||
pathParams: type({
|
|
||||||
did: "string"
|
|
||||||
}),
|
|
||||||
query: type({
|
|
||||||
cursor: "string | undefined",
|
|
||||||
limit: "number | undefined"
|
|
||||||
}),
|
|
||||||
responses: {
|
|
||||||
200: type({
|
|
||||||
notes: PublicNoteListItem.array()
|
|
||||||
})
|
|
||||||
},
|
|
||||||
summary: "List all notes"
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
export const client = initQueryClient(noteRouter, {
|
|
||||||
baseUrl: "https://api.remanso.space"
|
|
||||||
})
|
|
||||||
@@ -1,25 +1,28 @@
|
|||||||
import { computed, onMounted, ref } from "vue"
|
import { computed, onMounted, ref } from "vue"
|
||||||
|
|
||||||
import { data } from "@/data/data"
|
import { data, generateId } from "@/data/data"
|
||||||
import { DataType } from "@/data/DataType.enum"
|
import { DataType } from "@/data/DataType.enum"
|
||||||
import { useRepos } from "@/hooks/useRepos.hook"
|
|
||||||
import { RepoBase } from "@/modules/repo/interfaces/RepoBase"
|
import { RepoBase } from "@/modules/repo/interfaces/RepoBase"
|
||||||
import { FavoriteRepo } from "@/modules/repo/models/FavoriteRepo"
|
import { FavoriteRepo } from "@/modules/repo/models/FavoriteRepo"
|
||||||
|
|
||||||
export const useFavoriteRepos = () => {
|
export const useFavoriteRepos = () => {
|
||||||
const { repos } = useRepos()
|
|
||||||
const savedRepos = ref<FavoriteRepo[]>([])
|
const savedRepos = ref<FavoriteRepo[]>([])
|
||||||
|
|
||||||
const getFavorites = async () => {
|
const getFavorites = async () => {
|
||||||
savedRepos.value = await data.getAll<DataType.FavoriteRepo, FavoriteRepo>({
|
savedRepos.value = await data.getAll<DataType.FavoriteRepo, FavoriteRepo>({
|
||||||
prefix: DataType.FavoriteRepo,
|
prefix: DataType.FavoriteRepo
|
||||||
keys: repos.value.map((repo) => repo.id)
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const savedFavoriteRepos = computed(() =>
|
const savedFavoriteRepos = computed(() => {
|
||||||
savedRepos.value.filter((repo) => repo.isFavorite)
|
const seen = new Map<string, FavoriteRepo>()
|
||||||
)
|
for (const repo of savedRepos.value) {
|
||||||
|
if (repo.isFavorite && !seen.has(repo.name)) {
|
||||||
|
seen.set(repo.name, repo)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...seen.values()]
|
||||||
|
})
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
getFavorites()
|
getFavorites()
|
||||||
@@ -27,7 +30,7 @@ export const useFavoriteRepos = () => {
|
|||||||
|
|
||||||
const toggleFavorite = async (repo: RepoBase, isFavorite: boolean) => {
|
const toggleFavorite = async (repo: RepoBase, isFavorite: boolean) => {
|
||||||
const favorite: FavoriteRepo = {
|
const favorite: FavoriteRepo = {
|
||||||
_id: data.generateId(DataType.FavoriteRepo, repo.id),
|
_id: generateId(DataType.FavoriteRepo, repo.id),
|
||||||
$type: DataType.FavoriteRepo,
|
$type: DataType.FavoriteRepo,
|
||||||
isFavorite,
|
isFavorite,
|
||||||
name: repo.name,
|
name: repo.name,
|
||||||
|
|||||||
@@ -1,31 +1,38 @@
|
|||||||
import { computed } from "vue"
|
import { computed } from "vue"
|
||||||
|
|
||||||
|
import { DataType } from "@/data/DataType.enum"
|
||||||
import { useRepos } from "@/hooks/useRepos.hook"
|
import { useRepos } from "@/hooks/useRepos.hook"
|
||||||
import { useFavoriteRepos } from "@/modules/repo/hooks/useFavoriteRepos.hook"
|
import { useFavoriteRepos } from "@/modules/repo/hooks/useFavoriteRepos.hook"
|
||||||
import { RepoBase } from "@/modules/repo/interfaces/RepoBase"
|
import { RepoBase } from "@/modules/repo/interfaces/RepoBase"
|
||||||
|
|
||||||
|
const FAVORITE_ID_PREFIX = `${DataType.FavoriteRepo}-`
|
||||||
|
|
||||||
export const useRepoList = () => {
|
export const useRepoList = () => {
|
||||||
const { savedFavoriteRepos, addFavorite, removeFavorite } = useFavoriteRepos()
|
const { savedFavoriteRepos, addFavorite, removeFavorite } = useFavoriteRepos()
|
||||||
const { repos } = useRepos()
|
const { repos, canLoadMore, loadMore } = useRepos()
|
||||||
|
|
||||||
const favoriteRepos = computed(() => {
|
const favoriteRepos = computed<RepoBase[]>(() =>
|
||||||
return repos.value.filter((repo) =>
|
savedFavoriteRepos.value.map((fav) => ({
|
||||||
savedFavoriteRepos.value.find(
|
id: fav._id?.startsWith(FAVORITE_ID_PREFIX)
|
||||||
(fav) => fav._id?.includes(repo.id) ?? false
|
? fav._id.slice(FAVORITE_ID_PREFIX.length)
|
||||||
|
: (fav._id ?? ""),
|
||||||
|
name: fav.name,
|
||||||
|
isPrivate: fav.isPrivate
|
||||||
|
}))
|
||||||
)
|
)
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
const otherRepos = computed(() => {
|
|
||||||
return repos.value.filter(
|
|
||||||
(repo) => !favoriteRepos.value.find((favorite) => favorite.id === repo.id)
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
const favoriteCheckboxes = computed(() =>
|
const favoriteCheckboxes = computed(() =>
|
||||||
favoriteRepos.value.map((favorite) => favorite.id)
|
favoriteRepos.value.map((favorite) => favorite.id)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const otherRepos = computed(() => {
|
||||||
|
const starredIds = new Set(favoriteCheckboxes.value)
|
||||||
|
const starredNames = new Set(favoriteRepos.value.map((r) => r.name))
|
||||||
|
return repos.value.filter(
|
||||||
|
(repo) => !starredIds.has(repo.id) && !starredNames.has(repo.name)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
const toggleCheckbox = async (repo: RepoBase) => {
|
const toggleCheckbox = async (repo: RepoBase) => {
|
||||||
if (favoriteCheckboxes.value.includes(repo.id)) {
|
if (favoriteCheckboxes.value.includes(repo.id)) {
|
||||||
await removeFavorite(repo)
|
await removeFavorite(repo)
|
||||||
@@ -38,6 +45,8 @@ export const useRepoList = () => {
|
|||||||
favoriteRepos,
|
favoriteRepos,
|
||||||
otherRepos,
|
otherRepos,
|
||||||
favoriteCheckboxes,
|
favoriteCheckboxes,
|
||||||
toggleCheckbox
|
toggleCheckbox,
|
||||||
|
canLoadMore,
|
||||||
|
loadMore
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,4 +8,7 @@ export interface UserSettings extends Model<DataType.UserSettings> {
|
|||||||
fontSize?: string
|
fontSize?: string
|
||||||
chosenFontSize?: string
|
chosenFontSize?: string
|
||||||
backlink?: boolean
|
backlink?: boolean
|
||||||
|
chosenTitleFont?: string
|
||||||
|
chosenBodyFont?: string
|
||||||
|
pageWidth?: string
|
||||||
}
|
}
|
||||||
|
|||||||
62
src/modules/repo/services/octo.spec.ts
Normal file
62
src/modules/repo/services/octo.spec.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
|
||||||
|
|
||||||
|
vi.mock("@/modules/user/service/signIn", () => ({
|
||||||
|
getAccessToken: vi.fn().mockResolvedValue({ token: "test-token" })
|
||||||
|
}))
|
||||||
|
|
||||||
|
import { DEFAULT_OCTOKIT_TIMEOUT_MS, getOctokit } from "./octo"
|
||||||
|
|
||||||
|
const okResponse = () =>
|
||||||
|
new Response("{}", {
|
||||||
|
status: 200,
|
||||||
|
headers: { "content-type": "application/json" }
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("getOctokit timeout hook", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
})
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers()
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("injects an AbortSignal.timeout into requests by default", async () => {
|
||||||
|
const timeoutSpy = vi.spyOn(AbortSignal, "timeout")
|
||||||
|
let receivedSignal: AbortSignal | undefined
|
||||||
|
const mockFetch = vi.fn(async (_url: string, init: RequestInit) => {
|
||||||
|
receivedSignal = init.signal ?? undefined
|
||||||
|
return okResponse()
|
||||||
|
})
|
||||||
|
|
||||||
|
const octokit = await getOctokit()
|
||||||
|
await octokit.request("GET /user", {
|
||||||
|
request: { fetch: mockFetch as unknown as typeof fetch }
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(timeoutSpy).toHaveBeenCalledWith(DEFAULT_OCTOKIT_TIMEOUT_MS)
|
||||||
|
expect(receivedSignal).toBeInstanceOf(AbortSignal)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("respects a caller-provided signal and does not overwrite it", async () => {
|
||||||
|
const timeoutSpy = vi.spyOn(AbortSignal, "timeout")
|
||||||
|
const controller = new AbortController()
|
||||||
|
let receivedSignal: AbortSignal | undefined
|
||||||
|
const mockFetch = vi.fn(async (_url: string, init: RequestInit) => {
|
||||||
|
receivedSignal = init.signal ?? undefined
|
||||||
|
return okResponse()
|
||||||
|
})
|
||||||
|
|
||||||
|
const octokit = await getOctokit()
|
||||||
|
await octokit.request("GET /user", {
|
||||||
|
request: {
|
||||||
|
fetch: mockFetch as unknown as typeof fetch,
|
||||||
|
signal: controller.signal
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(timeoutSpy).not.toHaveBeenCalled()
|
||||||
|
expect(receivedSignal).toBe(controller.signal)
|
||||||
|
})
|
||||||
|
|
||||||
|
})
|
||||||
@@ -1,11 +1,40 @@
|
|||||||
import { Octokit } from "@octokit/rest"
|
import { Octokit } from "@octokit/rest"
|
||||||
|
|
||||||
import { getAccessToken } from "@/modules/user/service/signIn"
|
import { getAccessToken, refreshToken } from "@/modules/user/service/signIn"
|
||||||
|
|
||||||
|
export const DEFAULT_OCTOKIT_TIMEOUT_MS = 8_000
|
||||||
|
|
||||||
export const getOctokit = async (): Promise<Octokit> => {
|
export const getOctokit = async (): Promise<Octokit> => {
|
||||||
const response = await getAccessToken()
|
const response = await getAccessToken()
|
||||||
|
|
||||||
return new Octokit({
|
const octokit = new Octokit({
|
||||||
auth: response?.token ?? ""
|
auth: response?.token ?? ""
|
||||||
})
|
})
|
||||||
|
|
||||||
|
octokit.hook.before("request", (options) => {
|
||||||
|
options.request ??= {}
|
||||||
|
if (!options.request.signal) {
|
||||||
|
options.request.signal = AbortSignal.timeout(DEFAULT_OCTOKIT_TIMEOUT_MS)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return octokit
|
||||||
|
}
|
||||||
|
|
||||||
|
const isUnauthorized = (error: unknown): boolean =>
|
||||||
|
(error as { status?: number })?.status === 401
|
||||||
|
|
||||||
|
// Runs an Octokit call; on 401, force-refreshes the token and retries once.
|
||||||
|
// Rethrows the original error if the refresh fails or the retry still 401s.
|
||||||
|
export const runWithAuthRetry = async <T>(
|
||||||
|
call: (octokit: Octokit) => Promise<T>
|
||||||
|
): Promise<T> => {
|
||||||
|
try {
|
||||||
|
return await call(await getOctokit())
|
||||||
|
} catch (error) {
|
||||||
|
if (!isUnauthorized(error)) throw error
|
||||||
|
const refreshed = await refreshToken({ force: true }).catch(() => null)
|
||||||
|
if (!refreshed) throw error
|
||||||
|
return await call(await getOctokit())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
167
src/modules/repo/services/repo.spec.ts
Normal file
167
src/modules/repo/services/repo.spec.ts
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
|
||||||
|
|
||||||
|
const { reposGet } = vi.hoisted(() => ({ reposGet: vi.fn() }))
|
||||||
|
|
||||||
|
vi.mock("./octo", () => ({
|
||||||
|
getOctokit: vi.fn().mockResolvedValue({
|
||||||
|
repos: { get: reposGet }
|
||||||
|
}),
|
||||||
|
runWithAuthRetry: vi.fn()
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Stub heavyweight transitive imports so loading repo.ts doesn't spin up the
|
||||||
|
// data Web Worker or the full markdown rendering stack.
|
||||||
|
vi.mock("@/hooks/useMarkdown.hook", () => ({
|
||||||
|
markdownBuilder: () => ({ render: (s: string) => s })
|
||||||
|
}))
|
||||||
|
vi.mock("@/modules/note/cache/prepareNoteCache", () => ({
|
||||||
|
prepareNoteCache: () => ({
|
||||||
|
getCachedNote: async () => ({ note: null }),
|
||||||
|
saveCacheNote: vi.fn()
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
|
||||||
|
import { getOctokit, runWithAuthRetry } from "./octo"
|
||||||
|
import {
|
||||||
|
getFiles,
|
||||||
|
getRepoPermission,
|
||||||
|
queryFileContent
|
||||||
|
} from "./repo"
|
||||||
|
|
||||||
|
const makeOctokitWithRequest = (impl: (route: string, params: unknown) => unknown) => ({
|
||||||
|
request: vi.fn(impl)
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("getRepoPermission", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
reposGet.mockReset()
|
||||||
|
vi.mocked(getOctokit).mockResolvedValue({
|
||||||
|
repos: { get: reposGet }
|
||||||
|
} as never)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns true when permissions.push is true", async () => {
|
||||||
|
reposGet.mockResolvedValue({
|
||||||
|
data: { permissions: { push: true } }
|
||||||
|
})
|
||||||
|
await expect(getRepoPermission("owner", "repo")).resolves.toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns false when permissions.push is false", async () => {
|
||||||
|
reposGet.mockResolvedValue({
|
||||||
|
data: { permissions: { push: false } }
|
||||||
|
})
|
||||||
|
await expect(getRepoPermission("owner", "repo")).resolves.toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns false when permissions is missing (anonymous request)", async () => {
|
||||||
|
reposGet.mockResolvedValue({
|
||||||
|
data: {}
|
||||||
|
})
|
||||||
|
await expect(getRepoPermission("owner", "repo")).resolves.toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns false when owner or repo is empty", async () => {
|
||||||
|
await expect(getRepoPermission("", "repo")).resolves.toBe(false)
|
||||||
|
await expect(getRepoPermission("owner", "")).resolves.toBe(false)
|
||||||
|
expect(reposGet).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("getFiles", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.mocked(getOctokit).mockReset()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns empty array when owner is missing", async () => {
|
||||||
|
expect(await getFiles("", "repo")).toEqual([])
|
||||||
|
expect(getOctokit).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns empty array when repo is missing", async () => {
|
||||||
|
expect(await getFiles("owner", "")).toEqual([])
|
||||||
|
expect(getOctokit).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns empty array when there are no commits", async () => {
|
||||||
|
const octokit = makeOctokitWithRequest((route) => {
|
||||||
|
if (route === "GET /repos/{owner}/{repo}/commits") {
|
||||||
|
return { data: [] }
|
||||||
|
}
|
||||||
|
throw new Error("unexpected route " + route)
|
||||||
|
})
|
||||||
|
vi.mocked(getOctokit).mockResolvedValue(octokit as never)
|
||||||
|
|
||||||
|
expect(await getFiles("owner", "repo")).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("fetches the tree from the latest commit and filters out non-blob entries", async () => {
|
||||||
|
const octokit = makeOctokitWithRequest((route, params) => {
|
||||||
|
if (route === "GET /repos/{owner}/{repo}/commits") {
|
||||||
|
return {
|
||||||
|
data: [{ commit: { tree: { sha: "TREE_SHA" } } }]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (route === "GET /repos/{owner}/{repo}/git/trees/{tree_sha}") {
|
||||||
|
expect((params as { tree_sha: string }).tree_sha).toBe("TREE_SHA")
|
||||||
|
expect((params as { recursive: string }).recursive).toBe("true")
|
||||||
|
return {
|
||||||
|
data: {
|
||||||
|
tree: [
|
||||||
|
{ path: "README.md", type: "blob", sha: "a" },
|
||||||
|
{ path: "src", type: "tree", sha: "b" },
|
||||||
|
{ path: "src/note.md", type: "blob", sha: "c" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error("unexpected route " + route)
|
||||||
|
})
|
||||||
|
vi.mocked(getOctokit).mockResolvedValue(octokit as never)
|
||||||
|
|
||||||
|
const files = await getFiles("owner", "repo")
|
||||||
|
|
||||||
|
expect(files.map((f) => f.path)).toEqual(["README.md", "src/note.md"])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("queryFileContent", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.mocked(runWithAuthRetry).mockReset()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns null when owner or repo is missing", async () => {
|
||||||
|
expect(await queryFileContent("", "repo", "sha")).toBeNull()
|
||||||
|
expect(await queryFileContent("owner", "", "sha")).toBeNull()
|
||||||
|
expect(runWithAuthRetry).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns the blob content via runWithAuthRetry", async () => {
|
||||||
|
vi.mocked(runWithAuthRetry).mockImplementation(async (call) => {
|
||||||
|
const octokit = {
|
||||||
|
request: vi.fn().mockResolvedValue({
|
||||||
|
data: { content: "BASE64" }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return call(octokit as never)
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(await queryFileContent("owner", "repo", "SHA")).toBe("BASE64")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns null and swallows errors when the call fails", async () => {
|
||||||
|
const warn = vi.spyOn(console, "warn").mockImplementation(() => {})
|
||||||
|
vi.mocked(runWithAuthRetry).mockRejectedValue(new Error("boom"))
|
||||||
|
|
||||||
|
expect(await queryFileContent("owner", "repo", "SHA")).toBeNull()
|
||||||
|
expect(warn).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -2,7 +2,19 @@ import { markdownBuilder } from "@/hooks/useMarkdown.hook"
|
|||||||
import { prepareNoteCache } from "@/modules/note/cache/prepareNoteCache"
|
import { prepareNoteCache } from "@/modules/note/cache/prepareNoteCache"
|
||||||
import { RepoFile } from "@/modules/repo/interfaces/RepoFile"
|
import { RepoFile } from "@/modules/repo/interfaces/RepoFile"
|
||||||
import { UserSettings } from "@/modules/repo/interfaces/UserSettings"
|
import { UserSettings } from "@/modules/repo/interfaces/UserSettings"
|
||||||
import { getOctokit } from "@/modules/repo/services/octo"
|
import { getOctokit, runWithAuthRetry } from "@/modules/repo/services/octo"
|
||||||
|
|
||||||
|
export const getRepoPermission = async (
|
||||||
|
owner: string,
|
||||||
|
repo: string
|
||||||
|
): Promise<boolean> => {
|
||||||
|
if (!owner || !repo) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
const octokit = await getOctokit()
|
||||||
|
const { data } = await octokit.repos.get({ owner, repo })
|
||||||
|
return data.permissions?.push ?? false
|
||||||
|
}
|
||||||
|
|
||||||
export const getFiles = async (
|
export const getFiles = async (
|
||||||
owner: string,
|
owner: string,
|
||||||
@@ -30,7 +42,8 @@ export const getFiles = async (
|
|||||||
owner,
|
owner,
|
||||||
repo,
|
repo,
|
||||||
tree_sha: lastCommit.commit.tree.sha,
|
tree_sha: lastCommit.commit.tree.sha,
|
||||||
recursive: "true"
|
recursive: "true",
|
||||||
|
request: { signal: AbortSignal.timeout(20_000) }
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -82,6 +95,10 @@ export const getMainReadme = async (owner: string, repo: string) => {
|
|||||||
if (cachedReadme) {
|
if (cachedReadme) {
|
||||||
return render(cachedReadme.content)
|
return render(cachedReadme.content)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// No cached fallback — surface the error so the caller can classify
|
||||||
|
// network/timeout failures vs auth/404 and tailor the UI accordingly.
|
||||||
|
throw error
|
||||||
}
|
}
|
||||||
|
|
||||||
return null
|
return null
|
||||||
@@ -104,7 +121,16 @@ export const getUserSettingsContent = async (
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
return JSON.parse(atob(content)) as UserSettings
|
const raw = JSON.parse(atob(content)) as UserSettings & {
|
||||||
|
t?: string
|
||||||
|
p?: string
|
||||||
|
}
|
||||||
|
const { t, p, ...rest } = raw
|
||||||
|
return {
|
||||||
|
...rest,
|
||||||
|
chosenTitleFont: t,
|
||||||
|
chosenBodyFont: p
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const queryFileContent = async (
|
export const queryFileContent = async (
|
||||||
@@ -112,20 +138,21 @@ export const queryFileContent = async (
|
|||||||
repo: string,
|
repo: string,
|
||||||
sha: string
|
sha: string
|
||||||
) => {
|
) => {
|
||||||
const octokit = await getOctokit()
|
|
||||||
|
|
||||||
if (!user || !repo) {
|
if (!user || !repo) {
|
||||||
null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
const file = await octokit.request(
|
try {
|
||||||
"GET /repos/{owner}/{repo}/git/blobs/{file_sha}",
|
const file = await runWithAuthRetry((octokit) =>
|
||||||
{
|
octokit.request("GET /repos/{owner}/{repo}/git/blobs/{file_sha}", {
|
||||||
owner: user,
|
owner: user,
|
||||||
repo: repo,
|
repo: repo,
|
||||||
file_sha: sha
|
file_sha: sha
|
||||||
}
|
})
|
||||||
)
|
)
|
||||||
|
|
||||||
return file?.data.content ?? null
|
return file?.data.content ?? null
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("queryFileContent failed", { user, repo, sha, error })
|
||||||
|
return null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
97
src/modules/repo/services/runWithAuthRetry.spec.ts
Normal file
97
src/modules/repo/services/runWithAuthRetry.spec.ts
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
|
||||||
|
|
||||||
|
vi.mock("@/modules/user/service/signIn", () => ({
|
||||||
|
getAccessToken: vi.fn(),
|
||||||
|
refreshToken: vi.fn()
|
||||||
|
}))
|
||||||
|
|
||||||
|
import {
|
||||||
|
getAccessToken,
|
||||||
|
refreshToken
|
||||||
|
} from "@/modules/user/service/signIn"
|
||||||
|
|
||||||
|
import { runWithAuthRetry } from "./octo"
|
||||||
|
|
||||||
|
const unauthorized = () => Object.assign(new Error("Bad credentials"), { status: 401 })
|
||||||
|
const notFound = () => Object.assign(new Error("Not found"), { status: 404 })
|
||||||
|
|
||||||
|
describe("runWithAuthRetry", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.mocked(getAccessToken).mockResolvedValue({
|
||||||
|
token: "t1"
|
||||||
|
} as Awaited<ReturnType<typeof getAccessToken>>)
|
||||||
|
vi.mocked(refreshToken).mockReset()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns the call result on success without refreshing", async () => {
|
||||||
|
const call = vi.fn().mockResolvedValue("ok")
|
||||||
|
|
||||||
|
const result = await runWithAuthRetry(call)
|
||||||
|
|
||||||
|
expect(result).toBe("ok")
|
||||||
|
expect(call).toHaveBeenCalledTimes(1)
|
||||||
|
expect(refreshToken).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("rethrows non-401 errors immediately without refreshing", async () => {
|
||||||
|
const err = notFound()
|
||||||
|
const call = vi.fn().mockRejectedValue(err)
|
||||||
|
|
||||||
|
await expect(runWithAuthRetry(call)).rejects.toBe(err)
|
||||||
|
expect(refreshToken).not.toHaveBeenCalled()
|
||||||
|
expect(call).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("refreshes and retries once on 401, returning the retry result", async () => {
|
||||||
|
vi.mocked(refreshToken).mockResolvedValue({
|
||||||
|
token: "t2"
|
||||||
|
} as Awaited<ReturnType<typeof refreshToken>>)
|
||||||
|
|
||||||
|
const call = vi
|
||||||
|
.fn()
|
||||||
|
.mockRejectedValueOnce(unauthorized())
|
||||||
|
.mockResolvedValueOnce("after-refresh")
|
||||||
|
|
||||||
|
const result = await runWithAuthRetry(call)
|
||||||
|
|
||||||
|
expect(result).toBe("after-refresh")
|
||||||
|
expect(refreshToken).toHaveBeenCalledWith({ force: true })
|
||||||
|
expect(call).toHaveBeenCalledTimes(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("rethrows the original 401 when refresh returns null", async () => {
|
||||||
|
vi.mocked(refreshToken).mockResolvedValue(null)
|
||||||
|
const err = unauthorized()
|
||||||
|
const call = vi.fn().mockRejectedValue(err)
|
||||||
|
|
||||||
|
await expect(runWithAuthRetry(call)).rejects.toBe(err)
|
||||||
|
expect(call).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("rethrows the original 401 when refresh itself throws", async () => {
|
||||||
|
vi.mocked(refreshToken).mockRejectedValue(new Error("network down"))
|
||||||
|
const err = unauthorized()
|
||||||
|
const call = vi.fn().mockRejectedValue(err)
|
||||||
|
|
||||||
|
await expect(runWithAuthRetry(call)).rejects.toBe(err)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("propagates a retry-time error after a successful refresh", async () => {
|
||||||
|
vi.mocked(refreshToken).mockResolvedValue({
|
||||||
|
token: "t2"
|
||||||
|
} as Awaited<ReturnType<typeof refreshToken>>)
|
||||||
|
|
||||||
|
const retryErr = unauthorized()
|
||||||
|
const call = vi
|
||||||
|
.fn()
|
||||||
|
.mockRejectedValueOnce(unauthorized())
|
||||||
|
.mockRejectedValueOnce(retryErr)
|
||||||
|
|
||||||
|
await expect(runWithAuthRetry(call)).rejects.toBe(retryErr)
|
||||||
|
expect(call).toHaveBeenCalledTimes(2)
|
||||||
|
})
|
||||||
|
})
|
||||||
286
src/modules/repo/store/userRepo.store.spec.ts
Normal file
286
src/modules/repo/store/userRepo.store.spec.ts
Normal file
@@ -0,0 +1,286 @@
|
|||||||
|
import { createPinia, setActivePinia } from "pinia"
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
|
||||||
|
|
||||||
|
vi.mock("@/data/data", () => ({
|
||||||
|
data: {
|
||||||
|
get: vi.fn().mockResolvedValue(null),
|
||||||
|
update: vi.fn().mockResolvedValue(undefined),
|
||||||
|
add: vi.fn().mockResolvedValue(undefined)
|
||||||
|
},
|
||||||
|
generateId: (type: string, id: string) => `${type}-${id}`
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock("@/modules/repo/services/repo", () => ({
|
||||||
|
getFiles: vi.fn().mockResolvedValue([]),
|
||||||
|
getMainReadme: vi.fn().mockResolvedValue(null),
|
||||||
|
getCachedMainReadme: vi.fn().mockResolvedValue(null),
|
||||||
|
getUserSettingsContent: vi.fn().mockResolvedValue(null),
|
||||||
|
getRepoPermission: vi.fn().mockResolvedValue(false)
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock("@/modules/user/service/signIn", () => ({
|
||||||
|
refreshToken: vi.fn().mockResolvedValue(null)
|
||||||
|
}))
|
||||||
|
|
||||||
|
import { data } from "@/data/data"
|
||||||
|
import {
|
||||||
|
getCachedMainReadme,
|
||||||
|
getFiles,
|
||||||
|
getMainReadme,
|
||||||
|
getRepoPermission,
|
||||||
|
getUserSettingsContent
|
||||||
|
} from "@/modules/repo/services/repo"
|
||||||
|
|
||||||
|
import { useUserRepoStore } from "./userRepo.store"
|
||||||
|
|
||||||
|
const flushAsync = () => new Promise((r) => setTimeout(r, 0))
|
||||||
|
|
||||||
|
describe("userRepo store — synchronous mutations", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
setActivePinia(createPinia())
|
||||||
|
localStorage.clear()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("resetUserRepo clears user, repo, files, and settings", () => {
|
||||||
|
const store = useUserRepoStore()
|
||||||
|
store.user = "alice"
|
||||||
|
store.repo = "notes"
|
||||||
|
store.files = [{ sha: "x", path: "a.md", type: "blob" }] as never
|
||||||
|
store.userSettings = { $type: 1 } as never
|
||||||
|
|
||||||
|
store.resetUserRepo()
|
||||||
|
|
||||||
|
expect(store.user).toBe("")
|
||||||
|
expect(store.repo).toBe("")
|
||||||
|
expect(store.files).toEqual([])
|
||||||
|
expect(store.userSettings).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("resetFiles clears files and sets readme to null", () => {
|
||||||
|
const store = useUserRepoStore()
|
||||||
|
store.files = [{ sha: "x", path: "a.md", type: "blob" }] as never
|
||||||
|
store.readme = "<p>hi</p>"
|
||||||
|
|
||||||
|
store.resetFiles()
|
||||||
|
|
||||||
|
expect(store.files).toEqual([])
|
||||||
|
expect(store.readme).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("addFile appends a new file with a unique sha", () => {
|
||||||
|
const store = useUserRepoStore()
|
||||||
|
store.user = "alice"
|
||||||
|
store.repo = "notes"
|
||||||
|
store.files = [{ sha: "old", path: "a.md", type: "blob" }] as never
|
||||||
|
|
||||||
|
store.addFile({ sha: "new", path: "b.md", type: "blob" } as never)
|
||||||
|
|
||||||
|
expect(store.files.map((f) => f.sha)).toEqual(["old", "new"])
|
||||||
|
expect(vi.mocked(data.update)).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("addFile is a no-op when the sha already exists", () => {
|
||||||
|
const store = useUserRepoStore()
|
||||||
|
store.user = "alice"
|
||||||
|
store.repo = "notes"
|
||||||
|
store.files = [{ sha: "x", path: "a.md", type: "blob" }] as never
|
||||||
|
|
||||||
|
store.addFile({ sha: "x", path: "duplicate.md", type: "blob" } as never)
|
||||||
|
|
||||||
|
expect(store.files).toHaveLength(1)
|
||||||
|
expect(vi.mocked(data.update)).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("addFile is a no-op when sha is missing", () => {
|
||||||
|
const store = useUserRepoStore()
|
||||||
|
store.files = []
|
||||||
|
|
||||||
|
store.addFile({ path: "no-sha.md", type: "blob" } as never)
|
||||||
|
|
||||||
|
expect(store.files).toHaveLength(0)
|
||||||
|
expect(vi.mocked(data.update)).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("setFontFamily initializes userSettings when absent and persists to localStorage", () => {
|
||||||
|
const store = useUserRepoStore()
|
||||||
|
store.user = "alice"
|
||||||
|
store.repo = "notes"
|
||||||
|
|
||||||
|
store.setFontFamily("Inter")
|
||||||
|
|
||||||
|
expect(store.userSettings?.chosenFontFamily).toBe("Inter")
|
||||||
|
const persisted = JSON.parse(
|
||||||
|
localStorage.getItem("remanso:layout:alice:notes") as string
|
||||||
|
)
|
||||||
|
expect(persisted.chosenFontFamily).toBe("Inter")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("setFontSize, setTitleFont, setBodyFont each persist their respective field", () => {
|
||||||
|
const store = useUserRepoStore()
|
||||||
|
store.user = "alice"
|
||||||
|
store.repo = "notes"
|
||||||
|
|
||||||
|
store.setFontSize("18px")
|
||||||
|
store.setTitleFont("Serif")
|
||||||
|
store.setBodyFont("Sans")
|
||||||
|
|
||||||
|
const persisted = JSON.parse(
|
||||||
|
localStorage.getItem("remanso:layout:alice:notes") as string
|
||||||
|
)
|
||||||
|
expect(persisted.chosenFontSize).toBe("18px")
|
||||||
|
expect(persisted.chosenTitleFont).toBe("Serif")
|
||||||
|
expect(persisted.chosenBodyFont).toBe("Sans")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("userRepo store — setUserRepo", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
setActivePinia(createPinia())
|
||||||
|
localStorage.clear()
|
||||||
|
vi.clearAllMocks()
|
||||||
|
vi.mocked(data.get).mockResolvedValue(null as never)
|
||||||
|
vi.mocked(getFiles).mockResolvedValue([])
|
||||||
|
vi.mocked(getMainReadme).mockResolvedValue(null)
|
||||||
|
vi.mocked(getCachedMainReadme).mockResolvedValue(null)
|
||||||
|
vi.mocked(getUserSettingsContent).mockResolvedValue(null)
|
||||||
|
vi.mocked(getRepoPermission).mockResolvedValue(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("sets user and repo immediately and clears loadError", async () => {
|
||||||
|
const store = useUserRepoStore()
|
||||||
|
store.loadError = "auth"
|
||||||
|
|
||||||
|
await store.setUserRepo("alice", "notes")
|
||||||
|
|
||||||
|
expect(store.user).toBe("alice")
|
||||||
|
expect(store.repo).toBe("notes")
|
||||||
|
expect(store.loadError).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("populates files from getFiles on success", async () => {
|
||||||
|
vi.mocked(getFiles).mockResolvedValue([
|
||||||
|
{ sha: "a", path: "x.md", type: "blob" } as never
|
||||||
|
])
|
||||||
|
|
||||||
|
const store = useUserRepoStore()
|
||||||
|
await store.setUserRepo("alice", "notes")
|
||||||
|
await flushAsync()
|
||||||
|
await flushAsync()
|
||||||
|
|
||||||
|
expect(store.files.map((f) => f.sha)).toEqual(["a"])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("sets readme from getMainReadme on success", async () => {
|
||||||
|
vi.mocked(getMainReadme).mockResolvedValue("<p>hi</p>")
|
||||||
|
|
||||||
|
const store = useUserRepoStore()
|
||||||
|
await store.setUserRepo("alice", "notes")
|
||||||
|
await flushAsync()
|
||||||
|
await flushAsync()
|
||||||
|
|
||||||
|
expect(store.readme).toBe("<p>hi</p>")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("classifies 401 errors from getFiles as auth", async () => {
|
||||||
|
vi.mocked(getFiles).mockRejectedValue(
|
||||||
|
Object.assign(new Error("Unauthorized"), { status: 401 })
|
||||||
|
)
|
||||||
|
vi.spyOn(console, "warn").mockImplementation(() => {})
|
||||||
|
|
||||||
|
const store = useUserRepoStore()
|
||||||
|
await store.setUserRepo("alice", "notes")
|
||||||
|
await flushAsync()
|
||||||
|
await flushAsync()
|
||||||
|
|
||||||
|
expect(store.loadError).toBe("auth")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("classifies TimeoutError as network", async () => {
|
||||||
|
vi.mocked(getFiles).mockRejectedValue(
|
||||||
|
Object.assign(new Error("Timed out"), { name: "TimeoutError" })
|
||||||
|
)
|
||||||
|
vi.spyOn(console, "warn").mockImplementation(() => {})
|
||||||
|
|
||||||
|
const store = useUserRepoStore()
|
||||||
|
await store.setUserRepo("alice", "notes")
|
||||||
|
await flushAsync()
|
||||||
|
await flushAsync()
|
||||||
|
|
||||||
|
expect(store.loadError).toBe("network")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("classifies 500-range errors as network", async () => {
|
||||||
|
vi.mocked(getFiles).mockRejectedValue(
|
||||||
|
Object.assign(new Error("Server error"), { status: 503 })
|
||||||
|
)
|
||||||
|
vi.spyOn(console, "warn").mockImplementation(() => {})
|
||||||
|
|
||||||
|
const store = useUserRepoStore()
|
||||||
|
await store.setUserRepo("alice", "notes")
|
||||||
|
await flushAsync()
|
||||||
|
await flushAsync()
|
||||||
|
|
||||||
|
expect(store.loadError).toBe("network")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("does NOT surface loadError from getMainReadme when a cached readme is present", async () => {
|
||||||
|
vi.mocked(getCachedMainReadme).mockResolvedValue("<p>cached</p>")
|
||||||
|
vi.mocked(getMainReadme).mockRejectedValue(
|
||||||
|
Object.assign(new Error("Server error"), { status: 503 })
|
||||||
|
)
|
||||||
|
vi.spyOn(console, "warn").mockImplementation(() => {})
|
||||||
|
|
||||||
|
const store = useUserRepoStore()
|
||||||
|
await store.setUserRepo("alice", "notes")
|
||||||
|
await flushAsync()
|
||||||
|
await flushAsync()
|
||||||
|
|
||||||
|
expect(store.readme).toBe("<p>cached</p>")
|
||||||
|
expect(store.loadError).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("surfaces loadError from getMainReadme when no cached readme is present", async () => {
|
||||||
|
vi.mocked(getCachedMainReadme).mockResolvedValue(null)
|
||||||
|
vi.mocked(getMainReadme).mockRejectedValue(
|
||||||
|
Object.assign(new Error("Unauthorized"), { status: 401 })
|
||||||
|
)
|
||||||
|
vi.spyOn(console, "warn").mockImplementation(() => {})
|
||||||
|
|
||||||
|
const store = useUserRepoStore()
|
||||||
|
await store.setUserRepo("alice", "notes")
|
||||||
|
await flushAsync()
|
||||||
|
await flushAsync()
|
||||||
|
|
||||||
|
expect(store.readme).toBeNull()
|
||||||
|
expect(store.loadError).toBe("auth")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("ignores stale getFiles results when a newer setUserRepo has been called (race guard)", async () => {
|
||||||
|
let resolveStale: (files: never[]) => void = () => {}
|
||||||
|
vi.mocked(getFiles).mockImplementationOnce(
|
||||||
|
() =>
|
||||||
|
new Promise<never[]>((r) => {
|
||||||
|
resolveStale = r
|
||||||
|
})
|
||||||
|
)
|
||||||
|
vi.mocked(getFiles).mockImplementationOnce(async () => [
|
||||||
|
{ sha: "fresh", path: "fresh.md", type: "blob" } as never
|
||||||
|
])
|
||||||
|
|
||||||
|
const store = useUserRepoStore()
|
||||||
|
|
||||||
|
await store.setUserRepo("alice", "stale-repo")
|
||||||
|
await store.setUserRepo("alice", "fresh-repo")
|
||||||
|
|
||||||
|
resolveStale([{ sha: "stale", path: "stale.md", type: "blob" } as never])
|
||||||
|
await flushAsync()
|
||||||
|
await flushAsync()
|
||||||
|
|
||||||
|
expect(store.repo).toBe("fresh-repo")
|
||||||
|
expect(store.files.map((f) => f.sha)).toEqual(["fresh"])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { defineStore } from "pinia"
|
import { defineStore } from "pinia"
|
||||||
|
import { toRaw } from "vue"
|
||||||
|
|
||||||
import { data } from "@/data/data"
|
import { data, generateId } from "@/data/data"
|
||||||
import { DataType } from "@/data/DataType.enum"
|
import { DataType } from "@/data/DataType.enum"
|
||||||
import { RepoFile } from "@/modules/repo/interfaces/RepoFile"
|
import { RepoFile } from "@/modules/repo/interfaces/RepoFile"
|
||||||
import { UserSettings } from "@/modules/repo/interfaces/UserSettings"
|
import { UserSettings } from "@/modules/repo/interfaces/UserSettings"
|
||||||
@@ -9,10 +10,13 @@ import {
|
|||||||
getCachedMainReadme,
|
getCachedMainReadme,
|
||||||
getFiles,
|
getFiles,
|
||||||
getMainReadme,
|
getMainReadme,
|
||||||
|
getRepoPermission,
|
||||||
getUserSettingsContent
|
getUserSettingsContent
|
||||||
} from "@/modules/repo/services/repo"
|
} from "@/modules/repo/services/repo"
|
||||||
import { refreshToken } from "@/modules/user/service/signIn"
|
import { refreshToken } from "@/modules/user/service/signIn"
|
||||||
|
|
||||||
|
export type RepoLoadError = "auth" | "network" | null
|
||||||
|
|
||||||
interface State {
|
interface State {
|
||||||
user: string
|
user: string
|
||||||
repo: string
|
repo: string
|
||||||
@@ -20,6 +24,19 @@ interface State {
|
|||||||
readme?: string | null
|
readme?: string | null
|
||||||
userSettings?: UserSettings | null
|
userSettings?: UserSettings | null
|
||||||
needToLogin: boolean
|
needToLogin: boolean
|
||||||
|
loadError: RepoLoadError
|
||||||
|
canPush: boolean
|
||||||
|
_requestId: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const classifyLoadError = (error: unknown): "auth" | "network" => {
|
||||||
|
if (error && typeof error === "object") {
|
||||||
|
const e = error as { name?: string; status?: number }
|
||||||
|
if (e.name === "TimeoutError" || e.name === "AbortError") return "network"
|
||||||
|
if (e.status === 401 || e.status === 403 || e.status === 404) return "auth"
|
||||||
|
if (typeof e.status === "number" && e.status >= 500) return "network"
|
||||||
|
}
|
||||||
|
return "network"
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useUserRepoStore = defineStore("USER_REPO_STATE", {
|
export const useUserRepoStore = defineStore("USER_REPO_STATE", {
|
||||||
@@ -29,40 +46,98 @@ export const useUserRepoStore = defineStore("USER_REPO_STATE", {
|
|||||||
files: [],
|
files: [],
|
||||||
readme: undefined,
|
readme: undefined,
|
||||||
userSettings: undefined,
|
userSettings: undefined,
|
||||||
needToLogin: false
|
needToLogin: false,
|
||||||
|
loadError: null,
|
||||||
|
canPush: false,
|
||||||
|
_requestId: 0
|
||||||
}),
|
}),
|
||||||
actions: {
|
actions: {
|
||||||
|
_persistLayout() {
|
||||||
|
if (!this.userSettings) return
|
||||||
|
try {
|
||||||
|
const {
|
||||||
|
chosenTitleFont,
|
||||||
|
chosenBodyFont,
|
||||||
|
chosenFontSize,
|
||||||
|
chosenFontFamily,
|
||||||
|
pageWidth
|
||||||
|
} = this.userSettings
|
||||||
|
localStorage.setItem(
|
||||||
|
`remanso:layout:${this.user}:${this.repo}`,
|
||||||
|
JSON.stringify({
|
||||||
|
chosenTitleFont,
|
||||||
|
chosenBodyFont,
|
||||||
|
chosenFontSize,
|
||||||
|
chosenFontFamily,
|
||||||
|
pageWidth
|
||||||
|
})
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
},
|
||||||
async setUserRepo(user: string, repo: string) {
|
async setUserRepo(user: string, repo: string) {
|
||||||
|
const requestId = ++this._requestId
|
||||||
this.user = user
|
this.user = user
|
||||||
this.repo = repo
|
this.repo = repo
|
||||||
|
this.loadError = null
|
||||||
|
this.canPush = false
|
||||||
|
|
||||||
const savedRepoId = data.generateId(DataType.SavedRepo, `${user}-${repo}`)
|
let lsLayout: Partial<UserSettings> = {}
|
||||||
const cachedSavedRepo = await data.get<DataType.SavedRepo, SavedRepo>(
|
try {
|
||||||
savedRepoId
|
const lsRaw = localStorage.getItem(`remanso:layout:${user}:${repo}`)
|
||||||
)
|
if (lsRaw) lsLayout = JSON.parse(lsRaw)
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(lsLayout).length) {
|
||||||
|
if (!this.userSettings)
|
||||||
|
this.userSettings = { $type: DataType.UserSettings }
|
||||||
|
Object.assign(this.userSettings, lsLayout)
|
||||||
|
}
|
||||||
|
|
||||||
|
const savedRepoId = generateId(DataType.SavedRepo, `${user}-${repo}`)
|
||||||
|
const userSettingsId = `UserSetting-${user}-${repo}`
|
||||||
|
|
||||||
|
const [cachedSavedRepo, cachedUserSettings] = await Promise.all([
|
||||||
|
data.get<DataType.SavedRepo, SavedRepo>(savedRepoId),
|
||||||
|
data.get<DataType.UserSettings, UserSettings>(userSettingsId)
|
||||||
|
])
|
||||||
|
|
||||||
|
if (requestId !== this._requestId) return
|
||||||
|
|
||||||
if (cachedSavedRepo) {
|
if (cachedSavedRepo) {
|
||||||
this.files = cachedSavedRepo.files
|
this.files = cachedSavedRepo.files
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (cachedUserSettings) {
|
||||||
|
// localStorage layout choices take priority over PouchDB cache
|
||||||
|
this.userSettings = { ...cachedUserSettings, ...lsLayout }
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await refreshToken()
|
await refreshToken()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn("impossible to refresh token", error)
|
console.warn("impossible to refresh token", error)
|
||||||
}
|
}
|
||||||
|
|
||||||
const userSettingsId = `UserSetting-${user}-${repo}`
|
if (requestId !== this._requestId) return
|
||||||
const cachedUserSettings = await data.get<
|
|
||||||
DataType.UserSettings,
|
|
||||||
UserSettings
|
|
||||||
>(userSettingsId)
|
|
||||||
|
|
||||||
if (cachedUserSettings) {
|
getRepoPermission(user, repo)
|
||||||
this.userSettings = cachedUserSettings
|
.then((canPush) => {
|
||||||
}
|
if (requestId !== this._requestId) return
|
||||||
|
this.canPush = canPush
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
if (requestId !== this._requestId) return
|
||||||
|
console.warn("getRepoPermission failed", error)
|
||||||
|
this.canPush = false
|
||||||
|
})
|
||||||
|
|
||||||
getFiles(user, repo)
|
getFiles(user, repo)
|
||||||
.then(async (files) => {
|
.then(async (files) => {
|
||||||
|
if (requestId !== this._requestId) return
|
||||||
data.update<DataType.SavedRepo, SavedRepo>({
|
data.update<DataType.SavedRepo, SavedRepo>({
|
||||||
_id: savedRepoId,
|
_id: savedRepoId,
|
||||||
$type: DataType.SavedRepo,
|
$type: DataType.SavedRepo,
|
||||||
@@ -74,6 +149,7 @@ export const useUserRepoStore = defineStore("USER_REPO_STATE", {
|
|||||||
return getUserSettingsContent(user, repo, files)
|
return getUserSettingsContent(user, repo, files)
|
||||||
})
|
})
|
||||||
.then((userSettings) => {
|
.then((userSettings) => {
|
||||||
|
if (requestId !== this._requestId) return
|
||||||
const chosenFontFamily = userSettings?.fontFamilies?.find(
|
const chosenFontFamily = userSettings?.fontFamilies?.find(
|
||||||
(font) => font === this.userSettings?.chosenFontFamily
|
(font) => font === this.userSettings?.chosenFontFamily
|
||||||
)
|
)
|
||||||
@@ -81,26 +157,64 @@ export const useUserRepoStore = defineStore("USER_REPO_STATE", {
|
|||||||
: userSettings?.fontFamily
|
: userSettings?.fontFamily
|
||||||
const chosenFontSize =
|
const chosenFontSize =
|
||||||
this.userSettings?.chosenFontSize ?? userSettings?.fontSize
|
this.userSettings?.chosenFontSize ?? userSettings?.fontSize
|
||||||
|
const chosenTitleFont =
|
||||||
|
this.userSettings?.chosenTitleFont ??
|
||||||
|
userSettings?.chosenTitleFont ??
|
||||||
|
chosenFontFamily
|
||||||
|
const chosenBodyFont =
|
||||||
|
this.userSettings?.chosenBodyFont ??
|
||||||
|
userSettings?.chosenBodyFont ??
|
||||||
|
chosenFontFamily
|
||||||
|
if (userSettings) {
|
||||||
this.userSettings = userSettings
|
this.userSettings = userSettings
|
||||||
|
} else if (!this.userSettings) {
|
||||||
if (!this.userSettings) {
|
this.userSettings = { $type: DataType.UserSettings }
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
this.userSettings.chosenFontFamily =
|
this.userSettings.chosenFontFamily =
|
||||||
chosenFontFamily ?? this.userSettings.fontFamily
|
chosenFontFamily ?? this.userSettings.fontFamily
|
||||||
this.userSettings.chosenFontSize =
|
this.userSettings.chosenFontSize =
|
||||||
chosenFontSize ?? this.userSettings.fontSize
|
chosenFontSize ?? this.userSettings.fontSize
|
||||||
|
this.userSettings.chosenTitleFont = chosenTitleFont
|
||||||
|
this.userSettings.chosenBodyFont = chosenBodyFont
|
||||||
|
|
||||||
|
this._persistLayout()
|
||||||
|
|
||||||
|
// Persist only repo config fields — chosen* are localStorage-only
|
||||||
|
const {
|
||||||
|
chosenTitleFont: _t,
|
||||||
|
chosenBodyFont: _b,
|
||||||
|
chosenFontSize: _s,
|
||||||
|
chosenFontFamily: _f,
|
||||||
|
...repoConfig
|
||||||
|
} = toRaw(this.userSettings)
|
||||||
data.update<DataType.UserSettings, UserSettings>({
|
data.update<DataType.UserSettings, UserSettings>({
|
||||||
...this.userSettings,
|
...repoConfig,
|
||||||
_id: userSettingsId
|
_id: userSettingsId
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
if (requestId !== this._requestId) return
|
||||||
|
console.warn("getFiles failed", error)
|
||||||
|
this.loadError = classifyLoadError(error)
|
||||||
|
})
|
||||||
|
|
||||||
getCachedMainReadme(user, repo).then(async (cachedReadme) => {
|
getCachedMainReadme(user, repo)
|
||||||
this.readme = cachedReadme
|
.then(async (cachedReadme) => {
|
||||||
this.readme = await getMainReadme(user, repo)
|
if (requestId !== this._requestId) return
|
||||||
|
if (cachedReadme) this.readme = cachedReadme
|
||||||
|
const fetched = await getMainReadme(user, repo)
|
||||||
|
if (requestId !== this._requestId) return
|
||||||
|
this.readme = fetched
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
if (requestId !== this._requestId) return
|
||||||
|
console.warn("getMainReadme failed", error)
|
||||||
|
// Only surface the error UI if we have nothing cached to display.
|
||||||
|
if (!this.readme) {
|
||||||
|
this.readme = null
|
||||||
|
this.loadError = classifyLoadError(error)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
addFile(file: RepoFile) {
|
addFile(file: RepoFile) {
|
||||||
@@ -114,11 +228,36 @@ export const useUserRepoStore = defineStore("USER_REPO_STATE", {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const savedRepoId = data.generateId(
|
const savedRepoId = generateId(
|
||||||
DataType.SavedRepo,
|
DataType.SavedRepo,
|
||||||
`${this.user}-${this.repo}`
|
`${this.user}-${this.repo}`
|
||||||
)
|
)
|
||||||
const newFiles = [...this.files.filter((f) => f.sha !== file.sha), file]
|
const newFiles = [
|
||||||
|
...toRaw(this.files).filter((f) => f.sha !== file.sha),
|
||||||
|
toRaw(file)
|
||||||
|
]
|
||||||
|
data.update<DataType.SavedRepo, SavedRepo>({
|
||||||
|
_id: savedRepoId,
|
||||||
|
$type: DataType.SavedRepo,
|
||||||
|
repo: this.repo,
|
||||||
|
user: this.user,
|
||||||
|
files: newFiles
|
||||||
|
})
|
||||||
|
this.files = newFiles
|
||||||
|
},
|
||||||
|
registerUploadedFile(file: RepoFile) {
|
||||||
|
if (!file.path) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const savedRepoId = generateId(
|
||||||
|
DataType.SavedRepo,
|
||||||
|
`${this.user}-${this.repo}`
|
||||||
|
)
|
||||||
|
const newFiles = [
|
||||||
|
...toRaw(this.files).filter((f) => f.path !== file.path),
|
||||||
|
toRaw(file)
|
||||||
|
]
|
||||||
data.update<DataType.SavedRepo, SavedRepo>({
|
data.update<DataType.SavedRepo, SavedRepo>({
|
||||||
_id: savedRepoId,
|
_id: savedRepoId,
|
||||||
$type: DataType.SavedRepo,
|
$type: DataType.SavedRepo,
|
||||||
@@ -132,35 +271,40 @@ export const useUserRepoStore = defineStore("USER_REPO_STATE", {
|
|||||||
this.user = ""
|
this.user = ""
|
||||||
this.repo = ""
|
this.repo = ""
|
||||||
this.resetFiles()
|
this.resetFiles()
|
||||||
|
this.userSettings = undefined
|
||||||
},
|
},
|
||||||
resetFiles() {
|
resetFiles() {
|
||||||
this.files = []
|
this.files = []
|
||||||
this.readme = null
|
this.readme = null
|
||||||
this.userSettings = undefined
|
this.canPush = false
|
||||||
},
|
},
|
||||||
setFontFamily(fontFamily: string) {
|
setFontFamily(fontFamily: string) {
|
||||||
if (!this.userSettings) {
|
if (!this.userSettings) {
|
||||||
return
|
this.userSettings = { $type: DataType.UserSettings }
|
||||||
}
|
}
|
||||||
this.userSettings.chosenFontFamily = fontFamily
|
this.userSettings.chosenFontFamily = fontFamily
|
||||||
|
this._persistLayout()
|
||||||
const userSettingsId = `UserSetting-${this.user}-${this.repo}`
|
|
||||||
data.update<DataType.UserSettings, UserSettings>({
|
|
||||||
...this.userSettings,
|
|
||||||
_id: userSettingsId
|
|
||||||
})
|
|
||||||
},
|
},
|
||||||
setFontSize(fontSize: string) {
|
setFontSize(fontSize: string) {
|
||||||
if (!this.userSettings) {
|
if (!this.userSettings) {
|
||||||
return
|
this.userSettings = { $type: DataType.UserSettings }
|
||||||
}
|
}
|
||||||
this.userSettings.chosenFontSize = fontSize
|
this.userSettings.chosenFontSize = fontSize
|
||||||
|
this._persistLayout()
|
||||||
const userSettingsId = `UserSetting-${this.user}-${this.repo}`
|
},
|
||||||
data.update<DataType.UserSettings, UserSettings>({
|
setTitleFont(font: string) {
|
||||||
...this.userSettings,
|
if (!this.userSettings) {
|
||||||
_id: userSettingsId
|
this.userSettings = { $type: DataType.UserSettings }
|
||||||
})
|
}
|
||||||
|
this.userSettings.chosenTitleFont = font
|
||||||
|
this._persistLayout()
|
||||||
|
},
|
||||||
|
setBodyFont(font: string) {
|
||||||
|
if (!this.userSettings) {
|
||||||
|
this.userSettings = { $type: DataType.UserSettings }
|
||||||
|
}
|
||||||
|
this.userSettings.chosenBodyFont = font
|
||||||
|
this._persistLayout()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { watchEffect } from "vue"
|
import { watchEffect } from "vue"
|
||||||
|
|
||||||
|
import { resetNoteWidthCache } from "@/constants/note-width"
|
||||||
import { useUserRepoStore } from "@/modules/repo/store/userRepo.store"
|
import { useUserRepoStore } from "@/modules/repo/store/userRepo.store"
|
||||||
import { downloadFont } from "@/utils/downloadFont"
|
import { downloadFont } from "@/utils/downloadFont"
|
||||||
|
|
||||||
const DEFAULT_FONT_POLICY = '"Libertinus Serif", serif'
|
const DEFAULT_FONT_POLICY = '"Libertinus Serif", serif'
|
||||||
const DEFAULT_FONT_SIZE = "16px"
|
const DEFAULT_FONT_SIZE = "16px"
|
||||||
|
const DEFAULT_NOTE_WIDTH = "500px"
|
||||||
|
|
||||||
export const useUserSettings = () => {
|
export const useUserSettings = () => {
|
||||||
const store = useUserRepoStore()
|
const store = useUserRepoStore()
|
||||||
@@ -12,10 +14,19 @@ export const useUserSettings = () => {
|
|||||||
watchEffect(() => {
|
watchEffect(() => {
|
||||||
const root = document.documentElement
|
const root = document.documentElement
|
||||||
|
|
||||||
const fontFamily = store.userSettings?.chosenFontFamily
|
|
||||||
const fontSize = store.userSettings?.chosenFontSize
|
const fontSize = store.userSettings?.chosenFontSize
|
||||||
|
const bodyFont = store.userSettings?.chosenBodyFont
|
||||||
|
const titleFont = store.userSettings?.chosenTitleFont
|
||||||
|
|
||||||
downloadFont(fontFamily || DEFAULT_FONT_POLICY)
|
downloadFont(bodyFont || DEFAULT_FONT_POLICY, "--font-family")
|
||||||
|
downloadFont(
|
||||||
|
titleFont || bodyFont || DEFAULT_FONT_POLICY,
|
||||||
|
"--title-font-family"
|
||||||
|
)
|
||||||
root.style.setProperty("--font-size", fontSize || DEFAULT_FONT_SIZE)
|
root.style.setProperty("--font-size", fontSize || DEFAULT_FONT_SIZE)
|
||||||
|
|
||||||
|
const pageWidth = store.userSettings?.pageWidth ?? DEFAULT_NOTE_WIDTH
|
||||||
|
root.style.setProperty("--note-width", pageWidth)
|
||||||
|
resetNoteWidthCache()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
30
src/modules/user/service/oauthReturnPath.spec.ts
Normal file
30
src/modules/user/service/oauthReturnPath.spec.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { beforeEach, describe, expect, it } from "vitest"
|
||||||
|
|
||||||
|
import {
|
||||||
|
consumeGithubOAuthReturnPath,
|
||||||
|
GITHUB_OAUTH_RETURN_PATH_KEY
|
||||||
|
} from "./oauthReturnPath"
|
||||||
|
|
||||||
|
describe("consumeGithubOAuthReturnPath", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
sessionStorage.clear()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns the stored path and clears the key", () => {
|
||||||
|
sessionStorage.setItem(GITHUB_OAUTH_RETURN_PATH_KEY, "/alice/notes")
|
||||||
|
|
||||||
|
expect(consumeGithubOAuthReturnPath()).toBe("/alice/notes")
|
||||||
|
expect(sessionStorage.getItem(GITHUB_OAUTH_RETURN_PATH_KEY)).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns null when nothing is stored", () => {
|
||||||
|
expect(consumeGithubOAuthReturnPath()).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns null on the second call (consume-once semantics)", () => {
|
||||||
|
sessionStorage.setItem(GITHUB_OAUTH_RETURN_PATH_KEY, "/x")
|
||||||
|
|
||||||
|
expect(consumeGithubOAuthReturnPath()).toBe("/x")
|
||||||
|
expect(consumeGithubOAuthReturnPath()).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
7
src/modules/user/service/oauthReturnPath.ts
Normal file
7
src/modules/user/service/oauthReturnPath.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
export const GITHUB_OAUTH_RETURN_PATH_KEY = "github-oauth-return-path"
|
||||||
|
|
||||||
|
export const consumeGithubOAuthReturnPath = (): string | null => {
|
||||||
|
const path = sessionStorage.getItem(GITHUB_OAUTH_RETURN_PATH_KEY)
|
||||||
|
sessionStorage.removeItem(GITHUB_OAUTH_RETURN_PATH_KEY)
|
||||||
|
return path
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Octokit } from "@octokit/rest"
|
import { Octokit } from "@octokit/rest"
|
||||||
import { addMinutes, addSeconds, isBefore } from "date-fns"
|
import { addMinutes, addSeconds, isBefore } from "date-fns"
|
||||||
|
|
||||||
import { data } from "@/data/data"
|
import { data, generateId } from "@/data/data"
|
||||||
import { DataType } from "@/data/DataType.enum"
|
import { DataType } from "@/data/DataType.enum"
|
||||||
import { GithubAccessToken } from "@/data/models/GithubAccessToken"
|
import { GithubAccessToken } from "@/data/models/GithubAccessToken"
|
||||||
import { GithubToken } from "@/modules/user/interfaces/GithubToken"
|
import { GithubToken } from "@/modules/user/interfaces/GithubToken"
|
||||||
@@ -26,7 +26,7 @@ export const needToRefreshToken = async () => {
|
|||||||
const accessToken = await data.get<
|
const accessToken = await data.get<
|
||||||
DataType.GithubAccessToken,
|
DataType.GithubAccessToken,
|
||||||
GithubAccessToken
|
GithubAccessToken
|
||||||
>(data.generateId(DataType.GithubAccessToken, personalTokenId))
|
>(generateId(DataType.GithubAccessToken, personalTokenId))
|
||||||
|
|
||||||
if (!accessToken) {
|
if (!accessToken) {
|
||||||
return false
|
return false
|
||||||
@@ -38,17 +38,23 @@ export const needToRefreshToken = async () => {
|
|||||||
return isBefore(expirationDate, minimumViableDate)
|
return isBefore(expirationDate, minimumViableDate)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const refreshToken = async () => {
|
let inFlightRefresh: Promise<GithubAccessToken | null> | null = null
|
||||||
|
|
||||||
|
const performRefresh = async ({
|
||||||
|
force
|
||||||
|
}: {
|
||||||
|
force: boolean
|
||||||
|
}): Promise<GithubAccessToken | null> => {
|
||||||
const accessToken = await data.get<
|
const accessToken = await data.get<
|
||||||
DataType.GithubAccessToken,
|
DataType.GithubAccessToken,
|
||||||
GithubAccessToken
|
GithubAccessToken
|
||||||
>(data.generateId(DataType.GithubAccessToken, personalTokenId))
|
>(generateId(DataType.GithubAccessToken, personalTokenId))
|
||||||
|
|
||||||
if (!accessToken) {
|
if (!accessToken) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
const needRefresh = await needToRefreshToken()
|
const needRefresh = force || (await needToRefreshToken())
|
||||||
|
|
||||||
if (needRefresh) {
|
if (needRefresh) {
|
||||||
const authenticationServerURL = new URL(AUTHENTICATION_SERVER)
|
const authenticationServerURL = new URL(AUTHENTICATION_SERVER)
|
||||||
@@ -70,11 +76,23 @@ export const refreshToken = async () => {
|
|||||||
return accessToken
|
return accessToken
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GitHub refresh tokens are single-use, so concurrent refreshes race and the
|
||||||
|
// loser ends up with a revoked refresh token. Dedupe in-flight calls.
|
||||||
|
export const refreshToken = async ({
|
||||||
|
force = false
|
||||||
|
}: { force?: boolean } = {}): Promise<GithubAccessToken | null> => {
|
||||||
|
if (inFlightRefresh) return inFlightRefresh
|
||||||
|
inFlightRefresh = performRefresh({ force }).finally(() => {
|
||||||
|
inFlightRefresh = null
|
||||||
|
})
|
||||||
|
return inFlightRefresh
|
||||||
|
}
|
||||||
|
|
||||||
export const getAccessToken = async () => {
|
export const getAccessToken = async () => {
|
||||||
const response = await data.get<
|
const response = await data.get<
|
||||||
DataType.GithubAccessToken,
|
DataType.GithubAccessToken,
|
||||||
GithubAccessToken
|
GithubAccessToken
|
||||||
>(data.generateId(DataType.GithubAccessToken, personalTokenId))
|
>(generateId(DataType.GithubAccessToken, personalTokenId))
|
||||||
|
|
||||||
return response
|
return response
|
||||||
}
|
}
|
||||||
@@ -94,7 +112,7 @@ export const saveAccessToken = async (githubToken: GithubToken) => {
|
|||||||
|
|
||||||
const accessToken: GithubAccessToken = {
|
const accessToken: GithubAccessToken = {
|
||||||
...actualPAT,
|
...actualPAT,
|
||||||
_id: data.generateId(DataType.GithubAccessToken, personalTokenId),
|
_id: generateId(DataType.GithubAccessToken, personalTokenId),
|
||||||
$type: DataType.GithubAccessToken,
|
$type: DataType.GithubAccessToken,
|
||||||
token: githubToken.access_token,
|
token: githubToken.access_token,
|
||||||
expiresIn: githubToken.expires_in,
|
expiresIn: githubToken.expires_in,
|
||||||
|
|||||||
@@ -95,8 +95,9 @@ export const router = createRouter({
|
|||||||
routes
|
routes
|
||||||
})
|
})
|
||||||
|
|
||||||
router.beforeEach(() => {
|
router.beforeEach((to, from) => {
|
||||||
if (!("startViewTransition" in document)) return
|
if (!("startViewTransition" in document)) return
|
||||||
|
if (to.path === from.path) return
|
||||||
return new Promise<void>((resolve) => {
|
return new Promise<void>((resolve) => {
|
||||||
;(
|
;(
|
||||||
document as Document & {
|
document as Document & {
|
||||||
@@ -104,7 +105,12 @@ router.beforeEach(() => {
|
|||||||
}
|
}
|
||||||
).startViewTransition(async () => {
|
).startViewTransition(async () => {
|
||||||
resolve()
|
resolve()
|
||||||
await nextTick()
|
await new Promise<void>((r) => {
|
||||||
|
const unwatch = router.afterEach(() => {
|
||||||
|
unwatch()
|
||||||
|
nextTick().then(r)
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
6
src/shims-vue.d.ts
vendored
6
src/shims-vue.d.ts
vendored
@@ -1,3 +1,9 @@
|
|||||||
|
declare module "*.vue" {
|
||||||
|
import type { DefineComponent } from "vue"
|
||||||
|
const component: DefineComponent<{}, {}, any>
|
||||||
|
export default component
|
||||||
|
}
|
||||||
|
|
||||||
declare module "pouchdb-adapter-indexeddb"
|
declare module "pouchdb-adapter-indexeddb"
|
||||||
declare module "@toycode/markdown-it-class"
|
declare module "@toycode/markdown-it-class"
|
||||||
declare module "markdown-it-block-embed"
|
declare module "markdown-it-block-embed"
|
||||||
|
|||||||
@@ -6,12 +6,19 @@
|
|||||||
:root {
|
:root {
|
||||||
--primary-color: #ffa4c0;
|
--primary-color: #ffa4c0;
|
||||||
--font-family: "Libertinus Serif", serif;
|
--font-family: "Libertinus Serif", serif;
|
||||||
|
--title-font-family: "Libertinus Serif", serif;
|
||||||
--font-size: 13pt;
|
--font-size: 13pt;
|
||||||
--font-color: #4a4a4a;
|
--font-color: #4a4a4a;
|
||||||
--link: #445fb9;
|
--link: #445fb9;
|
||||||
--light-link: lighten(#445fb9, 45%);
|
--light-link: lighten(#445fb9, 45%);
|
||||||
--background-color: #ffffff;
|
--background-color: #ffffff;
|
||||||
--note-width: 500px;
|
--note-width: 500px;
|
||||||
|
--note-canvas-bg: color-mix(
|
||||||
|
in oklch,
|
||||||
|
var(--color-base-100) 60%,
|
||||||
|
var(--color-base-200)
|
||||||
|
);
|
||||||
|
--note-sheet-shadow: 1px 0 8px rgb(0 0 0 / 6%);
|
||||||
--color-contrast-content: var(--color-success);
|
--color-contrast-content: var(--color-success);
|
||||||
--notyf-margin: 0.5rem;
|
--notyf-margin: 0.5rem;
|
||||||
}
|
}
|
||||||
@@ -24,8 +31,8 @@
|
|||||||
|
|
||||||
@plugin 'daisyui' {
|
@plugin 'daisyui' {
|
||||||
themes:
|
themes:
|
||||||
garden --default,
|
light --default,
|
||||||
dim --prefersdark;
|
dracula --prefersdark;
|
||||||
}
|
}
|
||||||
|
|
||||||
@config '../../tailwind.config.js';
|
@config '../../tailwind.config.js';
|
||||||
@@ -48,21 +55,17 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
html {
|
html,
|
||||||
overflow-y: auto;
|
|
||||||
overflow-x: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
body {
|
||||||
height: 100vh;
|
height: 100dvh;
|
||||||
|
overflow: hidden;
|
||||||
scroll-behavior: smooth;
|
scroll-behavior: smooth;
|
||||||
overflow-y: auto;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media screen and (min-width: 769px) {
|
@media screen and (max-width: 768px) {
|
||||||
html,
|
html,
|
||||||
body {
|
body {
|
||||||
overflow-y: hidden;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,7 +80,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#app {
|
#app {
|
||||||
height: 100vh;
|
height: 100dvh;
|
||||||
display: flex;
|
display: flex;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,18 +95,36 @@ a {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
a.title-stacked-note-link {
|
.title-stacked-note {
|
||||||
color: var(--color-base-content);
|
color: var(--color-base-content);
|
||||||
display: block;
|
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 0;
|
top: 0;
|
||||||
|
overflow: visible;
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
a.title-stacked-note-link {
|
||||||
|
display: block;
|
||||||
|
overflow: visible;
|
||||||
&:hover {
|
&:hover {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.title-stacked-note ul,
|
||||||
|
.title-stacked-note li {
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 0;
|
||||||
|
padding-left: 0;
|
||||||
|
text-decoration: none;
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
.notyf__toast {
|
.notyf__toast {
|
||||||
border-radius: revert-layer;
|
border-radius: revert-layer;
|
||||||
border: none;
|
border: none;
|
||||||
@@ -161,6 +182,10 @@ pre {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:not(pre) > code {
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
pre.mermaid {
|
pre.mermaid {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -168,6 +193,89 @@ pre.mermaid {
|
|||||||
background-color: var(--color-base-content);
|
background-color: var(--color-base-content);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pre.tikz {
|
||||||
|
display: block;
|
||||||
|
overflow-x: auto;
|
||||||
|
text-align: center;
|
||||||
|
padding: 0.5rem;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
pre.tikz svg {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* TikZ SVGs are monochrome black; invert them on dark themes so they
|
||||||
|
remain readable on the transparent background. */
|
||||||
|
[data-theme="dracula"] pre.tikz svg {
|
||||||
|
filter: invert(1) hue-rotate(180deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root:not([data-theme="light"]) pre.tikz svg {
|
||||||
|
filter: invert(1) hue-rotate(180deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tikz-loading {
|
||||||
|
opacity: 0.6;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.svg-download-host {
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.svg-download-buttons {
|
||||||
|
position: absolute;
|
||||||
|
top: 0.25rem;
|
||||||
|
right: 0.25rem;
|
||||||
|
display: flex;
|
||||||
|
gap: 0.25rem;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 150ms ease-in-out;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.svg-download-host:hover .svg-download-buttons,
|
||||||
|
.svg-download-host:focus-within .svg-download-buttons {
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.svg-download-button {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
padding-inline: 0.4rem;
|
||||||
|
height: 1.5rem;
|
||||||
|
min-height: 1.5rem;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.svg-download-button .icon-tabler-download {
|
||||||
|
width: 0.9rem;
|
||||||
|
height: 0.9rem;
|
||||||
|
max-width: none;
|
||||||
|
filter: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tikz-error {
|
||||||
|
border-left: 3px solid var(--color-error);
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
background: color-mix(in srgb, var(--color-error) 10%, transparent);
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tikz-error pre {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
font-size: 0.85em;
|
||||||
|
}
|
||||||
|
|
||||||
.markdown-alert {
|
.markdown-alert {
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
background-color: #ecf0f1;
|
background-color: #ecf0f1;
|
||||||
@@ -228,11 +336,11 @@ iframe {
|
|||||||
height: 400px;
|
height: 400px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* TODO page */
|
.tabs :where(pre):not(:where([class~="not-prose"], [class~="not-prose"] *)) {
|
||||||
.todo-notes input[type="checkbox"] {
|
margin-top: 0;
|
||||||
@apply checkbox checkbox-success;
|
margin-bottom: 0;
|
||||||
}
|
padding-top: 0.2rem;
|
||||||
|
padding-inline-end: 0.2rem;
|
||||||
.todo-notes li:has(> input[type="checkbox"]) {
|
padding-bottom: 0.2rem;
|
||||||
list-style: none;
|
padding-inline-start: 0.2rem;
|
||||||
}
|
}
|
||||||
|
|||||||
8
src/test/setup.ts
Normal file
8
src/test/setup.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { config } from "@vue/test-utils"
|
||||||
|
|
||||||
|
config.global.stubs = {
|
||||||
|
"router-link": true,
|
||||||
|
"router-view": true,
|
||||||
|
transition: false,
|
||||||
|
"transition-group": false
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user