Compare commits
181 Commits
330bc5b41d
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | |||
|
|
c06253e509 | ||
|
|
767093c008 | ||
|
|
3a32cb5948 | ||
|
|
5f48aa5690 | ||
|
|
8e8706e258 | ||
|
|
d457fd4064 | ||
|
|
1aef212a36 | ||
|
|
d1bb9fa182 | ||
|
|
80170b9f62 | ||
|
|
ed5da7844a | ||
|
|
12676135f6 | ||
|
|
32f79785a8 | ||
|
|
c0b1a33c69 | ||
|
|
1fc66289a4 | ||
|
|
db27b03f21 | ||
|
|
72b704a54d | ||
|
|
b6d5ad5d4b | ||
|
|
694c2fcae9 | ||
|
|
dfdd646eb1 | ||
|
|
3c736124e8 | ||
|
|
53c444ed72 | ||
|
|
29e56304c4 | ||
|
|
ddabe5082d | ||
|
|
52561496b4 | ||
|
|
0ed2906782 | ||
|
|
944b128894 | ||
|
|
514d08946d | ||
|
|
ff6fe59f3a | ||
|
|
0381ca00cc | ||
|
|
163e3ee756 | ||
|
|
5d145dd7ff | ||
|
|
40c461e150 | ||
|
|
4d04d174ba | ||
|
|
16efd8c637 |
13
.claude/settings.json
Normal file
13
.claude/settings.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"extraKnownMarketplaces": {
|
||||
"remanso-local": {
|
||||
"source": {
|
||||
"source": "directory",
|
||||
"path": "."
|
||||
}
|
||||
}
|
||||
},
|
||||
"enabledPlugins": {
|
||||
"remanso-skills@remanso-local": true
|
||||
}
|
||||
}
|
||||
@@ -5,10 +5,6 @@
|
||||
"label": "Vue i18n",
|
||||
"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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
6
.dockerignore
Normal file
6
.dockerignore
Normal file
@@ -0,0 +1,6 @@
|
||||
node_modules
|
||||
dist
|
||||
.git
|
||||
.gitignore
|
||||
*.md
|
||||
.env*
|
||||
53
.eslintrc.js
53
.eslintrc.js
@@ -1,53 +0,0 @@
|
||||
require("@rushstack/eslint-patch/modern-module-resolution")
|
||||
|
||||
const DEV_TOOL_ACTIVATED =
|
||||
process.env.NODE_ENV === "production" ? "warn" : "off"
|
||||
|
||||
module.exports = {
|
||||
root: true,
|
||||
env: {
|
||||
node: true,
|
||||
es2022: true,
|
||||
},
|
||||
extends: ["plugin:vue/vue3-essential", "@vue/eslint-config-typescript"],
|
||||
plugins: ["simple-import-sort", "unused-imports"],
|
||||
rules: {
|
||||
"no-console": DEV_TOOL_ACTIVATED,
|
||||
"no-debugger": DEV_TOOL_ACTIVATED,
|
||||
"@typescript-eslint/explicit-module-boundary-types": "off",
|
||||
"@typescript-eslint/camelcase": "off",
|
||||
"prettier-vue/prettier": [
|
||||
"error",
|
||||
{
|
||||
semi: false,
|
||||
singleQuote: true,
|
||||
trailingComma: "none",
|
||||
arrowParens: "always",
|
||||
},
|
||||
],
|
||||
"vue/no-v-html": "off",
|
||||
"no-restricted-imports": [
|
||||
"error",
|
||||
{
|
||||
paths: [
|
||||
{
|
||||
name: "vue-demi",
|
||||
importNames: ["computed"],
|
||||
message: "Please use computed from vue instead.",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
"simple-import-sort/imports": "error",
|
||||
"simple-import-sort/exports": "error",
|
||||
"unused-imports/no-unused-imports": "error",
|
||||
},
|
||||
overrides: [
|
||||
{
|
||||
files: [
|
||||
"**/__tests__/*.{j,t}s?(x)",
|
||||
"**/tests/unit/**/*.spec.{j,t}s?(x)",
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -2,7 +2,6 @@
|
||||
node_modules
|
||||
/dist
|
||||
|
||||
|
||||
# local env files
|
||||
.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."
|
||||
9
.oxfmtrc.json
Normal file
9
.oxfmtrc.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"$schema": "./node_modules/oxfmt/configuration_schema.json",
|
||||
"semi": false,
|
||||
"singleQuote": false,
|
||||
"trailingComma": "none",
|
||||
"printWidth": 80,
|
||||
"sortPackageJson": false,
|
||||
"ignorePatterns": []
|
||||
}
|
||||
40
.oxlintrc.json
Normal file
40
.oxlintrc.json
Normal file
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||
"plugins": ["typescript"],
|
||||
"jsPlugins": [
|
||||
"eslint-plugin-prettier-vue",
|
||||
"eslint-plugin-simple-import-sort",
|
||||
"eslint-plugin-unused-imports"
|
||||
],
|
||||
"categories": {
|
||||
"correctness": "off"
|
||||
},
|
||||
"env": {
|
||||
"builtin": true
|
||||
},
|
||||
"rules": {
|
||||
"no-restricted-imports": [
|
||||
"error",
|
||||
{
|
||||
"paths": [
|
||||
{
|
||||
"name": "vue-demi",
|
||||
"importNames": ["computed"],
|
||||
"message": "Please use computed from vue instead."
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"simple-import-sort/imports": "error",
|
||||
"simple-import-sort/exports": "error",
|
||||
"unused-imports/no-unused-imports": "error"
|
||||
},
|
||||
"overrides": [
|
||||
{
|
||||
"files": ["**/*.vue"],
|
||||
"rules": {
|
||||
"unused-imports/no-unused-imports": "off"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"semi": false
|
||||
}
|
||||
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", "..."]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -51,7 +51,7 @@ src/
|
||||
│ ├── card/ # Spaced repetition
|
||||
│ ├── history/ # Edit history tracking
|
||||
│ ├── atproto/ # ATProto/Bluesky integration (DID resolution, blob URLs)
|
||||
│ └── post/ # ts-rest API client for public note publishing (api.litenote.li212.fr)
|
||||
│ └── post/ # ts-rest API client for public note publishing (api.remanso.space)
|
||||
├── hooks/ # Composition hooks (useMarkdown, useBacklinks, useGitHubContent, etc.)
|
||||
├── data/ # PouchDB wrapper and data models
|
||||
├── utils/ # Utilities including custom markdown-it plugins
|
||||
|
||||
33
Dockerfile
Normal file
33
Dockerfile
Normal file
@@ -0,0 +1,33 @@
|
||||
# ---- Stage 1: deps (only invalidated when lockfile changes) ----
|
||||
FROM node:22-alpine AS deps
|
||||
|
||||
RUN corepack enable
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
||||
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
|
||||
# ---- Stage 2: build (invalidated on any source change) ----
|
||||
FROM node:22-alpine AS builder
|
||||
|
||||
RUN corepack enable
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN pnpm run build
|
||||
|
||||
|
||||
# ---- Stage 3: serve ----
|
||||
FROM nginx:alpine AS runner
|
||||
|
||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
EXPOSE 80
|
||||
@@ -1,3 +1,7 @@
|
||||
# Remanso
|
||||
|
||||
Welcome to Remanso!
|
||||
|
||||
---
|
||||
|
||||
[Remanso website](https://remanso.space)
|
||||
|
||||
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)
|
||||
})
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
import { readFileSync, writeFileSync } from "fs"
|
||||
import { join } from "path"
|
||||
|
||||
import { commitTheme } from "./change-theme"
|
||||
|
||||
// Chemins vers les fichiers
|
||||
@@ -27,8 +28,8 @@ let themeConfigContent = readFileSync(themeConfigPath, "utf8")
|
||||
|
||||
// Remplacer la valeur du thème sombre
|
||||
themeConfigContent = themeConfigContent.replace(
|
||||
/dark:\s*['"][^'"]*['"],/,
|
||||
`dark: '${newTheme}',`,
|
||||
/dark:\s*['"][^'"]*['"](,?)/,
|
||||
`dark: '${newTheme}'$1`
|
||||
)
|
||||
|
||||
// Écrire le contenu mis à jour dans le fichier
|
||||
@@ -38,7 +39,7 @@ writeFileSync(themeConfigPath, themeConfigContent)
|
||||
let appCssContent = readFileSync(appCssPath, "utf8")
|
||||
appCssContent = appCssContent.replace(
|
||||
/(\s+)([a-zA-Z0-9-]+)(\s+--prefersdark;)/,
|
||||
`$1${newTheme}$3`,
|
||||
`$1${newTheme}$3`
|
||||
)
|
||||
writeFileSync(appCssPath, appCssContent)
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
import { readFileSync, writeFileSync } from "fs"
|
||||
import { join } from "path"
|
||||
|
||||
import { commitTheme } from "./change-theme"
|
||||
|
||||
// Chemins vers les fichiers
|
||||
@@ -29,7 +30,7 @@ let themeConfigContent = readFileSync(themeConfigPath, "utf8")
|
||||
// Remplacer la valeur du thème clair
|
||||
themeConfigContent = themeConfigContent.replace(
|
||||
/light:\s*['"][^'"]*['"],/,
|
||||
`light: '${newTheme}',`,
|
||||
`light: '${newTheme}',`
|
||||
)
|
||||
|
||||
// Écrire le contenu mis à jour dans le fichier
|
||||
@@ -39,7 +40,7 @@ writeFileSync(themeConfigPath, themeConfigContent)
|
||||
let indexContent = readFileSync(indexPath, "utf8")
|
||||
indexContent = indexContent.replace(
|
||||
/data-theme="[^"]*"/,
|
||||
`data-theme="${newTheme}"`,
|
||||
`data-theme="${newTheme}"`
|
||||
)
|
||||
writeFileSync(indexPath, indexContent)
|
||||
|
||||
@@ -47,7 +48,7 @@ writeFileSync(indexPath, indexContent)
|
||||
let appCssContent = readFileSync(appCssPath, "utf8")
|
||||
appCssContent = appCssContent.replace(
|
||||
/(\s+)([a-zA-Z0-9-]+)(\s+--default,)/,
|
||||
`$1${newTheme}$3`,
|
||||
`$1${newTheme}$3`
|
||||
)
|
||||
writeFileSync(appCssPath, appCssContent)
|
||||
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
module.exports = {
|
||||
presets: ['@vue/cli-plugin-babel/preset']
|
||||
presets: ["@vue/cli-plugin-babel/preset"]
|
||||
}
|
||||
|
||||
78
docs/bugs/rolldown-while-in-globalThis.md
Normal file
78
docs/bugs/rolldown-while-in-globalThis.md
Normal file
@@ -0,0 +1,78 @@
|
||||
# Rolldown minifier drops `while(` keyword — invalid syntax in Safari
|
||||
|
||||
**Status:** To be filed
|
||||
**Workaround applied:** `build: { minify: "esbuild" }` in `vite.config.mts`
|
||||
|
||||
## Summary
|
||||
|
||||
Rolldown's minifier drops the `while(`/`for(;` keyword when a `while (x in globalThis)` loop is bundled alongside other module-level variable declarations. The resulting output is a syntax error that crashes Safari.
|
||||
|
||||
## Target repositories
|
||||
|
||||
- <https://github.com/rolldown/rolldown/issues>
|
||||
- <https://github.com/oxc-project/oxc/issues> (underlying minifier)
|
||||
|
||||
## Environment
|
||||
|
||||
| Package | Version |
|
||||
| -------------------- | --------------------------------------- |
|
||||
| `vite` | 8.0.1 |
|
||||
| `rolldown` | 1.0.0-rc.10 |
|
||||
| `@oxc-project/types` | 0.120.0 |
|
||||
| Triggered by | `@ark/schema` 0.56.0 / `arktype` 2.1.29 |
|
||||
|
||||
## Source
|
||||
|
||||
File: `node_modules/@ark/schema/out/shared/registry.js`
|
||||
|
||||
```js
|
||||
let _registryName = "$ark"
|
||||
let suffix = 2
|
||||
while (_registryName in globalThis) _registryName = `$ark${suffix++}`
|
||||
export const registryName = _registryName
|
||||
```
|
||||
|
||||
## Actual minified output
|
||||
|
||||
```js
|
||||
un=Pe(`implementedTraits`),dn=`$ark`,fn=2;dn in globalThis;)dn=`$ark${fn++}`;var pn=dn;
|
||||
```
|
||||
|
||||
The `while(` keyword is missing. The orphaned `)` is a syntax error.
|
||||
|
||||
## Expected output
|
||||
|
||||
```js
|
||||
var dn = `$ark`,
|
||||
fn = 2
|
||||
for (; dn in globalThis; ) dn = `$ark${fn++}`
|
||||
var pn = dn
|
||||
```
|
||||
|
||||
## Impact
|
||||
|
||||
Safari throws: `SyntaxError: Unexpected keyword 'in'. Expected a ';' following a return statement.`
|
||||
Chrome and Firefox appear to tolerate the malformed output.
|
||||
|
||||
## Reproduction attempts
|
||||
|
||||
Direct Rolldown calls (even with the full `arktype` + `@better-fetch/fetch` bundle) do **not** reproduce the bug. It only manifests through Vite's full production build pipeline. This strongly suggests the issue is in the interaction between **Vite's module preprocessing** (dependency pre-bundling, plugin transforms, scope-flattening of ESM modules) and Rolldown's minifier `sequences` optimization pass.
|
||||
|
||||
Hypothesis: Vite's pipeline produces a flattened scope where multiple `var` declarations from different original modules are consecutive. When the `sequences` optimizer then tries to fold those `var` declarations into the `for` loop init, it incorrectly drops the `for(var` prefix when the loop condition contains the `in` operator.
|
||||
|
||||
## Notes
|
||||
|
||||
- Related closed issue: [rolldown/rolldown#8146](https://github.com/rolldown/rolldown/issues/8146) — "Minifier incorrectly merges statements into for-in expression via comma operator"
|
||||
- To reproduce: run `pnpm build` in this project **without** `build.minify: "esbuild"` — the broken chunk is `getAuthor-*.js`
|
||||
|
||||
## Workaround
|
||||
|
||||
Add to `vite.config.mts`:
|
||||
|
||||
```ts
|
||||
build: {
|
||||
minify: "esbuild",
|
||||
},
|
||||
```
|
||||
|
||||
esbuild correctly outputs `for(;Dt in globalThis;)Dt=\`$ark${za++}\``.
|
||||
@@ -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>
|
||||
<html lang="en" data-theme="garden">
|
||||
<html lang="en" data-theme="light">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
||||
@@ -25,6 +25,11 @@
|
||||
rel="stylesheet"
|
||||
href="https://cdnjs.cloudflare.com/ajax/libs/github-markdown-css/4.0.0/github-markdown.min.css"
|
||||
/>
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://cdn.jsdelivr.net/npm/@tabler/icons-webfont@latest/dist/tabler-icons.min.css"
|
||||
fetchpriority="low"
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
40
package.json
40
package.json
@@ -2,39 +2,48 @@
|
||||
"name": "remanso",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@11.0.9",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"serve": "vite preview",
|
||||
"test": "vitest",
|
||||
"types": "tsc --noEmit",
|
||||
"lint": "eslint --ext .ts,.js,.vue --ignore-path .gitignore --fix src",
|
||||
"prepare": "husky",
|
||||
"lint": "oxlint",
|
||||
"lint:fix": "oxlint --fix",
|
||||
"fmt": "oxfmt",
|
||||
"fmt:check": "oxfmt --check",
|
||||
"theme:light": "esno _scripts/change-theme-light.ts",
|
||||
"theme:dark": "esno _scripts/change-theme-dark.ts",
|
||||
"generate-pwa-assets": "pwa-assets-generator"
|
||||
"theme:dark": "esno _scripts/change-theme-dark.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@atproto/oauth-client-browser": "^0.3.41",
|
||||
"@better-fetch/fetch": "^1.1.21",
|
||||
"@better-fetch/logger": "^1.1.21",
|
||||
"@intlify/unplugin-vue-i18n": "^6.0.8",
|
||||
"@mdit/plugin-tab": "^0.24.2",
|
||||
"@octokit/core": "^7.0.6",
|
||||
"@octokit/rest": "^22.0.1",
|
||||
"@openpanel/web": "^1.3.0",
|
||||
"@tailwindcss/postcss": "^4.1.16",
|
||||
"@tanstack/vue-query": "^5.92.9",
|
||||
"@toycode/markdown-it-class": "^1.2.4",
|
||||
"@ts-rest/core": "^3.52.1",
|
||||
"@ts-rest/vue-query": "^3.52.1",
|
||||
"@vscode/markdown-it-katex": "^1.1.2",
|
||||
"@vueuse/components": "^14.2.1",
|
||||
"@vueuse/core": "^13.6.0",
|
||||
"@vueuse/router": "^13.6.0",
|
||||
"arktype": "^2.1.29",
|
||||
"comlink": "^4.4.2",
|
||||
"date-fns": "^4.1.0",
|
||||
"events": "^3.3.0",
|
||||
"font-color-contrast": "^11.1.0",
|
||||
"fontfaceobserver": "^2.3.0",
|
||||
"github-slugger": "^2.0.0",
|
||||
"isomorphic-fetch": "^3.0.0",
|
||||
"markdown-it": "^14.1.0",
|
||||
"markdown-it-anchor": "^9.2.0",
|
||||
"markdown-it-block-embed": "^0.0.3",
|
||||
"markdown-it-checkbox": "^1.1.0",
|
||||
"markdown-it-github-alerts": "^1.0.0",
|
||||
@@ -53,42 +62,37 @@
|
||||
"sanitize-html": "^2.17.0",
|
||||
"vue": "^3.5.18",
|
||||
"vue-i18n": "^11.1.11",
|
||||
"vue-router": "^4.5.1"
|
||||
"vue-router": "^4.5.1",
|
||||
"workbox-build": "^7.4.0",
|
||||
"workbox-window": "^7.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.28.5",
|
||||
"@rushstack/eslint-patch": "^1.14.1",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@types/fontfaceobserver": "^2.1.3",
|
||||
"@types/markdown-it": "^14.1.2",
|
||||
"@types/node": "^22.15.24",
|
||||
"@types/pouchdb-browser": "^6.1.5",
|
||||
"@types/sanitize-html": "^2.16.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.46.2",
|
||||
"@typescript-eslint/parser": "^8.46.2",
|
||||
"@vite-pwa/assets-generator": "^1.0.2",
|
||||
"@vitejs/plugin-vue": "^5.2.4",
|
||||
"@vue/compiler-sfc": "^3.5.28",
|
||||
"@vue/eslint-config-prettier": "^10.2.0",
|
||||
"@vue/eslint-config-typescript": "^14.6.0",
|
||||
"autoprefixer": "^10.4.24",
|
||||
"daisyui": "^5.5.18",
|
||||
"dotenv": "^17.2.3",
|
||||
"eslint": "^8.57.1",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-prettier-vue": "^5.0.0",
|
||||
"eslint-plugin-simple-import-sort": "^12.1.1",
|
||||
"eslint-plugin-unused-imports": "^4.4.1",
|
||||
"eslint-plugin-vue": "^10.8.0",
|
||||
"esno": "^4.8.0",
|
||||
"husky": "^9.1.7",
|
||||
"oxfmt": "^0.42.0",
|
||||
"oxlint": "^1.57.0",
|
||||
"prettier": "^3.8.1",
|
||||
"prettier-vue": "^1.1.2",
|
||||
"sass": "^1.93.3",
|
||||
"tailwindcss": "^4.1.16",
|
||||
"sass": "^1.98.0",
|
||||
"tailwindcss": "^4.2.2",
|
||||
"typescript": "~5.9.3",
|
||||
"vite": "^7.1.12",
|
||||
"vite-plugin-pwa": "^1.1.0",
|
||||
"vite": "^8.0.1",
|
||||
"vite-plugin-pwa": "^1.2.0",
|
||||
"vitest": "^3.2.4"
|
||||
}
|
||||
}
|
||||
|
||||
1935
pnpm-lock.yaml
generated
1935
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
7
pnpm-workspace.yaml
Normal file
7
pnpm-workspace.yaml
Normal file
@@ -0,0 +1,7 @@
|
||||
allowBuilds:
|
||||
'@parcel/watcher': true
|
||||
core-js: true
|
||||
esbuild: true
|
||||
fsevents: true
|
||||
sharp: true
|
||||
vue-demi: true
|
||||
@@ -1,3 +1,3 @@
|
||||
module.exports = {
|
||||
plugins: { "@tailwindcss/postcss": {}, autoprefixer: {} },
|
||||
plugins: { "@tailwindcss/postcss": {}, autoprefixer: {} }
|
||||
}
|
||||
|
||||
@@ -2,9 +2,7 @@
|
||||
"client_id": "https://remanso.space/client-metadata.json",
|
||||
"client_name": "Remanso",
|
||||
"client_uri": "https://remanso.space",
|
||||
"redirect_uris": [
|
||||
"https://remanso.space/"
|
||||
],
|
||||
"redirect_uris": ["https://remanso.space/"],
|
||||
"scope": "atproto transition:generic",
|
||||
"grant_types": ["authorization_code", "refresh_token"],
|
||||
"response_types": ["code"],
|
||||
|
||||
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,9 +1,9 @@
|
||||
import {
|
||||
defineConfig,
|
||||
minimal2023Preset as preset,
|
||||
minimal2023Preset as preset
|
||||
} from "@vite-pwa/assets-generator/config"
|
||||
|
||||
export default defineConfig({
|
||||
preset,
|
||||
images: ["public/favicon.png"],
|
||||
images: ["public/favicon.png"]
|
||||
})
|
||||
|
||||
10
skills-lock.json
Normal file
10
skills-lock.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"version": 1,
|
||||
"skills": {
|
||||
"migrate-oxlint": {
|
||||
"source": "oxc-project/oxc",
|
||||
"sourceType": "github",
|
||||
"computedHash": "80ce5201b1ef52d6cabe553a4cacfd6e1db97bad99618216b9cf9318d11d7e64"
|
||||
}
|
||||
}
|
||||
}
|
||||
32
src/App.vue
32
src/App.vue
@@ -1,7 +1,7 @@
|
||||
<script lang="ts" setup>
|
||||
import NewVersion from '@/components/NewVersion.vue'
|
||||
import { useATProtoLogin } from '@/hooks/useATProtoLogin.hook'
|
||||
import { useGitHubLogin } from '@/hooks/useGitHubLogin.hook'
|
||||
import NewVersion from "@/components/NewVersion.vue"
|
||||
import { useATProtoLogin } from "@/hooks/useATProtoLogin.hook"
|
||||
import { useGitHubLogin } from "@/hooks/useGitHubLogin.hook"
|
||||
|
||||
const { isReady } = useGitHubLogin()
|
||||
const { isATProtoReady } = useATProtoLogin()
|
||||
@@ -17,8 +17,32 @@ const { isATProtoReady } = useATProtoLogin()
|
||||
|
||||
<style lang="scss">
|
||||
#main-app {
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
display: flex;
|
||||
flex: 1;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 768px) {
|
||||
#main-app {
|
||||
overflow-y: auto;
|
||||
}
|
||||
}
|
||||
|
||||
::view-transition-old(root),
|
||||
::view-transition-new(root) {
|
||||
animation-duration: 0.25s;
|
||||
}
|
||||
|
||||
::view-transition-group(remanso-logo) {
|
||||
animation-duration: 0.4s;
|
||||
animation-timing-function: cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
::view-transition-old(remanso-logo),
|
||||
::view-transition-new(remanso-logo) {
|
||||
object-fit: contain;
|
||||
}
|
||||
</style>
|
||||
|
||||
9
src/analytics/openpanel.ts
Normal file
9
src/analytics/openpanel.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { OpenPanel } from "@openpanel/web"
|
||||
|
||||
export const op = new OpenPanel({
|
||||
apiUrl: "https://api.panel.apoena.dev",
|
||||
clientId: "038a6aac-19bb-4a7f-9aae-2d0201fead5b",
|
||||
trackScreenViews: true,
|
||||
trackOutgoingLinks: true,
|
||||
trackAttributes: true
|
||||
})
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createEventBus } from 'retrobus'
|
||||
import { createEventBus } from "retrobus"
|
||||
|
||||
interface EventBusParams {
|
||||
fileSha: string
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { createEventBus } from 'retrobus'
|
||||
import { createEventBus } from "retrobus"
|
||||
|
||||
interface EventBusParams {
|
||||
user: string
|
||||
repo: string
|
||||
path: string
|
||||
hash?: string
|
||||
currentNoteSHA?: string
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<script lang="ts" setup>
|
||||
import { onBeforeMount, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { onBeforeMount, ref } from "vue"
|
||||
import { useRoute, useRouter } from "vue-router"
|
||||
|
||||
import { useGitHubLogin } from '@/hooks/useGitHubLogin.hook'
|
||||
import { signIn } from '@/modules/user/service/signIn'
|
||||
import { useGitHubLogin } from "@/hooks/useGitHubLogin.hook"
|
||||
import { signIn } from "@/modules/user/service/signIn"
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -16,14 +16,13 @@ onBeforeMount(async () => {
|
||||
if (code) {
|
||||
const token = await signIn(code.toString())
|
||||
|
||||
if ('error' in token) {
|
||||
if ("error" in token) {
|
||||
hasError.value = true
|
||||
} else {
|
||||
token.access_token
|
||||
saveCredentials(token)
|
||||
await saveCredentials(token)
|
||||
}
|
||||
|
||||
router.replace({ name: 'Home' })
|
||||
router.replace({ name: "Home" })
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { useRouter, type RouteLocationRaw } from "vue-router"
|
||||
import { type RouteLocationRaw, useRouter } from "vue-router"
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{ fallback?: RouteLocationRaw; preferFallback?: boolean }>(),
|
||||
{ preferFallback: true },
|
||||
{ preferFallback: true }
|
||||
)
|
||||
|
||||
const router = useRouter()
|
||||
@@ -24,7 +24,7 @@ const goBack = () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a class="btn btn-sm back-button" @click="goBack">
|
||||
<button class="btn btn-sm back-button text-base-content" @click="goBack">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
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="8" />
|
||||
</svg>
|
||||
</a>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
<script lang="ts" setup>
|
||||
import {
|
||||
computed,
|
||||
defineAsyncComponent,
|
||||
nextTick,
|
||||
onMounted,
|
||||
onUnmounted,
|
||||
toRefs,
|
||||
watch,
|
||||
} from "vue"
|
||||
import { computed, 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 StackedNote from "@/components/StackedNote.vue"
|
||||
import { useGitHubLogin } from "@/hooks/useGitHubLogin.hook"
|
||||
import { useLinks } from "@/hooks/useLinks.hook"
|
||||
import { markdownBuilder } from "@/hooks/useMarkdown.hook"
|
||||
import { useNoteView } from "@/hooks/useNoteView.hook"
|
||||
@@ -19,11 +15,6 @@ import { useVisitRepo } from "@/modules/history/hooks/useVisitRepo.hook"
|
||||
import CacheAllNotes from "@/modules/note/components/CacheAllNote.vue"
|
||||
import { useUserRepoStore } from "@/modules/repo/store/userRepo.store"
|
||||
import { useUserSettings } from "@/modules/user/hooks/useUserSettings.hook"
|
||||
import SkeletonLoader from "@/components/SkeletonLoader.vue"
|
||||
|
||||
const HeaderNote = defineAsyncComponent(
|
||||
() => import("@/components/HeaderNote.vue"),
|
||||
)
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
@@ -38,8 +29,8 @@ const props = withDefaults(
|
||||
content: null,
|
||||
parseContent: true,
|
||||
withContent: true,
|
||||
withHeader: true,
|
||||
},
|
||||
withHeader: true
|
||||
}
|
||||
)
|
||||
|
||||
const user = computed(() => props.user)
|
||||
@@ -54,6 +45,7 @@ const { listenToClick } = useLinks("note-display")
|
||||
const { stackedNotes, scrollToFocusedNote } = useRouteQueryStackedNotes()
|
||||
|
||||
const { titles } = useNoteView()
|
||||
const { isLogged } = useGitHubLogin()
|
||||
useResizeContainer("note-container", stackedNotes)
|
||||
|
||||
const renderedContent = computed(() =>
|
||||
@@ -61,7 +53,7 @@ const renderedContent = computed(() =>
|
||||
? props.parseContent
|
||||
? toHTML(props.content)
|
||||
: props.content
|
||||
: store.readme,
|
||||
: store.readme
|
||||
)
|
||||
|
||||
const isLoading = computed(() => renderedContent.value === undefined)
|
||||
@@ -73,7 +65,7 @@ watch(
|
||||
await nextTick()
|
||||
listenToClick()
|
||||
},
|
||||
{ immediate: true },
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
watch(
|
||||
@@ -81,7 +73,7 @@ watch(
|
||||
() => {
|
||||
store.setUserRepo(props.user, props.repo)
|
||||
},
|
||||
{ immediate: true },
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
onMounted(() => visitRepo())
|
||||
@@ -112,9 +104,18 @@ onUnmounted(() => {
|
||||
<cache-all-notes />
|
||||
</div>
|
||||
<slot />
|
||||
<skeleton-loader v-if="isLoading || !hasContent" />
|
||||
<skeleton-loader v-if="isLoading" />
|
||||
<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
|
||||
v-else-if="withContent"
|
||||
v-else-if="withContent && hasContent"
|
||||
class="note-display"
|
||||
v-html="renderedContent"
|
||||
/>
|
||||
@@ -200,6 +201,15 @@ $header-height: 40px;
|
||||
}
|
||||
}
|
||||
|
||||
.repo-not-found {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 2rem 0;
|
||||
color: var(--color-base-content);
|
||||
}
|
||||
|
||||
.note-display {
|
||||
padding-bottom: 2rem;
|
||||
}
|
||||
@@ -208,12 +218,6 @@ $header-height: 40px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-y: auto;
|
||||
height: 100vh;
|
||||
position: sticky;
|
||||
|
||||
&:not(:first-child) {
|
||||
border-top: 1px solid rgba(18, 19, 58, 0.2);
|
||||
}
|
||||
|
||||
.title {
|
||||
text-align: left;
|
||||
@@ -221,6 +225,8 @@ $header-height: 40px;
|
||||
}
|
||||
|
||||
@media screen and (min-width: 769px) {
|
||||
background-color: var(--note-canvas-bg);
|
||||
|
||||
.repo-title-breadcrumb {
|
||||
padding: 0.5rem 1rem 0;
|
||||
transform-origin: 0 0;
|
||||
@@ -237,6 +243,11 @@ $header-height: 40px;
|
||||
.note {
|
||||
min-width: var(--note-width);
|
||||
max-width: var(--note-width);
|
||||
background-color: var(--color-base-100);
|
||||
}
|
||||
|
||||
.readme {
|
||||
box-shadow: var(--note-sheet-shadow);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -245,6 +256,7 @@ $header-height: 40px;
|
||||
.flux-note {
|
||||
.readme {
|
||||
padding: 0 0.75rem;
|
||||
position: relative;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -254,6 +266,7 @@ $header-height: 40px;
|
||||
|
||||
.note {
|
||||
width: 100vw;
|
||||
height: 100svh;
|
||||
overflow-y: visible;
|
||||
}
|
||||
|
||||
|
||||
@@ -7,50 +7,94 @@ import { useUserRepoStore } from "../modules/repo/store/userRepo.store"
|
||||
|
||||
const store = useUserRepoStore()
|
||||
|
||||
const fontFamilies = computed(() => store.userSettings?.fontFamilies ?? [])
|
||||
const sortedFontFamilies = computed(() =>
|
||||
[...fontFamilies.value].sort((a, b) => a.localeCompare(b)),
|
||||
const DEFAULT_FONT_FAMILIES = [
|
||||
"EB Garamond",
|
||||
"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 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>
|
||||
|
||||
<template>
|
||||
<div class="font-change" v-if="sortedFontFamilies.length > 0">
|
||||
<theme-swap />
|
||||
|
||||
<select
|
||||
class="select"
|
||||
:value="store.userSettings?.chosenFontFamily"
|
||||
@change="store.setFontFamily(($event.target as HTMLSelectElement).value)"
|
||||
>
|
||||
<div class="font-change">
|
||||
<div>
|
||||
<label for="title-font" class="font-label">t</label>
|
||||
<select id="title-font" class="select" v-model="titleFont">
|
||||
<option v-for="font in sortedFontFamilies" :key="font" :value="font">
|
||||
{{ font }}
|
||||
</option>
|
||||
</select>
|
||||
|
||||
<select
|
||||
class="select"
|
||||
:value="store.userSettings?.chosenFontSize"
|
||||
@change="store.setFontSize(($event.target as HTMLSelectElement).value)"
|
||||
>
|
||||
<label for="body-font" class="font-label">p</label>
|
||||
<select id="body-font" class="select" v-model="bodyFont">
|
||||
<option v-for="font in sortedFontFamilies" :key="font" :value="font">
|
||||
{{ 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">
|
||||
{{ size }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.font-change {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
|
||||
select {
|
||||
flex: 1;
|
||||
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>
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
<script lang="ts" setup>
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useRouter } from "vue-router"
|
||||
|
||||
const { push } = useRouter()
|
||||
|
||||
const back = () =>
|
||||
push({
|
||||
name: 'Home'
|
||||
name: "Home"
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,32 +1,13 @@
|
||||
<script lang="ts" setup>
|
||||
import FontChange from "@/components/FontChange.vue"
|
||||
import HomeButton from "@/components/HomeButton.vue"
|
||||
|
||||
defineProps<{ user: string; repo: string }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header class="header-note">
|
||||
<router-link
|
||||
: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>
|
||||
<home-button />
|
||||
<!-- <router-link
|
||||
:to="{ name: 'SpacedRepetitionCard', params: { user, repo } }"
|
||||
>
|
||||
@@ -51,12 +32,15 @@ defineProps<{ user: string; repo: string }>()
|
||||
</svg>
|
||||
</router-link> -->
|
||||
|
||||
<button onclick="font_modal.showModal()">
|
||||
<button
|
||||
class="btn btn-ghost btn-circle text-base-content"
|
||||
onclick="font_modal.showModal()"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="icon icon-tabler icons-tabler-outline icon-tabler-typography"
|
||||
width="36"
|
||||
height="36"
|
||||
width="30"
|
||||
height="30"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
stroke="currentColor"
|
||||
@@ -71,11 +55,14 @@ defineProps<{ user: string; repo: string }>()
|
||||
<path d="M5 20l6 -16l2 0l7 16" />
|
||||
</svg>
|
||||
</button>
|
||||
<router-link :to="{ name: 'FluxNoteView', params: { user, repo } }">
|
||||
<router-link
|
||||
class="btn btn-ghost btn-circle"
|
||||
:to="{ name: 'FluxNoteView', params: { user, repo } }"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="36"
|
||||
height="36"
|
||||
width="30"
|
||||
height="30"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
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" />
|
||||
</svg>
|
||||
</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
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="icon icon-tabler icon-tabler-notes"
|
||||
width="36"
|
||||
height="36"
|
||||
width="30"
|
||||
height="30"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
stroke="currentColor"
|
||||
@@ -107,11 +97,14 @@ defineProps<{ user: string; repo: string }>()
|
||||
<line x1="9" y1="15" x2="13" y2="15" />
|
||||
</svg>
|
||||
</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
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="36"
|
||||
height="36"
|
||||
width="30"
|
||||
height="30"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
@@ -129,12 +122,15 @@ defineProps<{ user: string; repo: string }>()
|
||||
<path d="M11 18l9 0" />
|
||||
</svg>
|
||||
</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
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="icon icon-tabler icon-tabler-mailbox"
|
||||
width="36"
|
||||
height="36"
|
||||
width="30"
|
||||
height="30"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
stroke="currentColor"
|
||||
@@ -150,7 +146,7 @@ defineProps<{ user: string; repo: string }>()
|
||||
</svg>
|
||||
</router-link>
|
||||
<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>
|
||||
<font-change />
|
||||
</div>
|
||||
@@ -168,12 +164,6 @@ defineProps<{ user: string; repo: string }>()
|
||||
justify-content: space-between;
|
||||
margin-top: 10px;
|
||||
|
||||
img {
|
||||
&:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
button {
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
@@ -6,13 +6,22 @@ const goHome = () => router.push({ name: "Home" })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a class="btn btn-ghost btn-circle btn-lg" @click="goHome">
|
||||
<img src="/favicon.png" alt="Remanso icon" />
|
||||
</a>
|
||||
<button
|
||||
class="btn btn-ghost btn-circle btn-lg text-base-content"
|
||||
@click="goHome"
|
||||
>
|
||||
<img src="/favicon.png" alt="Remanso icon" class="remanso-logo" />
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
img {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.remanso-logo {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
view-transition-name: remanso-logo;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -24,9 +24,9 @@ const emitNote = (sha: string) => {
|
||||
<h5 class="subtitle is-5">🔗</h5>
|
||||
<ul class="links">
|
||||
<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 }}
|
||||
</a>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<script lang="ts" setup>
|
||||
const url = new URL('https://github.com/login/oauth/authorize')
|
||||
url.searchParams.append('client_id', 'Iv1.87be14adcc912fa0')
|
||||
url.searchParams.append('redirect_uri', location.href)
|
||||
url.searchParams.append('scope', 'repo')
|
||||
const url = new URL("https://github.com/login/oauth/authorize")
|
||||
url.searchParams.append("client_id", "Iv1.87be14adcc912fa0")
|
||||
url.searchParams.append("redirect_uri", location.href)
|
||||
url.searchParams.append("scope", "repo")
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
82
src/components/NoteConflictModal.vue
Normal file
82
src/components/NoteConflictModal.vue
Normal file
@@ -0,0 +1,82 @@
|
||||
<script lang="ts" setup>
|
||||
import { onMounted, ref, watch } from "vue"
|
||||
|
||||
const props = defineProps<{ open: boolean }>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "discard"): void
|
||||
(e: "overwrite"): void
|
||||
(e: "cancel"): void
|
||||
(e: "update:open", value: boolean): void
|
||||
}>()
|
||||
|
||||
const dialogRef = ref<HTMLDialogElement | null>(null)
|
||||
|
||||
const close = () => {
|
||||
if (dialogRef.value?.open) dialogRef.value.close()
|
||||
emit("update:open", false)
|
||||
}
|
||||
|
||||
const choose = (action: "discard" | "overwrite" | "cancel") => {
|
||||
emit(action)
|
||||
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="choose('cancel')"
|
||||
>
|
||||
<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="choose('cancel')"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-warning"
|
||||
@click="choose('overwrite')"
|
||||
>
|
||||
Save anyway (overwrite)
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary"
|
||||
@click="choose('discard')"
|
||||
>
|
||||
Discard my edits, pull latest
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<form method="dialog" class="modal-backdrop">
|
||||
<button type="submit" @click="choose('cancel')">close</button>
|
||||
</form>
|
||||
</dialog>
|
||||
</template>
|
||||
215
src/components/NoteFreshnessBadge.vue
Normal file
215
src/components/NoteFreshnessBadge.vue
Normal file
@@ -0,0 +1,215 @@
|
||||
<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 "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 "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
|
||||
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-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>
|
||||
@@ -1,7 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { vInfiniteScroll } from "@vueuse/components"
|
||||
|
||||
import { toShortDid } from "@/modules/atproto/shortDid"
|
||||
import { PublicNoteListItem } from "@/modules/note/models/Note"
|
||||
import { slugify } from "@/utils/slugify"
|
||||
import { vInfiniteScroll } from "@vueuse/components"
|
||||
|
||||
defineProps<{
|
||||
notes: PublicNoteListItem[]
|
||||
@@ -25,10 +27,10 @@ defineSlots<{
|
||||
:to="{
|
||||
name: 'PublicNoteView',
|
||||
params: {
|
||||
did: note.did,
|
||||
shortDid: toShortDid(note.did),
|
||||
rkey: note.rkey,
|
||||
slug: slugify(note.title),
|
||||
},
|
||||
slug: slugify(note.title)
|
||||
}
|
||||
}"
|
||||
class="btn btn-link"
|
||||
>{{ note.title }}</router-link
|
||||
|
||||
@@ -26,8 +26,8 @@ const getStyle = (seed: string) => {
|
||||
name: 'FluxNoteView',
|
||||
params: {
|
||||
user: username,
|
||||
repo: favoriteRepo.name,
|
||||
},
|
||||
repo: favoriteRepo.name
|
||||
}
|
||||
}"
|
||||
class="btn"
|
||||
:style="getStyle(`${favoriteRepo.name}-${username}`)"
|
||||
@@ -40,6 +40,7 @@ const getStyle = (seed: string) => {
|
||||
<style scoped lang="scss">
|
||||
.repo-list {
|
||||
display: flex;
|
||||
justify-content: space-evenly;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
|
||||
|
||||
@@ -3,15 +3,16 @@ import { ref } from "vue"
|
||||
|
||||
import { useATProtoLogin } from "@/hooks/useATProtoLogin.hook"
|
||||
|
||||
const { handle, isLoggedIn, signIn, signOut } = useATProtoLogin()
|
||||
const { handle, isLoggedIn, isATProtoReady, signIn, signOut } =
|
||||
useATProtoLogin()
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
withSignOut?: boolean
|
||||
}>(),
|
||||
{
|
||||
withSignOut: true,
|
||||
},
|
||||
withSignOut: true
|
||||
}
|
||||
)
|
||||
|
||||
const inputHandle = ref("")
|
||||
@@ -24,13 +25,14 @@ const onSignIn = () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="isLoggedIn" class="sign-in-atproto is-signed-in">
|
||||
<div v-if="!isATProtoReady" class="skeleton h-8 w-40"></div>
|
||||
<div v-else-if="isLoggedIn" class="sign-in-atproto is-signed-in">
|
||||
<span>{{ handle }}</span>
|
||||
<button class="btn btn-sm" @click="signOut" v-if="withSignOut">
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
<div v-else class="sign-in-atproto join">
|
||||
<div v-else-if="!isLoggedIn" class="sign-in-atproto join">
|
||||
<input
|
||||
v-model="inputHandle"
|
||||
class="input input-sm join-item"
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
nextTick,
|
||||
onMounted,
|
||||
ref,
|
||||
watch,
|
||||
watch
|
||||
} from "vue"
|
||||
|
||||
import { useEditionMode } from "@/hooks/useEditionMode"
|
||||
@@ -13,20 +13,35 @@ import { useFile } from "@/hooks/useFile.hook"
|
||||
import { useGitHubContent } from "@/hooks/useGitHubContent.hook"
|
||||
import { useImages } from "@/hooks/useImages.hook"
|
||||
import { useLinks } from "@/hooks/useLinks.hook"
|
||||
import {
|
||||
renderCodeFile,
|
||||
runMermaid,
|
||||
useShikiji
|
||||
} from "@/hooks/useMarkdown.hook"
|
||||
import { useNoteFreshness } from "@/hooks/useNoteFreshness.hook"
|
||||
import { useNoteOverlay } from "@/hooks/useNoteOverlay.hook"
|
||||
import { useRouteQueryStackedNotes } from "@/hooks/useRouteQueryStackedNotes.hook"
|
||||
import { useTitleNotes } from "@/hooks/useTitleNotes.hook"
|
||||
import { useUserRepoStore } from "@/modules/repo/store/userRepo.store"
|
||||
import { encodeUTF8ToBase64 } from "@/utils/decodeBase64ToUTF8"
|
||||
import { getFileLanguage, isMarkdownPath } from "@/utils/fileLanguage"
|
||||
import { filenameToNoteTitle } from "@/utils/noteTitle"
|
||||
import { runMermaid, useShikiji } from "@/hooks/useMarkdown.hook"
|
||||
import { errorMessage } from "@/utils/notif"
|
||||
|
||||
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(
|
||||
() => import("@/modules/note/components/EditNote.vue"),
|
||||
() => import("@/modules/note/components/EditNote.vue")
|
||||
)
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -50,9 +65,38 @@ const {
|
||||
rawContent,
|
||||
getRawContent,
|
||||
saveCacheNote,
|
||||
getEditedSha,
|
||||
getEditedSha
|
||||
} = useFile(sha)
|
||||
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 { listenToClick } = useLinks(className.value, sha)
|
||||
const titleClassName = computed(() => `title-${className.value}`)
|
||||
@@ -67,13 +111,37 @@ const breadcrumbs = computed(() => displayedTitle.value.split(" / "))
|
||||
|
||||
const { updateFile } = useGitHubContent({
|
||||
user: user.value,
|
||||
repo: repo.value,
|
||||
repo: repo.value
|
||||
})
|
||||
|
||||
const {
|
||||
status: freshnessStatus,
|
||||
lastCheckedAt,
|
||||
latestSha,
|
||||
check: checkFreshness,
|
||||
pullLatest
|
||||
} = useNoteFreshness({
|
||||
user: user.value,
|
||||
repo: repo.value,
|
||||
sha,
|
||||
path,
|
||||
getEditedSha
|
||||
})
|
||||
|
||||
const conflictOpen = ref(false)
|
||||
|
||||
onMounted(async () => {
|
||||
initialRawContent.value = await getRawContent()
|
||||
})
|
||||
|
||||
watch(
|
||||
path,
|
||||
(p) => {
|
||||
if (p) void checkFreshness()
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
const { mode, toggleMode } = useEditionMode()
|
||||
|
||||
watch([content, mode], () => {
|
||||
@@ -92,13 +160,49 @@ watch([content, mode], () => {
|
||||
runMermaid(`.note-${sha.value} .mermaid`)
|
||||
}
|
||||
|
||||
if (rawContent.value.includes("```")) {
|
||||
if (isMarkdown.value && rawContent.value.includes("```")) {
|
||||
useShikiji()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const performSave = async (overrideSha?: string) => {
|
||||
if (!path.value) {
|
||||
console.warn("no path found for this file")
|
||||
return
|
||||
}
|
||||
|
||||
const editedSha = overrideSha ?? (await getEditedSha()) ?? sha.value
|
||||
const { sha: newSha, conflict } = await updateFile({
|
||||
content: rawContent.value,
|
||||
path: path.value,
|
||||
sha: editedSha
|
||||
})
|
||||
|
||||
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) => {
|
||||
if (newMode === "edit") {
|
||||
void checkFreshness()
|
||||
return
|
||||
}
|
||||
|
||||
const hasUserFinishedToEdit =
|
||||
newMode === "read" && rawContent.value !== initialRawContent.value
|
||||
|
||||
@@ -107,28 +211,59 @@ watch(mode, async (newMode) => {
|
||||
}
|
||||
if (!path.value) {
|
||||
console.warn("no path found for this file")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const editedSha = (await getEditedSha()) ?? sha.value
|
||||
const newSha = await updateFile({
|
||||
content: rawContent.value,
|
||||
path: path.value,
|
||||
sha: editedSha,
|
||||
})
|
||||
|
||||
if (!newSha) {
|
||||
console.warn("no new SHA found for this file")
|
||||
|
||||
await checkFreshness()
|
||||
if (freshnessStatus.value === "outdated") {
|
||||
conflictOpen.value = true
|
||||
return
|
||||
}
|
||||
|
||||
await saveCacheNote(encodeUTF8ToBase64(rawContent.value), {
|
||||
editedSha: newSha,
|
||||
})
|
||||
initialRawContent.value = rawContent.value
|
||||
await performSave()
|
||||
})
|
||||
|
||||
const onConflictDiscard = async () => {
|
||||
const newRaw = await pullLatest()
|
||||
if (newRaw !== null) {
|
||||
rawContent.value = newRaw
|
||||
initialRawContent.value = newRaw
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
return
|
||||
}
|
||||
|
||||
const hasUnsavedEdits = rawContent.value !== initialRawContent.value
|
||||
if (hasUnsavedEdits) {
|
||||
conflictOpen.value = true
|
||||
return
|
||||
}
|
||||
|
||||
const newRaw = await pullLatest()
|
||||
if (newRaw !== null) {
|
||||
rawContent.value = newRaw
|
||||
initialRawContent.value = newRaw
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("freshness badge click failed", error)
|
||||
errorMessage("❌ Couldn't pull latest from GitHub")
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -137,86 +272,96 @@ watch(mode, async (newMode) => {
|
||||
:class="{
|
||||
[className]: true,
|
||||
overlay: displayNoteOverlay,
|
||||
[`note-${sha}`]: true,
|
||||
[`note-${sha}`]: true
|
||||
}"
|
||||
>
|
||||
<div class="title-stacked-note breadcrumbs text-sm" :class="titleClassName">
|
||||
<div class="action-bar">
|
||||
<note-freshness-badge
|
||||
:status="freshnessStatus"
|
||||
:last-checked-at="lastCheckedAt"
|
||||
@click="onBadgeClick"
|
||||
class="action"
|
||||
/>
|
||||
<button
|
||||
v-if="isMarkdown"
|
||||
class="action button is-text is-light"
|
||||
:class="{ 'is-link': mode === 'edit' }"
|
||||
:style="mode === 'edit' ? 'color: var(--color-primary)' : ''"
|
||||
@click="toggleMode"
|
||||
>
|
||||
<svg
|
||||
v-if="mode === 'read'"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="icon icon-tabler icon-tabler-edit"
|
||||
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="M7 7h-1a2 2 0 0 0 -2 2v9a2 2 0 0 0 2 2h9a2 2 0 0 0 2 -2v-1"
|
||||
/>
|
||||
<path
|
||||
d="M20.385 6.585a2.1 2.1 0 0 0 -2.97 -2.97l-8.415 8.385v3h3l8.385 -8.415z"
|
||||
/>
|
||||
<path d="M16 5l3 3" />
|
||||
</svg>
|
||||
<svg
|
||||
v-else
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="icon icon-tabler icon-tabler-device-floppy"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="2"
|
||||
stroke="currentColor"
|
||||
fill="none"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||
<path
|
||||
d="M6 4h10l4 4v10a2 2 0 0 1 -2 2h-12a2 2 0 0 1 -2 -2v-12a2 2 0 0 1 2 -2"
|
||||
/>
|
||||
<path d="M12 14m-2 0a2 2 0 1 0 4 0a2 2 0 1 0 -4 0" />
|
||||
<path d="M14 4l0 4l-6 0l0 -4" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<a
|
||||
class="title-stacked-note-link"
|
||||
@click.prevent="scrollToFocusedNote(props.sha)"
|
||||
>
|
||||
<div
|
||||
class="title-stacked-note breadcrumbs text-sm"
|
||||
:class="titleClassName"
|
||||
@click.prevent="scrollToFocusedNote({ noteId: props.sha })"
|
||||
>
|
||||
<ul>
|
||||
<li v-for="(part, i) in breadcrumbs" :key="i">
|
||||
{{ part }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</a>
|
||||
<section class="text-content">
|
||||
<button
|
||||
class="action button is-text is-light"
|
||||
:class="{ 'is-link': mode === 'edit' }"
|
||||
@click="toggleMode"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="icon icon-tabler icon-tabler-edit"
|
||||
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="M7 7h-1a2 2 0 0 0 -2 2v9a2 2 0 0 0 2 2h9a2 2 0 0 0 2 -2v-1"
|
||||
/>
|
||||
<path
|
||||
d="M20.385 6.585a2.1 2.1 0 0 0 -2.97 -2.97l-8.415 8.385v3h3l8.385 -8.415z"
|
||||
/>
|
||||
<path d="M16 5l3 3" />
|
||||
</svg>
|
||||
</button>
|
||||
<div v-if="mode === 'edit'" class="edit">
|
||||
<edit-note v-model="rawContent" />
|
||||
|
||||
<button
|
||||
class="action button is-text is-light"
|
||||
:class="{ 'is-link': mode === 'edit' }"
|
||||
@click="toggleMode"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="icon icon-tabler icon-tabler-edit"
|
||||
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="M7 7h-1a2 2 0 0 0 -2 2v9a2 2 0 0 0 2 2h9a2 2 0 0 0 2 -2v-1"
|
||||
/>
|
||||
<path
|
||||
d="M20.385 6.585a2.1 2.1 0 0 0 -2.97 -2.97l-8.415 8.385v3h3l8.385 -8.415z"
|
||||
/>
|
||||
<path d="M16 5l3 3" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="mode === 'read'" class="note-content" v-html="content"></div>
|
||||
<section class="text-content">
|
||||
<div v-if="mode === 'edit' && isMarkdown" class="edit">
|
||||
<edit-note v-model="rawContent" />
|
||||
</div>
|
||||
<div
|
||||
v-if="mode === 'read'"
|
||||
class="note-content"
|
||||
v-html="displayedContent"
|
||||
></div>
|
||||
</section>
|
||||
<linked-notes v-if="hasBacklinks && content" :sha="sha" />
|
||||
<note-conflict-modal
|
||||
v-model:open="conflictOpen"
|
||||
@discard="onConflictDiscard"
|
||||
@overwrite="onConflictOverwrite"
|
||||
@cancel="onConflictCancel"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -234,7 +379,7 @@ $border-color: rgba(18, 19, 58, 0.2);
|
||||
}
|
||||
|
||||
section {
|
||||
padding: 0 0.5rem 2rem;
|
||||
padding: 0 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -248,7 +393,6 @@ $border-color: rgba(18, 19, 58, 0.2);
|
||||
background-color: var(--color-base-100);
|
||||
color: var(--color-base-content);
|
||||
font-size: 0.8em;
|
||||
overflow: hidden;
|
||||
|
||||
ul,
|
||||
li {
|
||||
@@ -263,14 +407,25 @@ $border-color: rgba(18, 19, 58, 0.2);
|
||||
flex: 1;
|
||||
scrollbar-width: none;
|
||||
|
||||
div {
|
||||
> .edit,
|
||||
> .note-content {
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.action-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.action {
|
||||
float: right;
|
||||
margin: 0.2rem;
|
||||
margin: 0;
|
||||
|
||||
&:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
img {
|
||||
vertical-align: bottom;
|
||||
@@ -280,9 +435,10 @@ $border-color: rgba(18, 19, 58, 0.2);
|
||||
@media screen and (max-width: 768px) {
|
||||
.stacked-note {
|
||||
padding: 0 0.75rem 1rem;
|
||||
height: 100svh;
|
||||
|
||||
section {
|
||||
padding: 1rem 0 2rem;
|
||||
padding: 1rem 0;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
@@ -297,10 +453,12 @@ $border-color: rgba(18, 19, 58, 0.2);
|
||||
.stacked-note {
|
||||
border-top: 0;
|
||||
border-left: 1px solid $border-color;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.title-stacked-note {
|
||||
padding: 0 1rem;
|
||||
padding: 0;
|
||||
transform-origin: 0 0;
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
@@ -308,6 +466,12 @@ $border-color: rgba(18, 19, 58, 0.2);
|
||||
a {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.action-bar {
|
||||
.action {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media print {
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
<script lang="ts" setup>
|
||||
import { computedAsync } from "@vueuse/core"
|
||||
import { computed, nextTick, ref, watch } from "vue"
|
||||
import { errorMessage } from "@/utils/notif"
|
||||
import { useRoute } from "vue-router"
|
||||
|
||||
import SkeletonLoader from "@/components/SkeletonLoader.vue"
|
||||
import { useATProtoLinks } from "@/hooks/useATProtoLinks.hook"
|
||||
import { markdownBuilder } from "@/hooks/useMarkdown.hook"
|
||||
import { useNoteOverlay } from "@/hooks/useNoteOverlay.hook"
|
||||
import { useRouteQueryStackedNotes } from "@/hooks/useRouteQueryStackedNotes.hook"
|
||||
import { markdownBuilder } from "@/hooks/useMarkdown.hook"
|
||||
import { computedAsync } from "@vueuse/core"
|
||||
import { getUrl } from "@/modules/atproto/getUrl"
|
||||
import { withATProtoImages } from "@/modules/atproto/withATProtoImages"
|
||||
import { getAuthor } from "@/modules/atproto/getAuthor"
|
||||
import { getUrl } from "@/modules/atproto/getUrl"
|
||||
import { PublicNoteRecord } from "@/modules/atproto/publicNote.types"
|
||||
import { fromShortDid } from "@/modules/atproto/shortDid"
|
||||
import { withATProtoImages } from "@/modules/atproto/withATProtoImages"
|
||||
import { errorMessage } from "@/utils/notif"
|
||||
|
||||
const props = defineProps<{
|
||||
didrkey: string
|
||||
@@ -17,7 +21,7 @@ const props = defineProps<{
|
||||
}>()
|
||||
|
||||
const didrkey = computed(() => props.didrkey)
|
||||
const did = computed(() => props.didrkey.split("-")[0])
|
||||
const did = computed(() => fromShortDid(props.didrkey.split("-")[0]))
|
||||
const rkey = computed(() => props.didrkey.split("-")[1])
|
||||
const classNameId = computed(() => didrkey.value.replaceAll(":", "-"))
|
||||
|
||||
@@ -25,14 +29,22 @@ const index = computed(() => props.index)
|
||||
|
||||
const author = computedAsync(async () => getAuthor(did.value))
|
||||
const url = computedAsync(async () =>
|
||||
getUrl({ did: did.value, rkey: rkey.value }),
|
||||
getUrl({ did: did.value, rkey: rkey.value })
|
||||
)
|
||||
|
||||
const className = computed(() => `stacked-note-${props.index}`)
|
||||
const titleClassName = computed(() => `title-${className.value}`)
|
||||
|
||||
const route = useRoute()
|
||||
const mainNoteId = computed(
|
||||
() => `${route.params.shortDid}-${route.params.rkey}`
|
||||
)
|
||||
|
||||
const { scrollToFocusedNote } = useRouteQueryStackedNotes()
|
||||
const { listenToClick } = useATProtoLinks(className.value, didrkey)
|
||||
const { listenToClick } = useATProtoLinks(className.value, {
|
||||
currentAtUri: didrkey,
|
||||
mainNoteId
|
||||
})
|
||||
const { displayNoteOverlay } = useNoteOverlay(className.value, index)
|
||||
|
||||
const noteNotFound = ref(false)
|
||||
@@ -58,10 +70,10 @@ const content = computed(() =>
|
||||
? toHTML(
|
||||
withATProtoImages(noteRecord.value.value.content, {
|
||||
pds: author.value.pds,
|
||||
did: did.value,
|
||||
}),
|
||||
did: did.value
|
||||
})
|
||||
)
|
||||
: "",
|
||||
: ""
|
||||
)
|
||||
|
||||
watch(
|
||||
@@ -70,7 +82,7 @@ watch(
|
||||
await nextTick()
|
||||
listenToClick()
|
||||
},
|
||||
{ immediate: true },
|
||||
{ immediate: true }
|
||||
)
|
||||
</script>
|
||||
|
||||
@@ -80,12 +92,12 @@ watch(
|
||||
:class="{
|
||||
[className]: true,
|
||||
overlay: displayNoteOverlay,
|
||||
[`note-${classNameId}`]: true,
|
||||
[`note-${classNameId}`]: true
|
||||
}"
|
||||
>
|
||||
<a
|
||||
class="title-stacked-note-link"
|
||||
@click.prevent="scrollToFocusedNote(didrkey)"
|
||||
@click.prevent="scrollToFocusedNote({ noteId: didrkey })"
|
||||
>
|
||||
<div
|
||||
class="title-stacked-note breadcrumbs text-sm"
|
||||
@@ -98,7 +110,8 @@ watch(
|
||||
<div v-if="noteNotFound" class="alert alert-error">
|
||||
This note no longer exists.
|
||||
</div>
|
||||
<div class="note-content" v-else v-html="content"></div>
|
||||
<div class="note-content" v-else-if="content" v-html="content"></div>
|
||||
<skeleton-loader v-else-if="!noteNotFound" />
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
@@ -117,7 +130,7 @@ $border-color: rgba(18, 19, 58, 0.2);
|
||||
}
|
||||
|
||||
section {
|
||||
padding: 0 0.5rem 2rem;
|
||||
padding: 0 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -131,7 +144,6 @@ $border-color: rgba(18, 19, 58, 0.2);
|
||||
background-color: var(--color-base-100);
|
||||
color: var(--color-base-content);
|
||||
font-size: 0.8em;
|
||||
overflow: hidden;
|
||||
|
||||
ul,
|
||||
li {
|
||||
@@ -165,7 +177,7 @@ $border-color: rgba(18, 19, 58, 0.2);
|
||||
padding: 0 0.75rem 1rem;
|
||||
|
||||
section {
|
||||
padding: 1rem 0 2rem;
|
||||
padding: 1rem 0;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
@@ -180,6 +192,8 @@ $border-color: rgba(18, 19, 58, 0.2);
|
||||
.stacked-note {
|
||||
border-top: 0;
|
||||
border-left: 1px solid $border-color;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.title-stacked-note {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,9 +4,13 @@ export const getNoteWidth = () => {
|
||||
if (cached === undefined) {
|
||||
cached = parseInt(
|
||||
getComputedStyle(document.documentElement).getPropertyValue(
|
||||
"--note-width",
|
||||
),
|
||||
"--note-width"
|
||||
)
|
||||
)
|
||||
}
|
||||
return cached
|
||||
}
|
||||
|
||||
export const resetNoteWidthCache = () => {
|
||||
cached = undefined
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
export enum DataType {
|
||||
GithubAccessToken = 'GithubAccessToken',
|
||||
FavoriteRepo = 'FavoriteRepo',
|
||||
SavedRepo = 'SavedRepo',
|
||||
Note = 'Note',
|
||||
BacklinkNote = 'BacklinkNote',
|
||||
RepetitionCard = 'RepetitionCard',
|
||||
History = 'History',
|
||||
UserSettings = 'UserSettings',
|
||||
AtprotoSession = 'AtprotoSession'
|
||||
GithubAccessToken = "GithubAccessToken",
|
||||
FavoriteRepo = "FavoriteRepo",
|
||||
SavedRepo = "SavedRepo",
|
||||
Note = "Note",
|
||||
BacklinkNote = "BacklinkNote",
|
||||
RepetitionCard = "RepetitionCard",
|
||||
History = "History",
|
||||
UserSettings = "UserSettings",
|
||||
AtprotoSession = "AtprotoSession"
|
||||
}
|
||||
|
||||
169
src/data/data.ts
169
src/data/data.ts
@@ -1,166 +1,31 @@
|
||||
import { wrap } 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 {
|
||||
export interface DataApi {
|
||||
add<DT extends DataType>(model: Model<DT>): Promise<boolean>
|
||||
update<DT extends DataType, T extends Model<DT>>(model: 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
|
||||
includeDocs?: boolean
|
||||
includeAttachments?: boolean
|
||||
keys?: string[]
|
||||
}): Promise<T[]>
|
||||
}
|
||||
|
||||
class Data {
|
||||
// eslint-disable-next-line @typescript-eslint/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)
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
export const generateId = (type?: DataType | string, id?: string): string => {
|
||||
if (!type) return 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
|
||||
|
||||
156
src/data/data.worker.ts
Normal file
156
src/data/data.worker.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
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 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())
|
||||
@@ -1,5 +1,5 @@
|
||||
import { DataType } from '@/data/DataType.enum'
|
||||
import { Model } from '@/data/models/Model'
|
||||
import { DataType } from "@/data/DataType.enum"
|
||||
import { Model } from "@/data/models/Model"
|
||||
|
||||
export interface AtprotoSession extends Model<DataType.AtprotoSession> {
|
||||
did: string
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { DataType } from '@/data/DataType.enum'
|
||||
import { Model } from '@/data/models/Model'
|
||||
import { DataType } from "@/data/DataType.enum"
|
||||
import { Model } from "@/data/models/Model"
|
||||
|
||||
export interface GithubAccessToken extends Model<DataType.GithubAccessToken> {
|
||||
username: string
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { DataType } from '@/data/DataType.enum'
|
||||
import { Model } from '@/data/models/Model'
|
||||
import { DataType } from "@/data/DataType.enum"
|
||||
import { Model } from "@/data/models/Model"
|
||||
|
||||
export interface History extends Model<DataType.History> {
|
||||
repos: ReadonlyArray<{
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { DataType } from '../DataType.enum'
|
||||
import { DataType } from "../DataType.enum"
|
||||
|
||||
export interface Model<DT extends DataType> {
|
||||
_id?: string
|
||||
|
||||
@@ -1,18 +1,24 @@
|
||||
import { ComputedRef, onUnmounted, Ref, toValue } from "vue"
|
||||
|
||||
import { isExternalLink } from "@/utils/link"
|
||||
import { useRouteQueryStackedNotes } from "@/hooks/useRouteQueryStackedNotes.hook"
|
||||
import { parseAtUri } from "@/modules/atproto/parseAtUri"
|
||||
import { toShortDid } from "@/modules/atproto/shortDid"
|
||||
import { router } from "@/router/router"
|
||||
import { isExternalLink } from "@/utils/link"
|
||||
|
||||
export const useATProtoLinks = (
|
||||
className: ComputedRef<string> | string,
|
||||
currentAtUri?: Ref<string> | string,
|
||||
options: {
|
||||
currentAtUri?: Ref<string> | string | ComputedRef<string>
|
||||
mainNoteId: Ref<string> | string | ComputedRef<string>
|
||||
}
|
||||
) => {
|
||||
const { addStackedNote } = useRouteQueryStackedNotes()
|
||||
const { addStackedNote, scrollToFocusedNote } = useRouteQueryStackedNotes()
|
||||
const { currentAtUri, mainNoteId } = options
|
||||
|
||||
const linkNote = (event: Event) => {
|
||||
const target = event.target as HTMLElement
|
||||
const href = target.getAttribute("href")
|
||||
const anchor = (event.target as HTMLElement).closest("a")
|
||||
const href = anchor?.getAttribute("href")
|
||||
|
||||
if (!href) {
|
||||
return
|
||||
@@ -32,28 +38,38 @@ export const useATProtoLinks = (
|
||||
|
||||
if (href.startsWith(window.location.origin)) {
|
||||
const { params } = router.resolve(
|
||||
href.replace(window.location.origin, ""),
|
||||
href.replace(window.location.origin, "")
|
||||
)
|
||||
|
||||
if (!params.did || !params.rkey) {
|
||||
if (!params.shortDid || !params.rkey) {
|
||||
return
|
||||
}
|
||||
|
||||
const noteId = params.slug
|
||||
? `${params.did}-${params.rkey}-${params.slug}`
|
||||
: `${params.did}-${params.rkey}`
|
||||
? `${params.shortDid}-${params.rkey}-${params.slug}`
|
||||
: `${params.shortDid}-${params.rkey}`
|
||||
|
||||
if (noteId === toValue(mainNoteId)) {
|
||||
scrollToFocusedNote()
|
||||
return
|
||||
}
|
||||
|
||||
addStackedNote(
|
||||
toValue(currentAtUri) ?? "",
|
||||
noteId,
|
||||
`${params.did}-${params.rkey}`,
|
||||
`${params.shortDid}-${params.rkey}`
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (href.startsWith("at://")) {
|
||||
const { did, rkey } = parseAtUri(href)
|
||||
const noteId = `${did}-${rkey}`
|
||||
const noteId = `${toShortDid(did)}-${rkey}`
|
||||
|
||||
if (noteId === toValue(mainNoteId)) {
|
||||
scrollToFocusedNote()
|
||||
return
|
||||
}
|
||||
|
||||
addStackedNote(toValue(currentAtUri) ?? "", noteId)
|
||||
}
|
||||
@@ -95,6 +111,6 @@ export const useATProtoLinks = (
|
||||
})
|
||||
|
||||
return {
|
||||
listenToClick,
|
||||
listenToClick
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,30 +1,62 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed, ref } from "vue"
|
||||
|
||||
import { getAuthor } from '@/modules/atproto/getAuthor'
|
||||
import { restoreSession, sdkSignOut, signInWithHandle } from '@/modules/atproto/service/atprotoOAuth'
|
||||
import { clearSession, loadSession, saveSession } from '@/modules/atproto/service/atprotoSession'
|
||||
import { getAuthor } from "@/modules/atproto/getAuthor"
|
||||
import {
|
||||
restoreSession,
|
||||
sdkSignOut,
|
||||
signInWithHandle
|
||||
} from "@/modules/atproto/service/atprotoOAuth"
|
||||
import {
|
||||
clearSession,
|
||||
loadSession,
|
||||
saveSession
|
||||
} from "@/modules/atproto/service/atprotoSession"
|
||||
|
||||
const did = ref<string | null>(null)
|
||||
const handle = ref<string | null>(null)
|
||||
const avatarUrl = ref<string | null>(null)
|
||||
|
||||
let init = true
|
||||
|
||||
const initializeAuth = async () => {
|
||||
const session = await restoreSession()
|
||||
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 () => {
|
||||
// Load cached session from IndexedDB first (fast, local) so the UI can render immediately
|
||||
const stored = await loadSession()
|
||||
did.value = stored?.did ?? ""
|
||||
handle.value = stored?.handle ?? ""
|
||||
if (stored?.did) {
|
||||
fetchAvatar(stored.did)
|
||||
}
|
||||
|
||||
// Then restore OAuth session in the background (may involve network)
|
||||
const session = await restoreSession()
|
||||
if (session) {
|
||||
const author = await getAuthor(session.did)
|
||||
const resolvedHandle = author?.handle ?? ''
|
||||
const resolvedHandle = author?.handle ?? ""
|
||||
|
||||
did.value = session.did
|
||||
handle.value = resolvedHandle
|
||||
await saveSession(session.did, resolvedHandle)
|
||||
fetchAvatar(session.did)
|
||||
|
||||
window.history.replaceState(null, '', window.location.pathname + window.location.search)
|
||||
} else {
|
||||
const stored = await loadSession()
|
||||
did.value = stored?.did ?? ''
|
||||
handle.value = stored?.handle ?? ''
|
||||
window.history.replaceState(
|
||||
null,
|
||||
"",
|
||||
window.location.pathname + window.location.search
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,16 +78,18 @@ export const useATProtoLogin = () => {
|
||||
await sdkSignOut(did.value)
|
||||
}
|
||||
await clearSession()
|
||||
did.value = ''
|
||||
handle.value = ''
|
||||
did.value = ""
|
||||
handle.value = ""
|
||||
avatarUrl.value = null
|
||||
}
|
||||
|
||||
return {
|
||||
did,
|
||||
handle,
|
||||
avatarUrl,
|
||||
isLoggedIn,
|
||||
isATProtoReady,
|
||||
signIn,
|
||||
signOut,
|
||||
signOut
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import { useAsyncState } from '@vueuse/core'
|
||||
import { ComputedRef, onUnmounted, toValue } from 'vue'
|
||||
import { useAsyncState } from "@vueuse/core"
|
||||
import { ComputedRef, onUnmounted, toValue } from "vue"
|
||||
|
||||
import { backlinkEventBus } from '@/bus/backlinkEventBus'
|
||||
import { data } from '@/data/data'
|
||||
import { DataType } from '@/data/DataType.enum'
|
||||
import { BacklinkNote } from '@/modules/note/models/BacklinkNote'
|
||||
import { backlinkEventBus } from "@/bus/backlinkEventBus"
|
||||
import { data, generateId } from "@/data/data"
|
||||
import { DataType } from "@/data/DataType.enum"
|
||||
import { BacklinkNote } from "@/modules/note/models/BacklinkNote"
|
||||
|
||||
export const useBacklinks = (sha: string | ComputedRef<string>) => {
|
||||
sha = toValue(sha)
|
||||
|
||||
const { state: backlink, execute } = useAsyncState(
|
||||
data.get<DataType.BacklinkNote, BacklinkNote>(
|
||||
data.generateId(DataType.BacklinkNote, sha)
|
||||
generateId(DataType.BacklinkNote, sha)
|
||||
),
|
||||
null,
|
||||
{
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ref, Ref, toValue, onUnmounted } from "vue"
|
||||
import { useDebounceFn } from "@vueuse/core"
|
||||
import { onUnmounted, Ref, ref, toValue } from "vue"
|
||||
|
||||
import { useGitHubContent } from "@/hooks/useGitHubContent.hook"
|
||||
|
||||
const CHECKBOX_PATTERN = /\[([ xX])\]/g
|
||||
@@ -7,7 +8,7 @@ const CHECKBOX_PATTERN = /\[([ xX])\]/g
|
||||
const setCheckboxInMarkdown = (
|
||||
markdown: string,
|
||||
index: number,
|
||||
checked: boolean,
|
||||
checked: boolean
|
||||
): string => {
|
||||
let currentIndex = 0
|
||||
|
||||
@@ -21,7 +22,7 @@ const setCheckboxInMarkdown = (
|
||||
|
||||
const findCheckboxIndex = (
|
||||
container: Element,
|
||||
checkbox: HTMLInputElement,
|
||||
checkbox: HTMLInputElement
|
||||
): number => {
|
||||
const allCheckboxes = container.querySelectorAll('input[type="checkbox"]')
|
||||
return Array.from(allCheckboxes).indexOf(checkbox)
|
||||
@@ -34,7 +35,7 @@ export const useCheckboxCommit = ({
|
||||
initialContent,
|
||||
initialSha,
|
||||
containerSelector,
|
||||
debounceMs = 1000,
|
||||
debounceMs = 1000
|
||||
}: {
|
||||
user: string
|
||||
repo: string
|
||||
@@ -73,10 +74,10 @@ export const useCheckboxCommit = ({
|
||||
|
||||
isCommitting.value = true
|
||||
|
||||
const newSha = await updateFile({
|
||||
const { sha: newSha } = await updateFile({
|
||||
content: pendingContent.value,
|
||||
path: pathValue,
|
||||
sha: currentSha.value,
|
||||
sha: currentSha.value
|
||||
})
|
||||
|
||||
if (newSha) {
|
||||
@@ -109,7 +110,7 @@ export const useCheckboxCommit = ({
|
||||
pendingContent.value = setCheckboxInMarkdown(
|
||||
pendingContent.value,
|
||||
index,
|
||||
target.checked,
|
||||
target.checked
|
||||
)
|
||||
hasPendingChanges.value = true
|
||||
|
||||
@@ -142,6 +143,6 @@ export const useCheckboxCommit = ({
|
||||
isCommitting,
|
||||
hasPendingChanges,
|
||||
syncContent,
|
||||
listenToCheckboxes,
|
||||
listenToCheckboxes
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,23 +1,34 @@
|
||||
import { watch } from 'vue'
|
||||
import { watch } from "vue"
|
||||
|
||||
import { backlinkEventBus } from '@/bus/backlinkEventBus'
|
||||
import { data } from '@/data/data'
|
||||
import { DataType } from '@/data/DataType.enum'
|
||||
import { useFile } from '@/hooks/useFile.hook'
|
||||
import { Backlink } from '@/modules/note/models/Backlink'
|
||||
import { BacklinkNote } from '@/modules/note/models/BacklinkNote'
|
||||
import { resolvePath } from '@/modules/repo/services/resolvePath'
|
||||
import { useUserRepoStore } from '@/modules/repo/store/userRepo.store'
|
||||
import { isExternalLink } from '@/utils/link'
|
||||
import { filenameToNoteTitle } from '@/utils/noteTitle'
|
||||
import { confirmMessage } from '@/utils/notif'
|
||||
import { backlinkEventBus } from "@/bus/backlinkEventBus"
|
||||
import { data, generateId } from "@/data/data"
|
||||
import { DataType } from "@/data/DataType.enum"
|
||||
import { useFile } from "@/hooks/useFile.hook"
|
||||
import { Backlink } from "@/modules/note/models/Backlink"
|
||||
import { BacklinkNote } from "@/modules/note/models/BacklinkNote"
|
||||
import { resolvePath } from "@/modules/repo/services/resolvePath"
|
||||
import { useUserRepoStore } from "@/modules/repo/store/userRepo.store"
|
||||
import { isExternalLink } from "@/utils/link"
|
||||
import { filenameToNoteTitle } from "@/utils/noteTitle"
|
||||
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 = () => {
|
||||
const store = useUserRepoStore()
|
||||
|
||||
watch(store, async () => {
|
||||
watch(
|
||||
() => store.files,
|
||||
async () => {
|
||||
await new Promise<void>((r) => setTimeout(r, 300))
|
||||
|
||||
if (!store.userSettings?.backlink) {
|
||||
return
|
||||
}
|
||||
@@ -27,14 +38,17 @@ export const useComputeBacklinks = () => {
|
||||
const backlinks: Map<string, Backlink[]> = new Map()
|
||||
|
||||
for (const file of store.files) {
|
||||
await yieldToMain()
|
||||
|
||||
if (!isMarkdown(file.path) || !file.sha) {
|
||||
continue
|
||||
}
|
||||
|
||||
const fileBacklinkId = data.generateId(DataType.BacklinkNote, file.sha)
|
||||
const fileBacklink = await data.get<DataType.BacklinkNote, BacklinkNote>(
|
||||
fileBacklinkId
|
||||
)
|
||||
const fileBacklinkId = generateId(DataType.BacklinkNote, file.sha)
|
||||
const fileBacklink = await data.get<
|
||||
DataType.BacklinkNote,
|
||||
BacklinkNote
|
||||
>(fileBacklinkId)
|
||||
if (fileBacklink) {
|
||||
continue
|
||||
}
|
||||
@@ -51,18 +65,18 @@ export const useComputeBacklinks = () => {
|
||||
}
|
||||
|
||||
const parser = new DOMParser()
|
||||
const htmlDoc = parser.parseFromString(note, 'text/html')
|
||||
const htmlDoc = parser.parseFromString(note, "text/html")
|
||||
|
||||
const links = htmlDoc.querySelectorAll('a')
|
||||
const links = htmlDoc.querySelectorAll("a")
|
||||
|
||||
for (const link of links) {
|
||||
const href = link.getAttribute('href') ?? ''
|
||||
const href = link.getAttribute("href") ?? ""
|
||||
|
||||
if (isExternalLink(href) || !isMarkdown(href)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const path = resolvePath(file.path ?? '', href)
|
||||
const path = resolvePath(file.path ?? "", href)
|
||||
const backlinkFile = store.files.find((file) => file.path === path)
|
||||
|
||||
if (!backlinkFile?.sha || !backlinkFile?.path) {
|
||||
@@ -77,21 +91,21 @@ export const useComputeBacklinks = () => {
|
||||
|
||||
if (!notifiedForComputation) {
|
||||
notifiedForComputation = true
|
||||
confirmMessage('Updating backlinks...')
|
||||
confirmMessage("Updating backlinks...")
|
||||
}
|
||||
|
||||
backlinks.set(backlinkFile.sha, [
|
||||
...previousBacklinks,
|
||||
{
|
||||
sha: file.sha,
|
||||
title: filenameToNoteTitle(file.path ?? '')
|
||||
title: filenameToNoteTitle(file.path ?? "")
|
||||
}
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
for (const [sha, fileBacklinks] of backlinks) {
|
||||
const fileBacklinkId = data.generateId(DataType.BacklinkNote, sha)
|
||||
const fileBacklinkId = generateId(DataType.BacklinkNote, sha)
|
||||
const backlinkNote: BacklinkNote = {
|
||||
_id: fileBacklinkId,
|
||||
$type: DataType.BacklinkNote,
|
||||
@@ -102,5 +116,6 @@ export const useComputeBacklinks = () => {
|
||||
await data.update(backlinkNote)
|
||||
backlinkEventBus.emit({ fileSha: sha })
|
||||
}
|
||||
})
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import { useMagicKeys } from '@vueuse/core'
|
||||
import { ref, watch } from 'vue'
|
||||
import { useMagicKeys } from "@vueuse/core"
|
||||
import { ref, watch } from "vue"
|
||||
|
||||
export const useEditionMode = () => {
|
||||
const mode = ref<'read' | 'edit'>('read')
|
||||
const mode = ref<"read" | "edit">("read")
|
||||
const toggleMode = () => {
|
||||
mode.value = mode.value === 'read' ? 'edit' : 'read'
|
||||
mode.value = mode.value === "read" ? "edit" : "read"
|
||||
}
|
||||
|
||||
const { escape } = useMagicKeys()
|
||||
|
||||
watch(escape, () => {
|
||||
if (mode.value === 'edit') {
|
||||
if (mode.value === "edit") {
|
||||
toggleMode()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -17,18 +17,18 @@ export const useFile = (sha: Ref<string> | string, retrieveContent = true) => {
|
||||
const {
|
||||
render,
|
||||
renderFromUTF8,
|
||||
getRawContent: getRawContentFromFile,
|
||||
getRawContent: getRawContentFromFile
|
||||
} = markdownBuilder(shaValue)
|
||||
|
||||
const { getCachedNote, saveCacheNote } = prepareNoteCache(
|
||||
shaValue,
|
||||
toValue(path),
|
||||
toValue(path)
|
||||
)
|
||||
|
||||
const fromCache = ref(false)
|
||||
const rawContent = ref("")
|
||||
const content = computed(() =>
|
||||
rawContent.value ? renderFromUTF8(rawContent.value) : "",
|
||||
rawContent.value ? renderFromUTF8(rawContent.value) : ""
|
||||
)
|
||||
|
||||
const getEditedSha = async () => {
|
||||
@@ -55,7 +55,7 @@ export const useFile = (sha: Ref<string> | string, retrieveContent = true) => {
|
||||
}
|
||||
saveCacheNote(fileContent)
|
||||
rawContent.value = getRawContentFromFile(fileContent)
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -111,6 +111,6 @@ export const useFile = (sha: Ref<string> | string, retrieveContent = true) => {
|
||||
getCachedFileContent,
|
||||
getEditedSha,
|
||||
fromCache,
|
||||
saveCacheNote,
|
||||
saveCacheNote
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
import { computedAsync } from "@vueuse/core"
|
||||
import { computed, Ref, ref, watch } from "vue"
|
||||
|
||||
import { Author, getAuthors } from "@/modules/atproto/getAuthor"
|
||||
import { PublicNoteListItem } from "@/modules/note/models/Note"
|
||||
import { computedAsync } from "@vueuse/core"
|
||||
import { computed, ref, Ref, watch } from "vue"
|
||||
|
||||
export function useFollowingNoteList(dids: Ref<Set<string>>, enabled: Ref<boolean>) {
|
||||
export function useFollowingNoteList(
|
||||
dids: Ref<Set<string>>,
|
||||
enabled: Ref<boolean>
|
||||
) {
|
||||
const isLoading = ref(false)
|
||||
const notes = ref<PublicNoteListItem[]>([])
|
||||
const cursor = ref<string | null | undefined>(null)
|
||||
const canLoadMore = computed(() => dids.value.size > 0 && cursor.value !== undefined)
|
||||
const canLoadMore = computed(
|
||||
() => dids.value.size > 0 && cursor.value !== undefined
|
||||
)
|
||||
|
||||
const onLoadMore = async () => {
|
||||
if (isLoading.value) return
|
||||
@@ -17,20 +23,21 @@ export function useFollowingNoteList(dids: Ref<Set<string>>, enabled: Ref<boolea
|
||||
|
||||
const body: { dids: string[]; limit: number; cursor?: string } = {
|
||||
dids: [...dids.value],
|
||||
limit: 20,
|
||||
limit: 20
|
||||
}
|
||||
|
||||
if (cursor.value) {
|
||||
body.cursor = cursor.value
|
||||
}
|
||||
|
||||
const response = await fetch("https://api.litenote.li212.fr/notes/feed", {
|
||||
const response = await fetch("https://api.remanso.space/notes/feed", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
body: JSON.stringify(body)
|
||||
})
|
||||
|
||||
const data: { notes: PublicNoteListItem[]; cursor?: string } = await response.json()
|
||||
const data: { notes: PublicNoteListItem[]; cursor?: string } =
|
||||
await response.json()
|
||||
|
||||
notes.value.push(...data.notes)
|
||||
cursor.value = data.cursor
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Ref, ref, watch } from 'vue'
|
||||
import { Ref, ref, watch } from "vue"
|
||||
|
||||
import { getFollows } from '@/modules/atproto/service/getFollows'
|
||||
import { getFollows } from "@/modules/atproto/service/getFollows"
|
||||
|
||||
export const useFollows = (did: Ref<string | null>) => {
|
||||
const follows = ref<Set<string>>(new Set())
|
||||
@@ -14,7 +14,7 @@ export const useFollows = (did: Ref<string | null>) => {
|
||||
follows.value = new Set()
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
return { follows }
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ref } from "vue"
|
||||
import { useRouter } from "vue-router"
|
||||
|
||||
export const useForm = () => {
|
||||
const userInput = ref('')
|
||||
const repoInput = ref('')
|
||||
const userInput = ref("")
|
||||
const repoInput = ref("")
|
||||
const { push } = useRouter()
|
||||
|
||||
const submit = () => {
|
||||
@@ -12,7 +12,7 @@ export const useForm = () => {
|
||||
}
|
||||
|
||||
push({
|
||||
name: 'FluxNoteView',
|
||||
name: "FluxNoteView",
|
||||
params: {
|
||||
user: userInput.value,
|
||||
repo: repoInput.value
|
||||
|
||||
@@ -2,22 +2,44 @@ import { getOctokit } from "@/modules/repo/services/octo"
|
||||
import { encodeUTF8ToBase64 } from "@/utils/decodeBase64ToUTF8"
|
||||
import { confirmMessage, errorMessage } from "@/utils/notif"
|
||||
|
||||
const isConflictStatus = (status: number) => status === 409 || status === 422
|
||||
|
||||
export const useGitHubContent = ({
|
||||
user,
|
||||
repo,
|
||||
repo
|
||||
}: {
|
||||
user: string
|
||||
repo: string
|
||||
}) => {
|
||||
const fetchLatestSha = async (path: string): Promise<string | null> => {
|
||||
try {
|
||||
const octokit = await getOctokit()
|
||||
const response = await octokit.request(
|
||||
"GET /repos/{owner}/{repo}/contents/{path}",
|
||||
{
|
||||
owner: user,
|
||||
repo,
|
||||
path,
|
||||
headers: { "X-GitHub-Api-Version": "2022-11-28" }
|
||||
}
|
||||
)
|
||||
const data = response?.data
|
||||
if (Array.isArray(data) || !data) return null
|
||||
return "sha" in data ? data.sha : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const putFile = async ({
|
||||
content,
|
||||
path,
|
||||
sha,
|
||||
sha
|
||||
}: {
|
||||
content: string
|
||||
path: string
|
||||
sha?: string
|
||||
}) => {
|
||||
}): Promise<{ sha: string | null; conflict: boolean }> => {
|
||||
try {
|
||||
const octokit = await getOctokit()
|
||||
|
||||
@@ -29,25 +51,34 @@ export const useGitHubContent = ({
|
||||
path,
|
||||
message: `Updating ${path} from Remanso`,
|
||||
content: encodeUTF8ToBase64(content),
|
||||
sha,
|
||||
},
|
||||
sha
|
||||
}
|
||||
)
|
||||
|
||||
confirmMessage("✅ Note saved")
|
||||
|
||||
return response?.data.content?.sha ?? null
|
||||
return { sha: response?.data.content?.sha ?? null, conflict: false }
|
||||
} catch (error) {
|
||||
const status = (error as { status?: number })?.status
|
||||
if (status && isConflictStatus(status)) {
|
||||
errorMessage("⚠ Conflict: this note changed on GitHub")
|
||||
console.warn(error)
|
||||
return { sha: null, conflict: true }
|
||||
}
|
||||
errorMessage("❌ Note could not be saved")
|
||||
console.warn(error)
|
||||
return { sha: null, conflict: false }
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
updateFile: async (props: { content: string; path: string; sha: string }) =>
|
||||
putFile(props),
|
||||
fetchLatestSha,
|
||||
updateFile: async (props: {
|
||||
content: string
|
||||
path: string
|
||||
sha: string
|
||||
}) => putFile(props),
|
||||
createFile: async (props: { content: string; path: string }) =>
|
||||
putFile(props),
|
||||
putFile(props)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed, ref } from "vue"
|
||||
|
||||
import { GithubToken } from '@/modules/user/interfaces/GithubToken'
|
||||
import { getAccessToken, saveAccessToken } from '@/modules/user/service/signIn'
|
||||
import { confirmMessage } from '@/utils/notif'
|
||||
import { GithubToken } from "@/modules/user/interfaces/GithubToken"
|
||||
import { getAccessToken, saveAccessToken } from "@/modules/user/service/signIn"
|
||||
import { confirmMessage } from "@/utils/notif"
|
||||
|
||||
const username = ref<string | null>(null)
|
||||
const accessToken = ref<string | null>(null)
|
||||
@@ -11,8 +11,8 @@ let init = true
|
||||
|
||||
const saveAccessTokenToLocal = async () => {
|
||||
const response = await getAccessToken()
|
||||
username.value = response?.username || ''
|
||||
accessToken.value = response?.token || ''
|
||||
username.value = response?.username || ""
|
||||
accessToken.value = response?.token || ""
|
||||
}
|
||||
|
||||
const saveCredentials = async (token: GithubToken): Promise<void> => {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { computed, watch } from 'vue'
|
||||
import { computed, watch } from "vue"
|
||||
|
||||
import { useFile } from '@/hooks/useFile.hook'
|
||||
import { resolvePath } from '@/modules/repo/services/resolvePath'
|
||||
import { useUserRepoStore } from '@/modules/repo/store/userRepo.store'
|
||||
import { useFile } from "@/hooks/useFile.hook"
|
||||
import { resolvePath } from "@/modules/repo/services/resolvePath"
|
||||
import { useUserRepoStore } from "@/modules/repo/store/userRepo.store"
|
||||
|
||||
const SRC_PREFIX = 'data:image/jpeg;charset=utf-8;base64,'
|
||||
const SRC_PREFIX = "data:image/jpeg;charset=utf-8;base64,"
|
||||
|
||||
export const useImages = (sha: string) => {
|
||||
const store = useUserRepoStore()
|
||||
@@ -23,14 +23,14 @@ export const useImages = (sha: string) => {
|
||||
const images = document.querySelectorAll(`.note-${sha} img`)
|
||||
|
||||
images.forEach(async (image) => {
|
||||
const src = image.getAttribute('src')
|
||||
const src = image.getAttribute("src")
|
||||
if (!src || src.startsWith(SRC_PREFIX)) {
|
||||
return
|
||||
}
|
||||
|
||||
const imageFilePath = resolvePath(
|
||||
filePath,
|
||||
image.getAttribute('src') ?? ''
|
||||
image.getAttribute("src") ?? ""
|
||||
)
|
||||
|
||||
const imageFile = store.files.find(
|
||||
@@ -43,7 +43,7 @@ export const useImages = (sha: string) => {
|
||||
const { getCachedFileContent } = useFile(imageFile.sha, false)
|
||||
|
||||
const fileContent = await getCachedFileContent()
|
||||
image.setAttribute('src', `${SRC_PREFIX} ${fileContent}`)
|
||||
image.setAttribute("src", `${SRC_PREFIX} ${fileContent}`)
|
||||
})
|
||||
},
|
||||
{ immediate: true }
|
||||
|
||||
@@ -6,19 +6,28 @@ import { isExternalLink } from "@/utils/link"
|
||||
|
||||
export const useLinks = (
|
||||
className: ComputedRef<string> | string,
|
||||
sha?: Ref<string> | string,
|
||||
sha?: Ref<string> | string
|
||||
) => {
|
||||
const store = useUserRepoStore()
|
||||
|
||||
const linkNote: EventListener = (event) => {
|
||||
const target = event.target as HTMLElement
|
||||
const href = target.getAttribute("href")
|
||||
const anchor = (event.target as HTMLElement).closest("a")
|
||||
const href = anchor?.getAttribute("href")
|
||||
|
||||
if (!href) {
|
||||
return
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -30,11 +39,16 @@ export const useLinks = (
|
||||
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({
|
||||
path: href,
|
||||
path,
|
||||
hash,
|
||||
currentNoteSHA: toValue(sha),
|
||||
user: store.user,
|
||||
repo: store.repo,
|
||||
repo: store.repo
|
||||
})
|
||||
}
|
||||
|
||||
@@ -74,6 +88,6 @@ export const useLinks = (
|
||||
})
|
||||
|
||||
return {
|
||||
listenToClick,
|
||||
listenToClick
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,24 @@
|
||||
import type { MarkdownItTabData, MarkdownItTabInfo } from "@mdit/plugin-tab"
|
||||
import { tab } from "@mdit/plugin-tab"
|
||||
import markdownItKatex from "@vscode/markdown-it-katex"
|
||||
import GithubSlugger from "github-slugger"
|
||||
import MarkdownIt, { Options } from "markdown-it"
|
||||
import Renderer, { type RenderRuleRecord } from "markdown-it/lib/renderer.mjs"
|
||||
import type Token from "markdown-it/lib/token.mjs"
|
||||
import markdownItAnchor from "markdown-it-anchor"
|
||||
import blockEmbedPlugin from "markdown-it-block-embed"
|
||||
import markdownItCheckbox from "markdown-it-checkbox"
|
||||
import MarkdownItGitHubAlerts from "markdown-it-github-alerts"
|
||||
import markdownItIframe from "markdown-it-iframe"
|
||||
import Shikiji from "markdown-it-shikiji"
|
||||
import mermaid from "mermaid"
|
||||
import type { LanguageRegistration } from "shikiji-core"
|
||||
import { Ref, toValue } from "vue"
|
||||
|
||||
import alloyGrammar from "@/utils/alloy.tmLanguage.json"
|
||||
import { decodeBase64ToUTF8 } from "@/utils/decodeBase64ToUTF8"
|
||||
import { html5Media } from "@/utils/markdown/markdown-html5-media"
|
||||
import mermaid from "mermaid"
|
||||
import type Token from "markdown-it/lib/token.mjs"
|
||||
import Renderer, { type RenderRuleRecord } from "markdown-it/lib/renderer.mjs"
|
||||
import { markdownItTablerIcons } from "@/utils/markdown/markdown-it-tabler-icons"
|
||||
|
||||
const markdownItMermaidExtractor = (md: MarkdownIt) => {
|
||||
const defaultFence =
|
||||
@@ -21,7 +28,7 @@ const markdownItMermaidExtractor = (md: MarkdownIt) => {
|
||||
index: number,
|
||||
options: Options,
|
||||
_: unknown,
|
||||
self: Renderer,
|
||||
self: Renderer
|
||||
) {
|
||||
return self.renderToken(tokens, index, options)
|
||||
}
|
||||
@@ -31,7 +38,7 @@ const markdownItMermaidExtractor = (md: MarkdownIt) => {
|
||||
index: number,
|
||||
options: Options,
|
||||
env: unknown,
|
||||
self: Renderer,
|
||||
self: Renderer
|
||||
) {
|
||||
const token = tokens[index]
|
||||
|
||||
@@ -44,38 +51,60 @@ const markdownItMermaidExtractor = (md: MarkdownIt) => {
|
||||
}
|
||||
}
|
||||
|
||||
const slugger = new GithubSlugger()
|
||||
|
||||
let tabGroupCounter = 0
|
||||
let currentTabGroup = 0
|
||||
let currentTabActiveSet = false
|
||||
|
||||
const md = new MarkdownIt({
|
||||
typographer: true,
|
||||
quotes: ["«\xA0", "\xA0»", "‹\xA0", "\xA0›"],
|
||||
quotes: ["«\xA0", "\xA0»", "‹\xA0", "\xA0›"]
|
||||
})
|
||||
.use(markdownItMermaidExtractor)
|
||||
.use(html5Media)
|
||||
.use(blockEmbedPlugin, {
|
||||
youtube: {
|
||||
width: "100%",
|
||||
height: 300,
|
||||
},
|
||||
height: 300
|
||||
}
|
||||
})
|
||||
.use(markdownItCheckbox)
|
||||
.use(markdownItKatex)
|
||||
.use(markdownItIframe, {
|
||||
width: "100%",
|
||||
width: "100%"
|
||||
})
|
||||
.use(MarkdownItGitHubAlerts)
|
||||
.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
|
||||
|
||||
export const useShikiji = async () => {
|
||||
if (shikijiInitialized) {
|
||||
return
|
||||
}
|
||||
|
||||
shikijiInitialized = true
|
||||
md.use(
|
||||
await Shikiji({
|
||||
export const useShikiji = (): Promise<void> => {
|
||||
if (!shikijiPromise) {
|
||||
shikijiPromise = Shikiji({
|
||||
themes: {
|
||||
light: "vitesse-light",
|
||||
dark: "vitesse-black",
|
||||
dark: "vitesse-black"
|
||||
},
|
||||
langs: [
|
||||
"bash",
|
||||
@@ -86,9 +115,17 @@ export const useShikiji = async () => {
|
||||
"html",
|
||||
"css",
|
||||
"json",
|
||||
],
|
||||
}),
|
||||
)
|
||||
{
|
||||
...alloyGrammar,
|
||||
name: "alloy",
|
||||
aliases: ["als"]
|
||||
} as unknown as LanguageRegistration
|
||||
]
|
||||
}).then((plugin) => {
|
||||
md.use(plugin)
|
||||
})
|
||||
}
|
||||
return shikijiPromise
|
||||
}
|
||||
|
||||
let mermaidInitialized = false
|
||||
@@ -99,19 +136,19 @@ export const runMermaid = (querySelector: string) => {
|
||||
mermaid.initialize({
|
||||
theme: "dark",
|
||||
startOnLoad: false,
|
||||
flowchart: { curve: "natural" },
|
||||
flowchart: { curve: "natural" }
|
||||
})
|
||||
}
|
||||
|
||||
mermaid.run({
|
||||
querySelector,
|
||||
querySelector
|
||||
})
|
||||
}
|
||||
|
||||
const rules: RenderRuleRecord = {
|
||||
table_open: () =>
|
||||
'<div class="overflow-x-auto"><table class="table table-zebra">',
|
||||
table_close: () => "</table></div>",
|
||||
table_close: () => "</table></div>"
|
||||
}
|
||||
|
||||
md.renderer.rules = { ...md.renderer.rules, ...rules }
|
||||
@@ -121,22 +158,44 @@ const stripFrontmatter = (content: string): string => {
|
||||
return match ? content.slice(match[0].length) : content
|
||||
}
|
||||
|
||||
const renderMarkdown = (content: string, env?: Record<string, unknown>) => {
|
||||
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) => {
|
||||
const getRawContent = (content: string) => decodeBase64ToUTF8(content)
|
||||
const renderFromUTF8 = (content: string, prefix?: string) => {
|
||||
return content
|
||||
? md.render(stripFrontmatter(content), {
|
||||
docId: defaultPrefix ? toValue(defaultPrefix) : (prefix ?? ""),
|
||||
? renderMarkdown(stripFrontmatter(content), {
|
||||
docId: defaultPrefix ? toValue(defaultPrefix) : (prefix ?? "")
|
||||
})
|
||||
: ""
|
||||
}
|
||||
|
||||
return {
|
||||
toHTML: (content: string) =>
|
||||
content ? md.render(stripFrontmatter(content)) : "",
|
||||
content ? renderMarkdown(stripFrontmatter(content)) : "",
|
||||
render: (content: string, prefix?: string) =>
|
||||
renderFromUTF8(decodeBase64ToUTF8(content), prefix),
|
||||
renderFromUTF8,
|
||||
getRawContent,
|
||||
getRawContent
|
||||
}
|
||||
}
|
||||
|
||||
93
src/hooks/useNoteFreshness.hook.ts
Normal file
93
src/hooks/useNoteFreshness.hook.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
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"
|
||||
|
||||
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 remoteSha = await fetchLatestSha(path.value)
|
||||
if (remoteSha === null) {
|
||||
status.value = "offline"
|
||||
return
|
||||
}
|
||||
latestSha.value = remoteSha
|
||||
lastCheckedAt.value = new Date()
|
||||
const local = await expectedSha()
|
||||
status.value = remoteSha === local ? "verified" : "outdated"
|
||||
}
|
||||
|
||||
const pullLatest = async (): Promise<string | null> => {
|
||||
if (!path.value) return null
|
||||
const usedCachedSha = latestSha.value !== null
|
||||
const remoteSha = latestSha.value ?? (await fetchLatestSha(path.value))
|
||||
if (!remoteSha) {
|
||||
console.warn("pullLatest: could not resolve remote sha", { path: path.value })
|
||||
status.value = "offline"
|
||||
return null
|
||||
}
|
||||
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 null
|
||||
}
|
||||
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 getRawContent(fileContent)
|
||||
}
|
||||
|
||||
return {
|
||||
status,
|
||||
lastCheckedAt,
|
||||
latestSha,
|
||||
check,
|
||||
pullLatest
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,16 @@
|
||||
import { computed, onMounted, Ref, ref, toValue } from "vue"
|
||||
|
||||
import { BOOKMARK_WIDTH_REM, getBookmarkWidthPx } from "@/constants/bookmark-width"
|
||||
import {
|
||||
BOOKMARK_WIDTH_REM,
|
||||
getBookmarkWidthPx
|
||||
} from "@/constants/bookmark-width"
|
||||
import { getNoteWidth } from "@/constants/note-width"
|
||||
import { useOverlay } from "@/hooks/useOverlay.hook"
|
||||
import { useRouteQueryStackedNotes } from "@/hooks/useRouteQueryStackedNotes.hook"
|
||||
|
||||
export const useNoteOverlay = (
|
||||
className: string,
|
||||
index: Ref<number> | number,
|
||||
index: Ref<number> | number
|
||||
) => {
|
||||
const { x, y, isMobile } = useOverlay()
|
||||
const noteHeight = ref(0)
|
||||
@@ -18,14 +21,17 @@ export const useNoteOverlay = (
|
||||
if (isMobile.value) {
|
||||
return y.value > valueIndex * noteHeight.value
|
||||
} else {
|
||||
return x.value > valueIndex * getNoteWidth() - valueIndex * getBookmarkWidthPx()
|
||||
return (
|
||||
x.value >
|
||||
valueIndex * getNoteWidth() - valueIndex * getBookmarkWidthPx()
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
const { stackedNotes } = useRouteQueryStackedNotes()
|
||||
const noteElement = document.querySelector(
|
||||
`.${className}`,
|
||||
`.${className}`
|
||||
) satisfies HTMLElement | null
|
||||
|
||||
if (!noteElement) {
|
||||
@@ -40,7 +46,7 @@ export const useNoteOverlay = (
|
||||
noteElement.style.left = `${(toValue(index) + 1) * BOOKMARK_WIDTH_REM}rem`
|
||||
|
||||
const stackedNoteContainers = document.querySelectorAll(
|
||||
".stacked-note",
|
||||
".stacked-note"
|
||||
) satisfies NodeListOf<HTMLElement>
|
||||
|
||||
stackedNoteContainers.forEach((stackedNote, ind) => {
|
||||
@@ -52,6 +58,6 @@ export const useNoteOverlay = (
|
||||
})
|
||||
|
||||
return {
|
||||
displayNoteOverlay,
|
||||
displayNoteOverlay
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,13 +21,13 @@ export const useNoteView = () => {
|
||||
obj[note] = pathToNotePathTitle(filePath)
|
||||
|
||||
return obj
|
||||
}, {}),
|
||||
}, {})
|
||||
)
|
||||
|
||||
const unsubscribeLink = noteEventBus.addEventBusListener(
|
||||
({ path, currentNoteSHA }) => {
|
||||
({ path, hash, currentNoteSHA }) => {
|
||||
const currentFile = store.files.find(
|
||||
(file) => file.sha === currentNoteSHA,
|
||||
(file) => file.sha === currentNoteSHA
|
||||
)
|
||||
|
||||
const absolutePath = resolvePath(currentFile?.path ?? "", path)
|
||||
@@ -38,8 +38,8 @@ export const useNoteView = () => {
|
||||
return
|
||||
}
|
||||
|
||||
addStackedNote(currentNoteSHA ?? "", file.sha)
|
||||
},
|
||||
addStackedNote(currentNoteSHA ?? "", file.sha, undefined, hash)
|
||||
}
|
||||
)
|
||||
|
||||
onUnmounted(() => {
|
||||
@@ -47,6 +47,6 @@ export const useNoteView = () => {
|
||||
})
|
||||
|
||||
return {
|
||||
titles,
|
||||
titles
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { useAsyncState } from '@vueuse/core'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useAsyncState } from "@vueuse/core"
|
||||
import { computed, ref } from "vue"
|
||||
|
||||
import { data } from '@/data/data'
|
||||
import { DataType } from '@/data/DataType.enum'
|
||||
import { prepareNoteCache } from '@/modules/note/cache/prepareNoteCache'
|
||||
import { Note } from '@/modules/note/models/Note'
|
||||
import { queryFileContent } from '@/modules/repo/services/repo'
|
||||
import { useUserRepoStore } from '@/modules/repo/store/userRepo.store'
|
||||
import { data, generateId } from "@/data/data"
|
||||
import { DataType } from "@/data/DataType.enum"
|
||||
import { prepareNoteCache } from "@/modules/note/cache/prepareNoteCache"
|
||||
import { Note } from "@/modules/note/models/Note"
|
||||
import { queryFileContent } from "@/modules/repo/services/repo"
|
||||
import { useUserRepoStore } from "@/modules/repo/store/userRepo.store"
|
||||
|
||||
export const useOfflineNotes = () => {
|
||||
const store = useUserRepoStore()
|
||||
@@ -36,7 +36,7 @@ export const useOfflineNotes = () => {
|
||||
|
||||
if (
|
||||
!file.sha ||
|
||||
cachedNotesSet.has(data.generateId(DataType.Note, file.sha))
|
||||
cachedNotesSet.has(generateId(DataType.Note, file.sha))
|
||||
) {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -10,31 +10,28 @@ export const useOverlay = (listen = true) => {
|
||||
const isMobile = computed(() => width.value <= MOBILE_BREAKPOINT)
|
||||
|
||||
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 = () => {
|
||||
x.value = document.body.scrollLeft || window.scrollX
|
||||
y.value = document.body.scrollTop || window.scrollY
|
||||
const mainApp = document.getElementById("main-app")
|
||||
x.value = mainApp?.scrollLeft ?? 0
|
||||
y.value = mainApp?.scrollTop ?? 0
|
||||
}
|
||||
useEventListener(window, "scroll", updateScroll, {
|
||||
passive: true,
|
||||
capture: false,
|
||||
})
|
||||
useEventListener(document.body, "scroll", updateScroll, {
|
||||
passive: true,
|
||||
capture: false,
|
||||
})
|
||||
useEventListener(
|
||||
() => document.getElementById("main-app"),
|
||||
"scroll",
|
||||
updateScroll,
|
||||
{ passive: true }
|
||||
)
|
||||
}
|
||||
|
||||
const scrollToNote = (to: number) => {
|
||||
const go = () => {
|
||||
const mainApp = document.getElementById("main-app")
|
||||
if (!mainApp) return
|
||||
|
||||
if (isMobile.value) {
|
||||
document.body.scrollTop = to
|
||||
document.documentElement.scrollTop = to
|
||||
mainApp.scrollTo({ top: to, behavior: "smooth" })
|
||||
} else {
|
||||
document.body.scrollLeft = to
|
||||
document.documentElement.scrollLeft = to
|
||||
mainApp.scrollTo({ left: to, behavior: "smooth" })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,10 +40,22 @@ export const useOverlay = (listen = true) => {
|
||||
}, 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 {
|
||||
x,
|
||||
y,
|
||||
isMobile,
|
||||
scrollToNote,
|
||||
scrollToElement
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { computedAsync } from "@vueuse/core"
|
||||
import { computed, Ref, ref } from "vue"
|
||||
|
||||
import { Author, getAuthors } from "@/modules/atproto/getAuthor"
|
||||
import { PublicNoteListItem } from "@/modules/note/models/Note"
|
||||
import { computedAsync } from "@vueuse/core"
|
||||
import { computed, ref, Ref } from "vue"
|
||||
|
||||
interface UsePublicNoteListOptions {
|
||||
did?: Ref<string | undefined>
|
||||
@@ -18,7 +19,7 @@ export function usePublicNoteList(options?: UsePublicNoteListOptions) {
|
||||
isLoading.value = true
|
||||
|
||||
const path = options?.did?.value ? `/${options.did.value}/notes` : "/notes"
|
||||
const noteAPI = new URL(path, "https://api.litenote.li212.fr")
|
||||
const noteAPI = new URL(path, "https://api.remanso.space")
|
||||
|
||||
if (cursor.value) {
|
||||
noteAPI.searchParams.set("cursor", cursor.value)
|
||||
@@ -50,6 +51,6 @@ export function usePublicNoteList(options?: UsePublicNoteListOptions) {
|
||||
canLoadMore,
|
||||
onLoadMore,
|
||||
authors,
|
||||
getAuthor,
|
||||
getAuthor
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,34 +1,96 @@
|
||||
import { useAsyncState } from '@vueuse/core'
|
||||
import { computed, ref, watch } from "vue"
|
||||
|
||||
import { useGitHubLogin } from '@/hooks/useGitHubLogin.hook'
|
||||
import { RepoBase } from '@/modules/repo/interfaces/RepoBase'
|
||||
import { getOctokit } from '@/modules/repo/services/octo'
|
||||
import { useGitHubLogin } from "@/hooks/useGitHubLogin.hook"
|
||||
import { RepoBase } from "@/modules/repo/interfaces/RepoBase"
|
||||
import { getOctokit } from "@/modules/repo/services/octo"
|
||||
|
||||
const PER_PAGE = 30
|
||||
const STALE_TIME_MS = 20 * 60 * 1000
|
||||
|
||||
const repos = ref<RepoBase[]>([])
|
||||
const isReady = ref(false)
|
||||
const isLoading = ref(false)
|
||||
const hasCredentialError = ref(false)
|
||||
const currentPage = ref(0)
|
||||
const totalCount = ref(0)
|
||||
let lastFetchedAt = 0
|
||||
|
||||
export const useRepos = () => {
|
||||
const { username, accessToken } = useGitHubLogin()
|
||||
const repos = useAsyncState<RepoBase[]>(async () => {
|
||||
if (!accessToken.value || !username.value) {
|
||||
return []
|
||||
|
||||
const resetState = () => {
|
||||
repos.value = []
|
||||
currentPage.value = 0
|
||||
totalCount.value = 0
|
||||
isReady.value = false
|
||||
isLoading.value = false
|
||||
hasCredentialError.value = false
|
||||
lastFetchedAt = 0
|
||||
}
|
||||
|
||||
const loadMore = async () => {
|
||||
if (!accessToken.value || !username.value) {
|
||||
isReady.value = true
|
||||
return
|
||||
}
|
||||
if (isLoading.value) return
|
||||
isLoading.value = true
|
||||
try {
|
||||
const octokit = await getOctokit()
|
||||
|
||||
const repoList = await octokit.request('GET /search/repositories', {
|
||||
const nextPage = currentPage.value + 1
|
||||
const repoList = await octokit.request("GET /search/repositories", {
|
||||
q: `user:${username.value}`,
|
||||
per_page: 100
|
||||
per_page: PER_PAGE,
|
||||
page: nextPage
|
||||
})
|
||||
|
||||
return repoList.data.items
|
||||
.map((item) => ({
|
||||
currentPage.value = nextPage
|
||||
totalCount.value = repoList.data.total_count
|
||||
const newItems = repoList.data.items.map((item) => ({
|
||||
id: `${item.id}`,
|
||||
name: item.name,
|
||||
isPrivate: item.private
|
||||
}))
|
||||
.sort((a, b) => (a.name < b.name ? -1 : 1))
|
||||
}, [])
|
||||
repos.value = [...repos.value, ...newItems].sort((a, b) =>
|
||||
a.name < b.name ? -1 : 1
|
||||
)
|
||||
} catch (err: unknown) {
|
||||
if (
|
||||
typeof err === "object" &&
|
||||
err !== null &&
|
||||
"status" in err &&
|
||||
(err as { status: number }).status === 401
|
||||
) {
|
||||
hasCredentialError.value = true
|
||||
} else {
|
||||
throw err
|
||||
}
|
||||
} finally {
|
||||
isReady.value = true
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
repos: repos.state,
|
||||
isReady: repos.isReady
|
||||
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 && repos.value.length < totalCount.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,17 +1,17 @@
|
||||
import { onMounted, watch, type Ref } from "vue"
|
||||
import { onMounted, onUnmounted, type Ref, watch } from "vue"
|
||||
|
||||
import { getNoteWidth } from "@/constants/note-width"
|
||||
import { useOverlay } from "@/hooks/useOverlay.hook"
|
||||
|
||||
export const useResizeContainer = (
|
||||
containerClass: string,
|
||||
stackedNotes: Readonly<Ref<readonly string[]>>,
|
||||
stackedNotes: Readonly<Ref<readonly string[]>>
|
||||
) => {
|
||||
const { isMobile } = useOverlay(false)
|
||||
|
||||
const resizeContainer = () => {
|
||||
const container = document.querySelector(
|
||||
`.${containerClass}`,
|
||||
`.${containerClass}`
|
||||
) as HTMLElement | null
|
||||
|
||||
if (!container) {
|
||||
@@ -19,9 +19,9 @@ export const useResizeContainer = (
|
||||
}
|
||||
|
||||
if (isMobile.value) {
|
||||
container.style.height = `${(stackedNotes.value.length + 1) * 100}vh`
|
||||
container.style.height = `${(stackedNotes.value.length + 1) * 100}svh`
|
||||
} else {
|
||||
container.style.width = `${
|
||||
container.style.minWidth = `${
|
||||
getNoteWidth() * (stackedNotes.value.length + 1)
|
||||
}px`
|
||||
}
|
||||
@@ -29,9 +29,14 @@ export const useResizeContainer = (
|
||||
|
||||
onMounted(() => {
|
||||
resizeContainer()
|
||||
window.addEventListener("resize", resizeContainer)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener("resize", resizeContainer)
|
||||
})
|
||||
|
||||
watch(stackedNotes, resizeContainer, {
|
||||
immediate: true,
|
||||
immediate: true
|
||||
})
|
||||
}
|
||||
|
||||
@@ -14,28 +14,85 @@ export const useRouteQueryStackedNotes = () => {
|
||||
}
|
||||
|
||||
return Array.isArray(value) ? value : [value]
|
||||
},
|
||||
}
|
||||
})
|
||||
const { height } = useWindowSize()
|
||||
|
||||
const { scrollToNote, isMobile } = useOverlay(false)
|
||||
const { scrollToNote, scrollToElement, isMobile } = useOverlay(false)
|
||||
|
||||
const scrollToFocusedNote = (
|
||||
noteId: string | null = null,
|
||||
notes: string[] = stackedNotes.value,
|
||||
const scrollToHashInNote = (
|
||||
cleanSha: string,
|
||||
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(() => {
|
||||
const index = noteId ? notes.findIndex((nid) => nid === noteId) : 0
|
||||
|
||||
if (isMobile.value) {
|
||||
if (noteId) {
|
||||
const cleanNoteId = noteId.replaceAll(":", "-")
|
||||
const element = document.querySelector(
|
||||
`.note-${cleanNoteId}`,
|
||||
) as HTMLElement
|
||||
|
||||
const top = (index + 1) * (element?.clientHeight ?? height.value)
|
||||
scrollToNote(top)
|
||||
scrollToNoteElement(noteId.replaceAll(":", "-"), index, anchorTop)
|
||||
} else {
|
||||
scrollToNote(0)
|
||||
}
|
||||
@@ -47,6 +104,10 @@ export const useRouteQueryStackedNotes = () => {
|
||||
scrollToNote(0)
|
||||
}
|
||||
}
|
||||
|
||||
if (hash && noteId) {
|
||||
scrollToHashInNote(noteId.replaceAll(":", "-"), hash, smoothHash)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -54,9 +115,18 @@ export const useRouteQueryStackedNotes = () => {
|
||||
currentSha: string,
|
||||
sha: string,
|
||||
selector?: string,
|
||||
hash?: string
|
||||
) => {
|
||||
const anchorTop =
|
||||
document.getElementById("main-app")?.scrollTop ?? undefined
|
||||
|
||||
if (stackedNotes.value.includes(sha)) {
|
||||
scrollToFocusedNote(selector ?? sha)
|
||||
scrollToFocusedNote({
|
||||
noteId: selector ?? sha,
|
||||
hash,
|
||||
smoothHash: true,
|
||||
anchorTop
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -70,18 +140,18 @@ export const useRouteQueryStackedNotes = () => {
|
||||
const newStackedNotes = [
|
||||
...splittedStackedNotes.replaceAll(";;", ";").split(";"),
|
||||
currentSha,
|
||||
sha,
|
||||
sha
|
||||
].filter((sha) => !!sha)
|
||||
|
||||
stackedNotes.value = newStackedNotes
|
||||
}
|
||||
|
||||
scrollToFocusedNote(selector ?? sha, stackedNotes.value)
|
||||
scrollToFocusedNote({ noteId: selector ?? sha, hash, anchorTop })
|
||||
}
|
||||
|
||||
return {
|
||||
stackedNotes: readonly(stackedNotes),
|
||||
addStackedNote,
|
||||
scrollToFocusedNote,
|
||||
scrollToFocusedNote
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
import { useTitle } from '@vueuse/core'
|
||||
import { computed, Ref, toValue, watch } from 'vue'
|
||||
import { useTitle } from "@vueuse/core"
|
||||
import { computed, Ref, toValue, watch } from "vue"
|
||||
|
||||
import { useRouteQueryStackedNotes } from '@/hooks/useRouteQueryStackedNotes.hook'
|
||||
import { useNotes } from '@/modules/note/hooks/useNotes'
|
||||
import { pathToNoteTitle } from '@/utils/noteTitle'
|
||||
import { useRouteQueryStackedNotes } from "@/hooks/useRouteQueryStackedNotes.hook"
|
||||
import { useNotes } from "@/modules/note/hooks/useNotes"
|
||||
import { pathToNoteTitle } from "@/utils/noteTitle"
|
||||
|
||||
export const generateTitle = (titles: string[]) => titles.join(' | ')
|
||||
export const generateTitle = (titles: string[]) => titles.join(" | ")
|
||||
|
||||
export const useTitleNotes = (prefix: Ref<string> | string) => {
|
||||
const { stackedNotes } = useRouteQueryStackedNotes()
|
||||
const { notes } = useNotes()
|
||||
const titleNotes = computed(() =>
|
||||
notes.value
|
||||
.filter((note) => stackedNotes.value.includes(note.sha ?? ''))
|
||||
.map((note) => pathToNoteTitle(note.path ?? ''))
|
||||
.filter((note) => stackedNotes.value.includes(note.sha ?? ""))
|
||||
.map((note) => pathToNoteTitle(note.path ?? ""))
|
||||
)
|
||||
|
||||
const title = useTitle(generateTitle([toValue(prefix), ...titleNotes.value]))
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import en from './en.json'
|
||||
import fr from './fr.json'
|
||||
import en from "./en.json"
|
||||
import fr from "./fr.json"
|
||||
|
||||
export const messages = {
|
||||
en,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import "notyf/notyf.min.css"
|
||||
import "./styles/app.css"
|
||||
import "@/analytics/openpanel"
|
||||
|
||||
import { VueQueryPlugin } from "@tanstack/vue-query"
|
||||
import { createPinia } from "pinia"
|
||||
@@ -13,7 +14,7 @@ import App from "./App.vue"
|
||||
|
||||
const i18n = createI18n({
|
||||
locale: "en",
|
||||
messages,
|
||||
messages
|
||||
})
|
||||
|
||||
createApp(App)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createSchema, createFetch } from "@better-fetch/fetch"
|
||||
import { createFetch, createSchema } from "@better-fetch/fetch"
|
||||
import { type } from "arktype"
|
||||
|
||||
export type Author = { handle: string; pds: string }
|
||||
@@ -12,20 +12,20 @@ const schema = createSchema(
|
||||
did: "string",
|
||||
handle: "string",
|
||||
pds: "string",
|
||||
signing_key: "string",
|
||||
signing_key: "string"
|
||||
}),
|
||||
query: type({
|
||||
identifier: "string",
|
||||
}),
|
||||
identifier: "string"
|
||||
})
|
||||
}
|
||||
},
|
||||
},
|
||||
{ strict: true },
|
||||
{ strict: true }
|
||||
)
|
||||
|
||||
const microcosmSlingshot = createFetch({
|
||||
baseURL: "https://slingshot.microcosm.blue",
|
||||
// plugins: [logger()],
|
||||
schema,
|
||||
schema
|
||||
})
|
||||
|
||||
export const getAuthor = async (did: string): Promise<Author | null> => {
|
||||
@@ -36,7 +36,7 @@ export const getAuthor = async (did: string): Promise<Author | null> => {
|
||||
try {
|
||||
const { data: author } = await microcosmSlingshot(
|
||||
"/xrpc/blue.microcosm.identity.resolveMiniDoc",
|
||||
{ query: { identifier: did } },
|
||||
{ query: { identifier: did } }
|
||||
)
|
||||
|
||||
if (!author) {
|
||||
@@ -62,7 +62,7 @@ export const getAuthors = async (dids: Set<string>) => {
|
||||
|
||||
const { data: author } = await microcosmSlingshot(
|
||||
"/xrpc/blue.microcosm.identity.resolveMiniDoc",
|
||||
{ query: { identifier: did } },
|
||||
{ query: { identifier: did } }
|
||||
)
|
||||
|
||||
if (!author) {
|
||||
@@ -72,7 +72,7 @@ export const getAuthors = async (dids: Set<string>) => {
|
||||
correspondanceCache.set(did, author)
|
||||
|
||||
return [did, author] as [string, Author | null]
|
||||
}),
|
||||
})
|
||||
)
|
||||
|
||||
return new Map(correspondance)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import {
|
||||
BrowserOAuthClient,
|
||||
buildLoopbackClientId,
|
||||
buildLoopbackClientId
|
||||
} from "@atproto/oauth-client-browser"
|
||||
|
||||
const getClientId = () =>
|
||||
@@ -14,7 +14,7 @@ export const getOAuthClient = (): Promise<BrowserOAuthClient> => {
|
||||
if (!clientPromise) {
|
||||
clientPromise = BrowserOAuthClient.load({
|
||||
clientId: getClientId(),
|
||||
handleResolver: "https://bsky.social",
|
||||
handleResolver: "https://bsky.social"
|
||||
})
|
||||
}
|
||||
return clientPromise
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { data } from '@/data/data'
|
||||
import { DataType } from '@/data/DataType.enum'
|
||||
import { AtprotoSession } from '@/data/models/AtprotoSession'
|
||||
import { data } from "@/data/data"
|
||||
import { DataType } from "@/data/DataType.enum"
|
||||
import { AtprotoSession } from "@/data/models/AtprotoSession"
|
||||
|
||||
const SESSION_ID = `${DataType.AtprotoSession}-current`
|
||||
|
||||
@@ -8,12 +8,15 @@ export const loadSession = (): Promise<AtprotoSession | null> => {
|
||||
return data.get<DataType.AtprotoSession, AtprotoSession>(SESSION_ID)
|
||||
}
|
||||
|
||||
export const saveSession = async (did: string, handle: string): Promise<void> => {
|
||||
export const saveSession = async (
|
||||
did: string,
|
||||
handle: string
|
||||
): Promise<void> => {
|
||||
const session: AtprotoSession = {
|
||||
_id: SESSION_ID,
|
||||
$type: DataType.AtprotoSession,
|
||||
did,
|
||||
handle,
|
||||
handle
|
||||
}
|
||||
await data.update<DataType.AtprotoSession, AtprotoSession>(session)
|
||||
}
|
||||
|
||||
@@ -3,15 +3,18 @@ export const getFollows = async (did: string): Promise<Set<string>> => {
|
||||
let cursor: string | undefined
|
||||
|
||||
do {
|
||||
const url = new URL('https://public.api.bsky.app/xrpc/app.bsky.graph.getFollows')
|
||||
url.searchParams.set('actor', did)
|
||||
url.searchParams.set('limit', '100')
|
||||
const url = new URL(
|
||||
"https://public.api.bsky.app/xrpc/app.bsky.graph.getFollows"
|
||||
)
|
||||
url.searchParams.set("actor", did)
|
||||
url.searchParams.set("limit", "100")
|
||||
if (cursor) {
|
||||
url.searchParams.set('cursor', cursor)
|
||||
url.searchParams.set("cursor", cursor)
|
||||
}
|
||||
|
||||
const response = await fetch(url)
|
||||
const result: { follows: { did: string }[]; cursor?: string } = await response.json()
|
||||
const result: { follows: { did: string }[]; cursor?: string } =
|
||||
await response.json()
|
||||
|
||||
for (const follow of result.follows) {
|
||||
follows.add(follow.did)
|
||||
|
||||
8
src/modules/atproto/shortDid.ts
Normal file
8
src/modules/atproto/shortDid.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export const toShortDid = (did: string) => did.replace(/^did:(plc:)?/, "")
|
||||
// did:plc:xxx → xxx, did:web:x → web:x
|
||||
|
||||
export const fromShortDid = (shortDid: string) => {
|
||||
if (shortDid.startsWith("did:")) return shortDid
|
||||
return shortDid.includes(":") ? `did:${shortDid}` : `did:plc:${shortDid}`
|
||||
}
|
||||
// xxx → did:plc:xxx, web:x → did:web:x, did:plc:xxx → did:plc:xxx (passthrough)
|
||||
@@ -1,6 +1,6 @@
|
||||
export const withATProtoImages = (
|
||||
markdown: string,
|
||||
{ pds, did }: { pds: string; did: string },
|
||||
{ pds, did }: { pds: string; did: string }
|
||||
): string => {
|
||||
const imageLinkPattern = /!\[([^\]]*)\]\((bafkrei[a-z0-9]+)\)/g
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed, ref } from "vue"
|
||||
|
||||
import FlipCard from '@/modules/card/components/FlipCard.vue'
|
||||
import { Repetition } from '@/modules/card/hooks/useSpacedRepetitionCards'
|
||||
import FlipCard from "@/modules/card/components/FlipCard.vue"
|
||||
import { Repetition } from "@/modules/card/hooks/useSpacedRepetitionCards"
|
||||
|
||||
const props = defineProps<{ cards: Repetition[] }>()
|
||||
const emits = defineEmits<{
|
||||
@@ -22,24 +22,24 @@ const sortedCards = ref(
|
||||
const currentIndex = ref(0)
|
||||
|
||||
const goToNextCard = (success: boolean) => {
|
||||
const id = sortedCards.value[currentIndex.value].repetition._id ?? ''
|
||||
const id = sortedCards.value[currentIndex.value].repetition._id ?? ""
|
||||
|
||||
if (success) {
|
||||
emits('success', id)
|
||||
emits("success", id)
|
||||
} else {
|
||||
const failedCard = sortedCards.value.at(currentIndex.value)
|
||||
if (failedCard) {
|
||||
sortedCards.value.push(failedCard)
|
||||
}
|
||||
emits('fail', id)
|
||||
emits("fail", id)
|
||||
}
|
||||
|
||||
currentIndex.value++
|
||||
}
|
||||
|
||||
const needsReview = () => {
|
||||
const id = sortedCards.value[currentIndex.value].repetition._id ?? ''
|
||||
emits('needsReview', id)
|
||||
const id = sortedCards.value[currentIndex.value].repetition._id ?? ""
|
||||
emits("needsReview", id)
|
||||
currentIndex.value++
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useAsyncState } from '@vueuse/core'
|
||||
import { useAsyncState } from "@vueuse/core"
|
||||
|
||||
import { data } from '@/data/data'
|
||||
import { DataType } from '@/data/DataType.enum'
|
||||
import { RepetitionCard } from '@/modules/card/models/RepetitionCard'
|
||||
import { data } from "@/data/data"
|
||||
import { DataType } from "@/data/DataType.enum"
|
||||
import { RepetitionCard } from "@/modules/card/models/RepetitionCard"
|
||||
|
||||
export const useNeedReviewCards = () => {
|
||||
const { state: cardsToReview, isReady } = useAsyncState(async () => {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useAsyncState } from "@vueuse/core"
|
||||
import { addDays, isAfter } from "date-fns"
|
||||
import { computed, nextTick, watch } from "vue"
|
||||
|
||||
import { data } from "@/data/data"
|
||||
import { data, generateId } from "@/data/data"
|
||||
import { DataType } from "@/data/DataType.enum"
|
||||
import { useFile } from "@/hooks/useFile.hook"
|
||||
import { useLinks } from "@/hooks/useLinks.hook"
|
||||
@@ -31,14 +31,14 @@ export const useSpacedRepetitionCards = () => {
|
||||
(file) =>
|
||||
file.path !== undefined &&
|
||||
file.path.startsWith("_cards") &&
|
||||
file.path.endsWith(".md"),
|
||||
),
|
||||
file.path.endsWith(".md")
|
||||
)
|
||||
)
|
||||
|
||||
const {
|
||||
state: cards,
|
||||
isReady,
|
||||
execute,
|
||||
execute
|
||||
} = useAsyncState(
|
||||
async () => {
|
||||
const cards: Repetition[] = []
|
||||
@@ -51,11 +51,11 @@ export const useSpacedRepetitionCards = () => {
|
||||
const repetition = await data.getOrCreate<
|
||||
DataType.RepetitionCard,
|
||||
RepetitionCard
|
||||
>(data.generateId(DataType.RepetitionCard, cardFile.path), {
|
||||
>(generateId(DataType.RepetitionCard, cardFile.path), {
|
||||
$type: DataType.RepetitionCard,
|
||||
level: 1,
|
||||
repeatDate: new Date(),
|
||||
needsReview: false,
|
||||
needsReview: false
|
||||
})
|
||||
|
||||
if (
|
||||
@@ -77,20 +77,20 @@ export const useSpacedRepetitionCards = () => {
|
||||
card: {
|
||||
front: toHTML(front),
|
||||
back: toHTML(back),
|
||||
references: toHTML(references),
|
||||
},
|
||||
references: toHTML(references)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return cards
|
||||
},
|
||||
[],
|
||||
{ immediate: false },
|
||||
{ immediate: false }
|
||||
)
|
||||
|
||||
const successRepetition = async (cardId: string) => {
|
||||
const repetition = await data.get<DataType.RepetitionCard, RepetitionCard>(
|
||||
cardId,
|
||||
cardId
|
||||
)
|
||||
if (!repetition) {
|
||||
return
|
||||
@@ -100,13 +100,13 @@ export const useSpacedRepetitionCards = () => {
|
||||
...repetition,
|
||||
needsReview: false,
|
||||
level: Math.min(repetition.level + 1, MAX_LEVEL),
|
||||
repeatDate: addDays(new Date(), 2 ** repetition.level),
|
||||
repeatDate: addDays(new Date(), 2 ** repetition.level)
|
||||
})
|
||||
}
|
||||
|
||||
const failRepetition = async (cardId: string) => {
|
||||
const repetition = await data.get<DataType.RepetitionCard, RepetitionCard>(
|
||||
cardId,
|
||||
cardId
|
||||
)
|
||||
if (!repetition) {
|
||||
return
|
||||
@@ -118,13 +118,13 @@ export const useSpacedRepetitionCards = () => {
|
||||
...repetition,
|
||||
level,
|
||||
needsReview: false,
|
||||
repeatDate: addDays(new Date(), level),
|
||||
repeatDate: addDays(new Date(), level)
|
||||
})
|
||||
}
|
||||
|
||||
const needsReview = async (cardId: string) => {
|
||||
const repetition = await data.get<DataType.RepetitionCard, RepetitionCard>(
|
||||
cardId,
|
||||
cardId
|
||||
)
|
||||
if (!repetition) {
|
||||
return
|
||||
@@ -132,7 +132,7 @@ export const useSpacedRepetitionCards = () => {
|
||||
|
||||
await data.update<DataType.RepetitionCard, RepetitionCard>({
|
||||
...repetition,
|
||||
needsReview: true,
|
||||
needsReview: true
|
||||
})
|
||||
}
|
||||
|
||||
@@ -142,7 +142,7 @@ export const useSpacedRepetitionCards = () => {
|
||||
nextTick(() => {
|
||||
listenToClick()
|
||||
}),
|
||||
{ immediate: true },
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
watch(cardFiles, () => execute())
|
||||
@@ -152,6 +152,6 @@ export const useSpacedRepetitionCards = () => {
|
||||
successRepetition,
|
||||
failRepetition,
|
||||
needsReview,
|
||||
isLoading: !isReady,
|
||||
isLoading: !isReady
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { DataType } from '@/data/DataType.enum'
|
||||
import { Model } from '@/data/models/Model'
|
||||
import { DataType } from "@/data/DataType.enum"
|
||||
import { Model } from "@/data/models/Model"
|
||||
|
||||
export interface RepetitionCard extends Model<DataType.RepetitionCard> {
|
||||
level: number
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts" setup>
|
||||
import { useLastVisitedRepos } from '@/modules/history/hooks/useLastVisitedRepos.hook'
|
||||
import { useLastVisitedRepos } from "@/modules/history/hooks/useLastVisitedRepos.hook"
|
||||
|
||||
const { lastVisitedRepos } = useLastVisitedRepos()
|
||||
</script>
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import { useAsyncState } from '@vueuse/core'
|
||||
import { computed } from 'vue'
|
||||
import { useAsyncState } from "@vueuse/core"
|
||||
import { computed } from "vue"
|
||||
|
||||
import { data } from '@/data/data'
|
||||
import { DataType } from '@/data/DataType.enum'
|
||||
import { History } from '@/data/models/History'
|
||||
import { data, generateId } from "@/data/data"
|
||||
import { DataType } from "@/data/DataType.enum"
|
||||
import { History } from "@/data/models/History"
|
||||
|
||||
const HISTORY_ID = data.generateId(DataType.History, 'history')
|
||||
const HISTORY_ID = generateId(DataType.History, "history")
|
||||
|
||||
export const useLastVisitedRepos = () => {
|
||||
const history = useAsyncState(
|
||||
() =>
|
||||
data.get<DataType.History, History>(
|
||||
data.generateId(DataType.History, 'history')
|
||||
generateId(DataType.History, "history")
|
||||
),
|
||||
null
|
||||
)
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { Ref, toValue } from 'vue'
|
||||
import { Ref, toValue } from "vue"
|
||||
|
||||
import { data } from '@/data/data'
|
||||
import { DataType } from '@/data/DataType.enum'
|
||||
import { History } from '@/data/models/History'
|
||||
import { data, generateId } from "@/data/data"
|
||||
import { DataType } from "@/data/DataType.enum"
|
||||
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
|
||||
|
||||
export const useVisitRepo = (newRepo: {
|
||||
|
||||
20
src/modules/note/cache/prepareNoteCache.ts
vendored
20
src/modules/note/cache/prepareNoteCache.ts
vendored
@@ -1,26 +1,26 @@
|
||||
import { data } from '@/data/data'
|
||||
import { DataType } from '@/data/DataType.enum'
|
||||
import { Note } from '@/modules/note/models/Note'
|
||||
import { useUserRepoStore } from '@/modules/repo/store/userRepo.store'
|
||||
import { data, generateId } from "@/data/data"
|
||||
import { DataType } from "@/data/DataType.enum"
|
||||
import { Note } from "@/modules/note/models/Note"
|
||||
import { useUserRepoStore } from "@/modules/repo/store/userRepo.store"
|
||||
|
||||
type NoteCacheResult =
|
||||
| {
|
||||
note: Note
|
||||
from: 'sha'
|
||||
from: "sha"
|
||||
}
|
||||
| { note: Note; from: 'path' }
|
||||
| { note: Note; from: "path" }
|
||||
| { note: null; from: null }
|
||||
|
||||
export const prepareNoteCache = (sha: string, path?: string) => {
|
||||
const store = useUserRepoStore()
|
||||
|
||||
const noteId = data.generateId(DataType.Note, sha)
|
||||
const notePath = path ? data.generateId(DataType.Note, path) : null
|
||||
const noteId = generateId(DataType.Note, sha)
|
||||
const notePath = path ? generateId(DataType.Note, path) : null
|
||||
const getCachedNote = async (): Promise<NoteCacheResult> => {
|
||||
const note = await data.get<DataType.Note, Note>(noteId)
|
||||
|
||||
if (note) {
|
||||
return { note, from: 'sha' }
|
||||
return { note, from: "sha" }
|
||||
}
|
||||
|
||||
if (notePath) {
|
||||
@@ -33,7 +33,7 @@ export const prepareNoteCache = (sha: string, path?: string) => {
|
||||
}
|
||||
return {
|
||||
note,
|
||||
from: 'path'
|
||||
from: "path"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user