Compare commits

...

54 Commits

Author SHA1 Message Date
Julien Calixte
70b679b204 Merge branch 'main' of ssh://git.apoena.dev:22222/remanso-space/remanso 2026-04-20 11:10:48 +02:00
Julien Calixte
36dc1293f9 docs: fix ATProto session storage split between SecureStore and SQLite 2026-04-20 10:56:03 +02:00
Julien Calixte
801b7cb94a docs: add React Native migration design spec 2026-04-20 10:55:44 +02:00
Julien Calixte
1fa66d8594 fix: prevent spurious y-scrollbar when section has overflow-x: auto on mobile
Setting overflow-x: auto forces overflow-y off 'visible' per CSS spec,
which caused an unwanted vertical scrollbar in stacked note sections.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-19 14:28:25 +02:00
Julien Calixte
b827f31cf0 fix: current color for svg in buttons 2026-04-19 10:49:37 +02:00
Julien Calixte
cf02569c75 design: change light theme to emerald 2026-04-19 10:39:49 +02:00
Julien Calixte
0a4f8dbf41 fix: make BackButton and LinkedNotes keyboard accessible
Replace <a> (no href) with <button> so both elements receive tab focus.
BackButton gets text-base-content to preserve icon color; LinkedNotes
uses btn class="link" to keep the inline text-link appearance.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 01:08:34 +02:00
Julien Calixte
b6f6759af5 fix: restore icon color on button elements in header
<button> gets color:ButtonText from the browser UA stylesheet, making
SVG stroke="currentColor" render black. Add text-base-content to
inherit the DaisyUI theme color like the <a>-based router-links do.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 01:07:25 +02:00
Julien Calixte
c42c26a407 fix: restore icon color on FontChange trigger button
<button> defaults to color: ButtonText (black) in browsers, unlike <a>
which inherits. Adding color: inherit restores the theme color for the
SVG stroke (which uses currentColor).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 01:06:25 +02:00
Julien Calixte
cfe5ef8fcd fix: make HomeButton keyboard accessible
Replace <a> with <button> so the home logo receives tab focus.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 01:04:50 +02:00
Julien Calixte
4c5116bc89 fix: make FontChange modal trigger keyboard accessible
Replace <a> with <button> for the typography icon in HeaderNote so it
receives tab focus — <a> without href is excluded from the tab order.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 01:02:12 +02:00
Julien Calixte
8581baafb7 design: change dark theme to forest 2026-04-08 19:07:03 +02:00
Julien Calixte
29c092e0a0 design: change dark theme to abyss 2026-04-08 19:04:28 +02:00
Julien Calixte
410c0cec7c design: change dark theme to sunset 2026-04-08 19:03:07 +02:00
Julien Calixte
66a1bcbaa9 design: change dark theme to black 2026-04-08 19:01:48 +02:00
Julien Calixte
541e058d12 fix: restore dark theme and fix theme script regex
Dark theme was set to "dim" in theme.config.ts while app.css registered
"sunset" as the prefersdark theme. The script's regex required a trailing
comma that didn't exist on the last property, causing silent failures.
2026-04-08 19:01:29 +02:00
Julien Calixte
a05ff9f238 design: change dark theme to sunset 2026-04-08 18:57:36 +02:00
Julien Calixte
6558de8df5 design: change dark theme to black 2026-04-08 18:51:30 +02:00
Julien Calixte
b48c1bd0d5 prune: remove obsolete agent 2026-04-06 23:41:19 +02:00
Julien Calixte
e369541dc0 refactor: scope PouchDB writes to repo config, not user font prefs
chosen* fields are per-browser preferences — localStorage is the correct
and sufficient store for them. Removing data.update from font setters and
stripping chosen* from the GitHub fetch PouchDB write prevents stale PouchDB
data from conflicting with localStorage on reload.
2026-04-06 23:26:50 +02:00
Julien Calixte
73a6014750 fix: persist font selections across navigation and page reloads
- Use v-model with writable computeds instead of :value+@change so selects
  re-sync when the options list changes asynchronously
- Always include currently chosen fonts in sortedFontFamilies so a selected
  font not present in .remanso.json fontFamilies still shows in the select
- Initialize userSettings instead of returning early in font setters so
  changes made before async GitHub fetch completes are not silently dropped
- Back font choices with localStorage so they survive hard reloads even when
  PouchDB/IndexedDB fails silently in the web worker
2026-04-06 18:51:27 +02:00
Julien Calixte
c197b80095 feat: smaller modal 2026-04-06 17:44:43 +02:00
Julien Calixte
f3e74aed34 fix: resolve all TypeScript type errors
- Install missing comlink (was in lockfile but not node_modules)
- Add @ts-rest/core and @ts-rest/vue-query (imported but not declared as deps)
- Add declare module '*.vue' shim to shims-vue.d.ts
- Replace arktype validators in ts-rest contract with contract.type<T>() since @ts-rest expects Zod schemas
2026-04-06 15:05:57 +02:00
Julien Calixte
8d9134a062 perf: cache repo list with 20-minute stale time
Hoist useRepos state to module scope so all callers share one instance, and skip re-fetching until data is older than 20 minutes.
2026-04-06 14:59:12 +02:00
Julien Calixte
006cd63388 feat: paginate repo list with infinite scroll
Load 30 repos at a time instead of 100 at once, showing data sooner.
Adds v-infinite-scroll to RepoList.vue to fetch subsequent pages on scroll.
2026-04-05 11:56:36 +02:00
Julien Calixte
3de9eb35f6 feat: show font family selectors with default fonts when no .remanso.json 2026-04-05 10:49:01 +02:00
Julien Calixte
99c349f6df fix: preserve font settings when repo has no .remanso.json
When no config file exists, userSettings was set to null which destroyed
cached user preferences and silently blocked all setFont* actions.
2026-04-04 14:39:34 +02:00
Julien Calixte
64b29bcdef fix: remove favicon.png from PWA manifest icons to fix dock icon on macOS 2026-04-04 14:22:14 +02:00
Julien Calixte
9e26e231cb fix: show theme and font size controls before font families load
Move the v-if guard from the outer FontChange wrapper to only the font-family
selects, so ThemeSwap and the font-size select are always visible in the modal
even before userSettings.fontFamilies resolves asynchronously.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 11:35:05 +02:00
Julien Calixte
b003a3e008 perf: move PouchDB/IndexedDB operations to a Web Worker
All database reads and writes now run off the main thread via a
dedicated worker, eliminating IndexedDB overhead from the frame budget.

- Create data.worker.ts exposing the Data class via Comlink
- Refactor data.ts to export a Comlink-wrapped proxy and a standalone
  generateId() pure function (workers can't expose sync methods cleanly)
- Update all 10 call sites to import generateId directly instead of
  calling data.generateId()

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 11:27:45 +02:00
Julien Calixte
1b5e23e3d4 fix: keep font settings visible during repo navigation
- resetFiles() no longer clears userSettings so FontChange stays visible
  while navigating between repos (old fonts show until new ones load)
- Add _requestId counter to setUserRepo() to discard stale async callbacks
  from previous navigations, preventing state corruption on quick nav
- Load savedRepo and userSettings caches in parallel with Promise.all,
  reducing yield points so cache hits apply before first render

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 11:21:56 +02:00
Julien Calixte
52d7c84bd0 perf: prevent FPS drops during navigation in FluxNoteView
- Narrow backlinks watcher from entire store to store.files only,
  reducing trigger count from ~8 to 2 per navigation
- Defer computation start by 300ms so it runs after the 250ms view
  transition animation completes
- Yield to the browser between each file iteration using
  scheduler.yield() (with setTimeout fallback) to avoid blocking frames

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 11:14:02 +02:00
Julien Calixte
d76182b2c2 Merge branch 'main' of ssh://git.apoena.dev:22222/remanso-space/remanso 2026-04-03 15:02:46 +02:00
Julien Calixte
ed1a6b7fba fix: add the right margin to the right components 2026-03-29 22:09:01 +02:00
Julien Calixte
d5b251c4a0 fix: remove overflow because it's causing too much trouble 2026-03-29 22:00:22 +02:00
Julien Calixte
19b77810ec chore: remove healthcheck in docker to be faster 2026-03-29 21:55:32 +02:00
Julien Calixte
c8b0a78973 fix: add nginx SPA fallback to serve index.html for all routes
Prevents 404 errors when navigating directly to client-side routes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 21:50:50 +02:00
Julien Calixte
087d1a355e revert: remove justify-content center from welcome content
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 22:37:19 +01:00
Julien Calixte
5d90da8ab5 feat: center welcome content vertically
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 22:36:40 +01:00
Julien Calixte
72d065975d fix: lock html/body to 100dvh overflow hidden on all screen sizes
All views that need scroll use their own overflow-y: auto containers.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 22:35:11 +01:00
Julien Calixte
8b3df48791 fix: clip app at 100dvh to prevent body scroll on mobile
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 22:29:50 +01:00
Julien Calixte
cd8e173e05 fix: use 100dvh for body and #app to match dynamic viewport
Prevents white space below the app on Android Chrome where the
system nav bar makes 100vh > 100dvh.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 22:25:19 +01:00
Julien Calixte
8767f7c430 fix: give .home explicit height so flex children resolve correctly
On Chrome Android, cross-axis stretch doesn't always produce a
definite height for inner flex items. Adding height: 100dvh to
.home ensures flex: 1 on .welcome-world resolves to full viewport.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 22:22:54 +01:00
Julien Calixte
369a200a42 fix: wrap content in flex:1 div so footer doesn't overflow
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 22:07:08 +01:00
Julien Calixte
06eaa3c9a7 fix: ensure footer stays at bottom with align-self stretch
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 21:56:09 +01:00
Julien Calixte
4cbcf42e3d feat: replace BackButton and logo with HomeButton in PublicNoteView
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 21:32:19 +01:00
Julien Calixte
a0be25c0dd fix: prevent layout shift on first load in PWA mode
Replace space-between with flex-start + margin-top:auto on footer and
add gap to avoid wide spacing while async components are loading.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 21:28:47 +01:00
Julien Calixte
dcee26100f fix: use 100dvh to prevent scroll on mobile first load
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 21:05:34 +01:00
Julien Calixte
ac68c68f8a feat: reorganize FontChange layout and resize header icons
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 20:46:44 +01:00
Julien Calixte
982f3070a1 fix: use <a> for font modal trigger to match icon color
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 20:35:52 +01:00
Julien Calixte
20e9538983 feat: mv profile to footer 2026-03-28 20:24:08 +01:00
Julien Calixte
10c3e1ca60 feat: replace back button with HomeButton and fix view transition
- Use HomeButton component in HeaderNote for logo, hover, and view-transition-name
- Eagerly import HeaderNote in FluxNote so the logo exists in the DOM when the transition snapshot is taken
- Wait for afterEach + nextTick in the view transition hook to handle lazy-loaded routes
- Add cursor: pointer to font change button

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 20:19:59 +01:00
6dc98c80ca Merge pull request 'chore/migrate-to-oxc' (#1) from chore/migrate-to-oxc into main
Reviewed-on: #1
2026-03-28 09:00:30 +00:00
Julien Calixte
1aef212a36 docs: rolldown and oxc 2026-03-22 01:23:13 +01:00
51 changed files with 1043 additions and 768 deletions

View File

@@ -1,8 +0,0 @@
{
"name": "remanso-skills",
"version": "1.0.0",
"description": "Local skills for the Remanso project",
"author": {
"name": "julien"
}
}

View File

@@ -1,196 +0,0 @@
---
name: migrate-oxlint
description: Guide for migrating a project from ESLint to Oxlint. Use when asked to migrate, convert, or switch a JavaScript/TypeScript project's linter from ESLint to Oxlint.
---
This skill guides you through migrating a JavaScript/TypeScript project from ESLint to [Oxlint](https://oxc.rs/docs/guide/usage/linter/).
## Overview
Oxlint is a high-performance linter that implements many popular ESLint rules natively in Rust. It can be used alongside ESLint or as a full replacement.
An official migration tool is available, and will be used by this skill: [`@oxlint/migrate`](https://github.com/oxc-project/oxlint-migrate)
## Step 1: Run Automated Migration
Run the migration tool in the project root:
```bash
npx @oxlint/migrate
```
This reads your ESLint flat config (`eslint.config.js` for example) and generates a `.oxlintrc.json` file from it. It will find your ESLint config file automatically in most cases.
See options below for more info.
### Key Options
| Option | Description |
| --------------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
| `--type-aware` | Include type-aware rules from `@typescript-eslint` (will require the `oxlint-tsgolint` package to be installed after migrating) |
| `--with-nursery` | Include experimental rules still under development, may not be fully stable or consistent with ESLint equivalents |
| `--js-plugins [bool]` | Enable/disable ESLint plugin migration via `jsPlugins` (default: enabled) |
| `--details` | List rules that could not be migrated |
| `--replace-eslint-comments` | Convert all `// eslint-disable` comments to `// oxlint-disable` |
| `--output-file <file>` | Specify a different output path (default: `.oxlintrc.json`) |
If your ESLint config is not at the default location, pass the path explicitly:
```bash
npx @oxlint/migrate ./path/to/eslint.config.js
```
## Step 2: Review Generated Config
After migration, review the generated `.oxlintrc.json`.
### Plugin Mapping
The migration tool automatically maps ESLint plugins to oxlint's built-in equivalents. The following table is for reference when reviewing the generated config:
| ESLint Plugin | Oxlint Plugin Name |
| --------------------------------------------------- | ------------------ |
| `@typescript-eslint/eslint-plugin` | `typescript` |
| `eslint-plugin-react` / `eslint-plugin-react-hooks` | `react` |
| `eslint-plugin-import` / `eslint-plugin-import-x` | `import` |
| `eslint-plugin-unicorn` | `unicorn` |
| `eslint-plugin-jsx-a11y` | `jsx-a11y` |
| `eslint-plugin-react-perf` | `react-perf` |
| `eslint-plugin-promise` | `promise` |
| `eslint-plugin-jest` | `jest` |
| `@vitest/eslint-plugin` | `vitest` |
| `eslint-plugin-jsdoc` | `jsdoc` |
| `eslint-plugin-next` | `nextjs` |
| `eslint-plugin-node` | `node` |
| `eslint-plugin-vue` | `vue` |
Default plugins (enabled when `plugins` field is omitted): `unicorn`, `typescript`, `oxc`.
Setting the `plugins` array explicitly overrides these defaults.
ESLint core rules are usable in oxlint without needing to configure a plugin in the config file.
### Rule Categories
Oxlint groups rules into categories for bulk configuration, though only `correctness` is enabled by default:
```json
{
"categories": {
"correctness": "error",
"suspicious": "warn"
}
}
```
Available categories: `correctness` (default: enabled), `suspicious`, `pedantic`, `perf`, `style`, `restriction`, `nursery`.
Individual rule settings in `rules` override category settings.
`@oxlint/migrate` will turn `correctness` off to avoid enabling additional rules that weren't enabled by your ESLint config. You can choose to enable additional categories after migration if desired.
### Check Unmigrated Rules
Run with `--details` to see which ESLint rules could not be migrated:
```bash
npx @oxlint/migrate --details
```
Review the output and decide whether to keep ESLint for those rules or not. Some rules may be mentioned in the output from `--details` as having equivalents in oxlint that were not automatically mapped by the migration tool. In those cases, consider enabling the equivalent oxlint rule manually after migration.
## Step 3: Install Oxlint
Install the core oxlint package (use `yarn install`, `pnpm install`, `vp install`, `bun install`, etc. depending on your package manager):
```bash
npm install -D oxlint
```
If you want to add the `oxlint-tsgolint` package, if you intend to use type-aware rules that require TypeScript type information:
```bash
npm install -D oxlint-tsgolint
```
No other packages besides the above are needed by default, though you will need to keep/install any additional ESLint plugins that were migrated into `jsPlugins`. Do not add `@oxlint/migrate` to the package.json, it is meant for one-off usage.
## Step 4: Handle Unsupported Features
Some features require manual attention:
- Local plugins (relative path imports): Must be migrated manually to `jsPlugins`
- `eslint-plugin-prettier`: Supported, but very slow. It is recommended to use [oxfmt](https://oxc.rs/docs/guide/usage/formatter) instead, or switch to `prettier --check` as a separate step alongside oxlint.
- `settings` in override configs: Oxlint does not support `settings` inside `overrides` blocks.
- ESLint v9+ plugins: Not all work with oxlint's JS Plugins API, but the majority will.
### Local Plugins
If you have any custom ESLint rules in the project repo itself, you can migrate them manually after running the migration tool by adding them to the `jsPlugins` field in `.oxlintrc.json`:
```json
{
"jsPlugins": ["./path/to/my-plugin.js"],
"rules": {
"local-plugin/rule-name": "error"
}
}
```
### External ESLint Plugins
For ESLint plugins without a built-in oxlint equivalent, use the `jsPlugins` field to load them:
```json
{
"jsPlugins": ["eslint-plugin-custom"],
"rules": {
"custom/my-rule": "warn"
}
}
```
## Step 5: Update CI and Scripts
Replace ESLint commands with oxlint. Path arguments are optional; oxlint defaults to the current working directory.
```bash
# Before
npx eslint src/
npx eslint --fix src/
# After
npx oxlint src/
npx oxlint --fix src/
```
### Common CLI Options
| ESLint | oxlint equivalent |
| ------------------------- | ---------------------------------------------- |
| `eslint .` | `oxlint` (default: lints the cwd) |
| `eslint src/` | `oxlint src/` |
| `eslint --fix` | `oxlint --fix` |
| `eslint --max-warnings 0` | `oxlint --deny-warnings` or `--max-warnings 0` |
| `eslint --format json` | `oxlint --format json` |
Additional oxlint options:
- `--tsconfig <path>`: Specify tsconfig.json path, likely unnecessary unless you have a non-standard name for `tsconfig.json`.
## Tips
- You can run alongside ESLint if necessary: Oxlint is designed to complement ESLint during migration, but with JS Plugins many projects can switch over fully without losing many rules.
- Disable comments work: `// eslint-disable` and `// eslint-disable-next-line` comments are supported by oxlint. Use `--replace-eslint-comments` when running @oxlint/migrate to convert them to `// oxlint-disable` equivalents if desired.
- List available rules: Run `npx oxlint --rules` to see all supported rules, or refer to the [rule documentation](https://oxc.rs/docs/guide/usage/linter/rules.html).
- Schema support: Add `"$schema": "./node_modules/oxlint/configuration_schema.json"` to `.oxlintrc.json` for editor autocompletion if the migration tool didn't do it automatically.
- Output formats: `default`, `stylish`, `json`, `github`, `gitlab`, `junit`, `checkstyle`, `unix`
- Ignore files: `.eslintignore` is supported by oxlint if you have it, but it's recommended to move any ignore patterns into the `ignorePatterns` field in `.oxlintrc.json` for consistency and simplicity. All files and paths ignored via a `.gitignore` file will be ignored by oxlint by default as well.
- If you ran the migration tool multiple times, remove the `.oxlintrc.json.bak` backup file created by the migration tool once you've finished migrating.
- If you are not using any JS Plugins and have replaced your ESLint configuration, you can remove all ESLint packages from your project dependencies.
- Ensure your editor is configured to use oxlint instead of ESLint for linting and error reporting. You may want to install the Oxc extension for your preferred editor. See https://oxc.rs/docs/guide/usage/linter/editors.html for more details.
## References
- [CLI Reference](https://oxc.rs/docs/guide/usage/linter/cli.html)
- [Config File Reference](https://oxc.rs/docs/guide/usage/linter/config-file-reference.html)
- [Complete Oxlint rule list and docs](https://oxc.rs/docs/guide/usage/linter/rules.html)

View File

@@ -1,14 +0,0 @@
{
"name": "remanso-local",
"description": "Local plugins for the Remanso project",
"owner": {
"name": "julien"
},
"plugins": [
{
"name": "remanso-skills",
"description": "Local skills for the Remanso project (migrate-oxlint, etc.)",
"source": "./.agents"
}
]
}

1
.gitignore vendored
View File

@@ -2,7 +2,6 @@
node_modules
/dist
# local env files
.env.local
.env.*.local

View File

@@ -28,8 +28,6 @@ RUN pnpm run build
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
HEALTHCHECK --interval=30s --timeout=3s \
CMD wget -qO- http://localhost:80/ || exit 1

View File

@@ -28,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

View File

@@ -0,0 +1,231 @@
# 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 (~200300 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

View File

@@ -1,5 +1,5 @@
<!doctype html>
<html lang="en" data-theme="garden">
<html lang="en" data-theme="emerald">
<head>
<meta charset="utf-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />

9
nginx.conf Normal file
View File

@@ -0,0 +1,9 @@
server {
listen 80;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
}

View File

@@ -28,11 +28,14 @@
"@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",

105
pnpm-lock.yaml generated
View File

@@ -38,6 +38,12 @@ importers:
'@toycode/markdown-it-class':
specifier: ^1.2.4
version: 1.2.4
'@ts-rest/core':
specifier: ^3.52.1
version: 3.52.1(@types/node@22.15.24)(zod@3.25.76)
'@ts-rest/vue-query':
specifier: ^3.52.1
version: 3.52.1(@tanstack/vue-query@5.92.9(vue@3.5.18(typescript@5.9.3)))(@ts-rest/core@3.52.1(@types/node@22.15.24)(zod@3.25.76))(zod@3.25.76)
'@vscode/markdown-it-katex':
specifier: ^1.1.2
version: 1.1.2
@@ -53,6 +59,9 @@ importers:
arktype:
specifier: ^2.1.29
version: 2.1.29
comlink:
specifier: ^4.4.2
version: 4.4.2
date-fns:
specifier: ^4.1.0
version: 4.1.0
@@ -1053,12 +1062,6 @@ packages:
cpu: [x64]
os: [win32]
'@eslint-community/eslint-utils@4.4.0':
resolution: {integrity: sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==}
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
peerDependencies:
eslint: ^6.0.0 || ^7.0.0 || >=8.0.0
'@eslint-community/eslint-utils@4.7.0':
resolution: {integrity: sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw==}
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
@@ -1069,10 +1072,6 @@ packages:
resolution: {integrity: sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==}
engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0}
'@eslint-community/regexpp@4.6.2':
resolution: {integrity: sha512-pPTNuaAG3QMH+buKyBIGJs3g/S5y0caxw0ygM3YyE6yJFySwiGGSzA+mM3KJ8QQvzeLh3blwgSonkFjgQdxzMw==}
engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0}
'@eslint/eslintrc@2.1.4':
resolution: {integrity: sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==}
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
@@ -2119,6 +2118,27 @@ packages:
'@toycode/markdown-it-class@1.2.4':
resolution: {integrity: sha512-hA4gHBK8moObkOYdWTjhy1wYcYy0MJeM3JjSKbsXHRpRMvIKhk6Jm+t3bXsSScTdz/byWqQbs8YIwVYjHp+SlQ==}
'@ts-rest/core@3.52.1':
resolution: {integrity: sha512-tAjz7Kxq/grJodcTA1Anop4AVRDlD40fkksEV5Mmal88VoZeRKAG8oMHsDwdwPZz+B/zgnz0q2sF+cm5M7Bc7g==}
peerDependencies:
'@types/node': ^18.18.7 || >=20.8.4
zod: ^3.22.3
peerDependenciesMeta:
'@types/node':
optional: true
zod:
optional: true
'@ts-rest/vue-query@3.52.1':
resolution: {integrity: sha512-89u7aS9LGDC7uNUC5CagWX1EB7vTwyXohYcizLi1D9v7MD/Cnu5OTQNf8SY3PuAK62RcFJXB2XZGsMAPC0svNw==}
peerDependencies:
'@tanstack/vue-query': ^4.0.0
'@ts-rest/core': ~3.52.0
zod: ^3.22.3
peerDependenciesMeta:
zod:
optional: true
'@tybys/wasm-util@0.10.1':
resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==}
@@ -2985,6 +3005,9 @@ packages:
resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==}
engines: {node: '>= 0.8'}
comlink@4.4.2:
resolution: {integrity: sha512-OxGdvBmJuNKSCMO4NTl1L47VRp6xn2wG4F/2hYzB6tiCb709otOxtEYCSvK80PtjODfXXZu8ds+Nw5kVCjqd2g==}
commander@2.20.3:
resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==}
@@ -3275,15 +3298,6 @@ packages:
supports-color:
optional: true
debug@4.3.4:
resolution: {integrity: sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==}
engines: {node: '>=6.0'}
peerDependencies:
supports-color: '*'
peerDependenciesMeta:
supports-color:
optional: true
debug@4.4.1:
resolution: {integrity: sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==}
engines: {node: '>=6.0'}
@@ -4959,9 +4973,6 @@ packages:
ms@2.0.0:
resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==}
ms@2.1.2:
resolution: {integrity: sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==}
ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
@@ -7642,25 +7653,17 @@ snapshots:
'@esbuild/win32-x64@0.25.5':
optional: true
'@eslint-community/eslint-utils@4.4.0(eslint@8.57.1)':
dependencies:
eslint: 8.57.1
eslint-visitor-keys: 3.4.3
'@eslint-community/eslint-utils@4.7.0(eslint@8.57.1)':
dependencies:
eslint: 8.57.1
eslint-visitor-keys: 3.4.3
'@eslint-community/regexpp@4.12.1':
optional: true
'@eslint-community/regexpp@4.6.2': {}
'@eslint-community/regexpp@4.12.1': {}
'@eslint/eslintrc@2.1.4':
dependencies:
ajv: 6.12.6
debug: 4.3.4
debug: 4.4.3
espree: 9.6.1
globals: 13.20.0
ignore: 5.2.4
@@ -7676,7 +7679,7 @@ snapshots:
'@humanwhocodes/config-array@0.13.0':
dependencies:
'@humanwhocodes/object-schema': 2.0.3
debug: 4.3.4
debug: 4.4.3
minimatch: 3.1.2
transitivePeerDependencies:
- supports-color
@@ -8424,6 +8427,18 @@ snapshots:
'@toycode/markdown-it-class@1.2.4': {}
'@ts-rest/core@3.52.1(@types/node@22.15.24)(zod@3.25.76)':
optionalDependencies:
'@types/node': 22.15.24
zod: 3.25.76
'@ts-rest/vue-query@3.52.1(@tanstack/vue-query@5.92.9(vue@3.5.18(typescript@5.9.3)))(@ts-rest/core@3.52.1(@types/node@22.15.24)(zod@3.25.76))(zod@3.25.76)':
dependencies:
'@tanstack/vue-query': 5.92.9(vue@3.5.18(typescript@5.9.3))
'@ts-rest/core': 3.52.1(@types/node@22.15.24)(zod@3.25.76)
optionalDependencies:
zod: 3.25.76
'@tybys/wasm-util@0.10.1':
dependencies:
tslib: 2.8.1
@@ -8656,7 +8671,7 @@ snapshots:
'@typescript-eslint/types': 8.46.2
'@typescript-eslint/typescript-estree': 8.46.2(typescript@5.9.3)
'@typescript-eslint/visitor-keys': 8.46.2
debug: 4.4.1
debug: 4.4.3
eslint: 8.57.1
typescript: 5.9.3
transitivePeerDependencies:
@@ -8676,7 +8691,7 @@ snapshots:
dependencies:
'@typescript-eslint/tsconfig-utils': 8.46.2(typescript@5.9.3)
'@typescript-eslint/types': 8.46.2
debug: 4.4.1
debug: 4.4.3
typescript: 5.9.3
transitivePeerDependencies:
- supports-color
@@ -8707,7 +8722,7 @@ snapshots:
'@typescript-eslint/types': 8.46.2
'@typescript-eslint/typescript-estree': 8.46.2(typescript@5.9.3)
'@typescript-eslint/utils': 8.46.2(eslint@8.57.1)(typescript@5.9.3)
debug: 4.4.1
debug: 4.4.3
eslint: 8.57.1
ts-api-utils: 2.1.0(typescript@5.9.3)
typescript: 5.9.3
@@ -8742,7 +8757,7 @@ snapshots:
'@typescript-eslint/tsconfig-utils': 8.46.2(typescript@5.9.3)
'@typescript-eslint/types': 8.46.2
'@typescript-eslint/visitor-keys': 8.46.2
debug: 4.4.1
debug: 4.4.3
fast-glob: 3.3.3
is-glob: 4.0.3
minimatch: 9.0.5
@@ -9542,6 +9557,8 @@ snapshots:
dependencies:
delayed-stream: 1.0.0
comlink@4.4.2: {}
commander@2.20.3: {}
commander@7.2.0: {}
@@ -9836,10 +9853,6 @@ snapshots:
dependencies:
ms: 2.1.3
debug@4.3.4:
dependencies:
ms: 2.1.2
debug@4.4.1:
dependencies:
ms: 2.1.3
@@ -10155,8 +10168,8 @@ snapshots:
eslint@8.57.1:
dependencies:
'@eslint-community/eslint-utils': 4.4.0(eslint@8.57.1)
'@eslint-community/regexpp': 4.6.2
'@eslint-community/eslint-utils': 4.7.0(eslint@8.57.1)
'@eslint-community/regexpp': 4.12.1
'@eslint/eslintrc': 2.1.4
'@eslint/js': 8.57.1
'@humanwhocodes/config-array': 0.13.0
@@ -10165,8 +10178,8 @@ snapshots:
'@ungap/structured-clone': 1.2.0
ajv: 6.12.6
chalk: 4.1.2
cross-spawn: 7.0.3
debug: 4.3.4
cross-spawn: 7.0.6
debug: 4.4.3
doctrine: 3.0.0
escape-string-regexp: 4.0.0
eslint-scope: 7.2.2
@@ -11745,8 +11758,6 @@ snapshots:
ms@2.0.0: {}
ms@2.1.2: {}
ms@2.1.3: {}
multiformats@9.9.0: {}

View File

@@ -17,7 +17,7 @@ const { isATProtoReady } = useATProtoLogin()
<style lang="scss">
#main-app {
height: 100vh;
height: 100dvh;
width: 100%;
display: flex;
flex: 1;

View File

@@ -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>

View File

@@ -1,7 +1,6 @@
<script lang="ts" setup>
import {
computed,
defineAsyncComponent,
nextTick,
onMounted,
onUnmounted,
@@ -9,6 +8,7 @@ import {
watch
} from "vue"
import HeaderNote from "@/components/HeaderNote.vue"
import SkeletonLoader from "@/components/SkeletonLoader.vue"
import StackedNote from "@/components/StackedNote.vue"
import { useLinks } from "@/hooks/useLinks.hook"
@@ -21,10 +21,6 @@ import CacheAllNotes from "@/modules/note/components/CacheAllNote.vue"
import { useUserRepoStore } from "@/modules/repo/store/userRepo.store"
import { useUserSettings } from "@/modules/user/hooks/useUserSettings.hook"
const HeaderNote = defineAsyncComponent(
() => import("@/components/HeaderNote.vue")
)
const props = withDefaults(
defineProps<{
user: string

View File

@@ -7,50 +7,106 @@ 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 />
<div class="font-change">
<div>
<label for="title-font" class="font-label">t</label>
<select
id="title-font"
class="select"
:value="store.userSettings?.chosenFontFamily"
@change="store.setFontFamily(($event.target as HTMLSelectElement).value)"
v-model="titleFont"
>
<option v-for="font in sortedFontFamilies" :key="font" :value="font">
{{ font }}
</option>
</select>
<label for="body-font" class="font-label">p</label>
<select
id="body-font"
class="select"
:value="store.userSettings?.chosenFontSize"
@change="store.setFontSize(($event.target as HTMLSelectElement).value)"
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>

View File

@@ -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);
}

View File

@@ -6,9 +6,9 @@ const goHome = () => router.push({ name: "Home" })
</script>
<template>
<a class="btn btn-ghost btn-circle btn-lg" @click="goHome">
<button class="btn btn-ghost btn-circle btn-lg text-base-content" @click="goHome">
<img src="/favicon.png" alt="Remanso icon" class="remanso-logo" />
</a>
</button>
</template>
<style>

View File

@@ -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>

View File

@@ -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;

View File

@@ -278,6 +278,7 @@ $border-color: rgba(18, 19, 58, 0.2);
section {
padding: 1rem 0 2rem;
overflow-x: auto;
overflow-y: hidden;
}
.note-content {

View File

@@ -180,6 +180,7 @@ $border-color: rgba(18, 19, 58, 0.2);
section {
padding: 1rem 0 2rem;
overflow-x: auto;
overflow-y: hidden;
}
.note-content {

View File

@@ -3,16 +3,19 @@ import RepoList from "@/components/RepoList.vue"
import SignInAtproto from "@/components/SignInAtproto.vue"
import SignInGithub from "@/components/SignInGithub.vue"
import ThemeSwap from "@/components/ThemeSwap.vue"
import { useATProtoLogin } from "@/hooks/useATProtoLogin.hook"
import { useForm } from "@/hooks/useForm.hook"
import { useGitHubLogin } from "@/hooks/useGitHubLogin.hook"
import LastVisited from "@/modules/history/components/LastVisited.vue"
const { isLogged } = useGitHubLogin()
const { isLoggedIn: isATProtoLoggedIn, avatarUrl } = useATProtoLogin()
const { userInput, repoInput, submit } = useForm()
</script>
<template>
<div class="welcome-world">
<div class="welcome-content">
<h1 class="title is-1">
<img src="/favicon.png" alt="Remanso icon" class="remanso-logo" />
Remanso
@@ -22,13 +25,6 @@ const { userInput, repoInput, submit } = useForm()
<last-visited />
<div class="get-started">
<sign-in-github />
<router-link v-if="isLogged" :to="{ name: 'RepoList' }" class="btn btn-sm"
>Manage your repos</router-link
>
</div>
<form class="github-form" @submit.prevent>
<div>github/</div>
<input
@@ -44,12 +40,15 @@ const { userInput, repoInput, submit } = useForm()
type="text"
placeholder="repo"
/>
<button type="submit" class="btn btn-primary" @click="submit">go</button>
<button type="submit" class="btn btn-sm btn-primary" @click="submit">
go
</button>
</form>
</div>
<footer>
<theme-swap />
Made with
made with
<svg
xmlns="http://www.w3.org/2000/svg"
class="icon icon-tabler icon-tabler-heart"
@@ -71,7 +70,32 @@ const { userInput, repoInput, submit } = useForm()
<a href="https://apoena.dev" target="_blank" rel="noopener noreferrer"
>apoena</a
>
<sign-in-atproto :with-sign-out="false" />
<button
class="btn btn-ghost btn-circle btn-sm profile-btn"
onclick="profile_modal.showModal()"
>
<img
v-if="isATProtoLoggedIn && avatarUrl"
:src="avatarUrl"
class="profile-avatar"
alt="Profile"
/>
<svg
v-else
xmlns="http://www.w3.org/2000/svg"
width="28"
height="28"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M12 12m-4 0a4 4 0 1 0 8 0a4 4 0 1 0 -8 0" />
<path d="M6 20c0 -2.21 2.686 -4 6 -4s6 1.79 6 4" />
</svg>
</button>
<router-link
:to="{
name: 'FluxNoteView',
@@ -81,6 +105,28 @@ const { userInput, repoInput, submit } = useForm()
>Get started</router-link
>
</footer>
<dialog id="profile_modal" class="modal">
<div class="modal-box profile-modal-box">
<h3 class="text-lg font-bold">Profile</h3>
<div class="profile-section">
<sign-in-atproto :with-sign-out="true" />
</div>
<div class="divider"></div>
<div class="profile-section">
<sign-in-github />
<router-link
v-if="isLogged"
:to="{ name: 'RepoList' }"
class="btn btn-sm"
>Manage your repos</router-link
>
</div>
</div>
<form method="dialog" class="modal-backdrop">
<button></button>
</form>
</dialog>
</div>
</template>
@@ -98,26 +144,26 @@ h1 {
}
.welcome-world {
padding: 1rem;
margin: auto;
display: flex;
flex: 1;
align-self: stretch;
flex-direction: column;
justify-content: space-between;
.get-started {
margin: center;
text-align: center;
display: flex;
flex-wrap: wrap;
gap: 1rem;
justify-content: center;
}
.title {
text-align: center;
}
}
.welcome-content {
display: flex;
flex: 1;
flex-direction: column;
gap: 1rem;
padding: 0 0.5rem;
}
.github-form {
display: flex;
align-items: center;
@@ -127,15 +173,33 @@ h1 {
max-width: 140px;
}
}
footer {
display: flex;
gap: 1rem;
gap: 0.2rem;
align-items: center;
img {
vertical-align: middle;
margin-top: 0;
justify-content: space-around;
padding: 0.5rem;
}
.profile-avatar {
max-width: 100%;
border-radius: 50%;
object-fit: cover;
box-shadow: none;
}
.profile-modal-box {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.profile-section {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
align-items: center;
}
.to-user-repo {

View File

@@ -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 {
// 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)
}
}
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
View 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())

View File

@@ -14,14 +14,32 @@ import {
const did = ref<string | null>(null)
const handle = ref<string | null>(null)
const avatarUrl = ref<string | null>(null)
let init = true
const fetchAvatar = async (actorDid: string) => {
try {
const res = await fetch(
`https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(actorDid)}`
)
if (res.ok) {
const data = await res.json()
avatarUrl.value = data.avatar ?? null
}
} catch {
avatarUrl.value = null
}
}
const initializeAuth = async () => {
// 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()
@@ -32,6 +50,7 @@ const initializeAuth = async () => {
did.value = session.did
handle.value = resolvedHandle
await saveSession(session.did, resolvedHandle)
fetchAvatar(session.did)
window.history.replaceState(
null,
@@ -61,11 +80,13 @@ export const useATProtoLogin = () => {
await clearSession()
did.value = ""
handle.value = ""
avatarUrl.value = null
}
return {
did,
handle,
avatarUrl,
isLoggedIn,
isATProtoReady,
signIn,

View File

@@ -2,7 +2,7 @@ import { useAsyncState } from "@vueuse/core"
import { ComputedRef, onUnmounted, toValue } from "vue"
import { backlinkEventBus } from "@/bus/backlinkEventBus"
import { data } from "@/data/data"
import { data, generateId } from "@/data/data"
import { DataType } from "@/data/DataType.enum"
import { BacklinkNote } from "@/modules/note/models/BacklinkNote"
@@ -11,7 +11,7 @@ export const useBacklinks = (sha: string | ComputedRef<string>) => {
const { state: backlink, execute } = useAsyncState(
data.get<DataType.BacklinkNote, BacklinkNote>(
data.generateId(DataType.BacklinkNote, sha)
generateId(DataType.BacklinkNote, sha)
),
null,
{

View File

@@ -1,7 +1,7 @@
import { watch } from "vue"
import { backlinkEventBus } from "@/bus/backlinkEventBus"
import { data } from "@/data/data"
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"
@@ -14,10 +14,19 @@ import { confirmMessage } from "@/utils/notif"
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,11 +36,13 @@ 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 fileBacklinkId = generateId(DataType.BacklinkNote, file.sha)
const fileBacklink = await data.get<DataType.BacklinkNote, BacklinkNote>(
fileBacklinkId
)
@@ -91,7 +102,7 @@ export const useComputeBacklinks = () => {
}
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 +113,6 @@ export const useComputeBacklinks = () => {
await data.update(backlinkNote)
backlinkEventBus.emit({ fileSha: sha })
}
})
}
)
}

View File

@@ -1,7 +1,7 @@
import { useAsyncState } from "@vueuse/core"
import { computed, ref } from "vue"
import { data } from "@/data/data"
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"
@@ -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
}

View File

@@ -1,34 +1,59 @@
import { useAsyncState } from "@vueuse/core"
import { computed, ref } from "vue"
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 currentPage = ref(0)
const totalCount = ref(0)
let lastFetchedAt = 0
export const useRepos = () => {
const { username, accessToken } = useGitHubLogin()
const repos = useAsyncState<RepoBase[]>(async () => {
const loadMore = async () => {
if (!accessToken.value || !username.value) {
return []
isReady.value = true
return
}
const octokit = await getOctokit()
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
)
isReady.value = true
}
return {
repos: repos.state,
isReady: repos.isReady
const canLoadMore = computed(() => repos.value.length < totalCount.value)
const isStale = Date.now() - lastFetchedAt > STALE_TIME_MS
if (!isReady.value || isStale) {
if (isStale && isReady.value) {
repos.value = []
currentPage.value = 0
totalCount.value = 0
isReady.value = false
}
lastFetchedAt = Date.now()
loadMore()
}
return { repos, isReady, canLoadMore, loadMore }
}

View File

@@ -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"
@@ -51,7 +51,7 @@ 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(),

View File

@@ -1,17 +1,17 @@
import { useAsyncState } from "@vueuse/core"
import { computed } from "vue"
import { data } from "@/data/data"
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
)

View File

@@ -1,10 +1,10 @@
import { Ref, toValue } from "vue"
import { data } from "@/data/data"
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: {

View File

@@ -1,4 +1,4 @@
import { data } from "@/data/data"
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"
@@ -14,8 +14,8 @@ type NoteCacheResult =
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)

View File

@@ -29,31 +29,19 @@ export const noteRouter = contract.router({
noteLists: {
method: "GET",
path: "/notes",
query: type({
cursor: "string | undefined",
limit: "number | undefined"
}),
query: contract.type<{ cursor?: string; limit?: number }>(),
responses: {
200: type({
notes: PublicNoteListItem.array()
})
200: contract.type<{ notes: PublicNoteListItem[] }>()
},
summary: "List all notes"
},
noteListsByDid: {
method: "GET",
path: "/:did/notes",
pathParams: type({
did: "string"
}),
query: type({
cursor: "string | undefined",
limit: "number | undefined"
}),
pathParams: contract.type<{ did: string }>(),
query: contract.type<{ cursor?: string; limit?: number }>(),
responses: {
200: type({
notes: PublicNoteListItem.array()
})
200: contract.type<{ notes: PublicNoteListItem[] }>()
},
summary: "List all notes"
}

View File

@@ -1,6 +1,6 @@
import { computed, onMounted, ref } from "vue"
import { data } from "@/data/data"
import { data, generateId } from "@/data/data"
import { DataType } from "@/data/DataType.enum"
import { useRepos } from "@/hooks/useRepos.hook"
import { RepoBase } from "@/modules/repo/interfaces/RepoBase"
@@ -27,7 +27,7 @@ export const useFavoriteRepos = () => {
const toggleFavorite = async (repo: RepoBase, isFavorite: boolean) => {
const favorite: FavoriteRepo = {
_id: data.generateId(DataType.FavoriteRepo, repo.id),
_id: generateId(DataType.FavoriteRepo, repo.id),
$type: DataType.FavoriteRepo,
isFavorite,
name: repo.name,

View File

@@ -6,7 +6,7 @@ import { RepoBase } from "@/modules/repo/interfaces/RepoBase"
export const useRepoList = () => {
const { savedFavoriteRepos, addFavorite, removeFavorite } = useFavoriteRepos()
const { repos } = useRepos()
const { repos, canLoadMore, loadMore } = useRepos()
const favoriteRepos = computed(() => {
return repos.value.filter((repo) =>
@@ -38,6 +38,8 @@ export const useRepoList = () => {
favoriteRepos,
otherRepos,
favoriteCheckboxes,
toggleCheckbox
toggleCheckbox,
canLoadMore,
loadMore
}
}

View File

@@ -8,4 +8,6 @@ export interface UserSettings extends Model<DataType.UserSettings> {
fontSize?: string
chosenFontSize?: string
backlink?: boolean
chosenTitleFont?: string
chosenBodyFont?: string
}

View File

@@ -104,7 +104,16 @@ export const getUserSettingsContent = async (
return null
}
return JSON.parse(atob(content)) as UserSettings
const raw = JSON.parse(atob(content)) as UserSettings & {
t?: string
p?: string
}
const { t, p, ...rest } = raw
return {
...rest,
chosenTitleFont: t,
chosenBodyFont: p
}
}
export const queryFileContent = async (

View File

@@ -1,6 +1,6 @@
import { defineStore } from "pinia"
import { data } from "@/data/data"
import { data, generateId } from "@/data/data"
import { DataType } from "@/data/DataType.enum"
import { RepoFile } from "@/modules/repo/interfaces/RepoFile"
import { UserSettings } from "@/modules/repo/interfaces/UserSettings"
@@ -20,6 +20,7 @@ interface State {
readme?: string | null
userSettings?: UserSettings | null
needToLogin: boolean
_requestId: number
}
export const useUserRepoStore = defineStore("USER_REPO_STATE", {
@@ -29,40 +30,71 @@ export const useUserRepoStore = defineStore("USER_REPO_STATE", {
files: [],
readme: undefined,
userSettings: undefined,
needToLogin: false
needToLogin: false,
_requestId: 0
}),
actions: {
_persistFonts() {
if (!this.userSettings) return
try {
const { chosenTitleFont, chosenBodyFont, chosenFontSize, chosenFontFamily } =
this.userSettings
localStorage.setItem(
`remanso:fonts:${this.user}:${this.repo}`,
JSON.stringify({ chosenTitleFont, chosenBodyFont, chosenFontSize, chosenFontFamily })
)
} catch {
// ignore
}
},
async setUserRepo(user: string, repo: string) {
const requestId = ++this._requestId
this.user = user
this.repo = repo
const savedRepoId = data.generateId(DataType.SavedRepo, `${user}-${repo}`)
const cachedSavedRepo = await data.get<DataType.SavedRepo, SavedRepo>(
savedRepoId
)
let lsFonts: Partial<UserSettings> = {}
try {
const lsRaw = localStorage.getItem(`remanso:fonts:${user}:${repo}`)
if (lsRaw) lsFonts = JSON.parse(lsRaw)
} catch {
// ignore
}
if (Object.keys(lsFonts).length) {
if (!this.userSettings) this.userSettings = { $type: DataType.UserSettings }
Object.assign(this.userSettings, lsFonts)
}
const savedRepoId = generateId(DataType.SavedRepo, `${user}-${repo}`)
const userSettingsId = `UserSetting-${user}-${repo}`
const [cachedSavedRepo, cachedUserSettings] = await Promise.all([
data.get<DataType.SavedRepo, SavedRepo>(savedRepoId),
data.get<DataType.UserSettings, UserSettings>(userSettingsId)
])
if (requestId !== this._requestId) return
if (cachedSavedRepo) {
this.files = cachedSavedRepo.files
}
if (cachedUserSettings) {
// localStorage font choices take priority over PouchDB cache
this.userSettings = { ...cachedUserSettings, ...lsFonts }
}
try {
await refreshToken()
} catch (error) {
console.warn("impossible to refresh token", error)
}
const userSettingsId = `UserSetting-${user}-${repo}`
const cachedUserSettings = await data.get<
DataType.UserSettings,
UserSettings
>(userSettingsId)
if (cachedUserSettings) {
this.userSettings = cachedUserSettings
}
if (requestId !== this._requestId) return
getFiles(user, repo)
.then(async (files) => {
if (requestId !== this._requestId) return
data.update<DataType.SavedRepo, SavedRepo>({
_id: savedRepoId,
$type: DataType.SavedRepo,
@@ -74,6 +106,7 @@ export const useUserRepoStore = defineStore("USER_REPO_STATE", {
return getUserSettingsContent(user, repo, files)
})
.then((userSettings) => {
if (requestId !== this._requestId) return
const chosenFontFamily = userSettings?.fontFamilies?.find(
(font) => font === this.userSettings?.chosenFontFamily
)
@@ -81,24 +114,43 @@ export const useUserRepoStore = defineStore("USER_REPO_STATE", {
: userSettings?.fontFamily
const chosenFontSize =
this.userSettings?.chosenFontSize ?? userSettings?.fontSize
const chosenTitleFont =
this.userSettings?.chosenTitleFont ??
userSettings?.chosenTitleFont ??
chosenFontFamily
const chosenBodyFont =
this.userSettings?.chosenBodyFont ??
userSettings?.chosenBodyFont ??
chosenFontFamily
if (userSettings) {
this.userSettings = userSettings
if (!this.userSettings) {
return
} else if (!this.userSettings) {
this.userSettings = { $type: DataType.UserSettings }
}
this.userSettings.chosenFontFamily =
chosenFontFamily ?? this.userSettings.fontFamily
this.userSettings.chosenFontSize =
chosenFontSize ?? this.userSettings.fontSize
this.userSettings.chosenTitleFont = chosenTitleFont
this.userSettings.chosenBodyFont = chosenBodyFont
// Persist only repo config fields — chosen* are localStorage-only
const {
chosenTitleFont: _t,
chosenBodyFont: _b,
chosenFontSize: _s,
chosenFontFamily: _f,
...repoConfig
} = this.userSettings
data.update<DataType.UserSettings, UserSettings>({
...this.userSettings,
...repoConfig,
_id: userSettingsId
})
})
getCachedMainReadme(user, repo).then(async (cachedReadme) => {
if (requestId !== this._requestId) return
this.readme = cachedReadme
this.readme = await getMainReadme(user, repo)
})
@@ -114,7 +166,7 @@ export const useUserRepoStore = defineStore("USER_REPO_STATE", {
return
}
const savedRepoId = data.generateId(
const savedRepoId = generateId(
DataType.SavedRepo,
`${this.user}-${this.repo}`
)
@@ -132,35 +184,39 @@ export const useUserRepoStore = defineStore("USER_REPO_STATE", {
this.user = ""
this.repo = ""
this.resetFiles()
this.userSettings = undefined
},
resetFiles() {
this.files = []
this.readme = null
this.userSettings = undefined
},
setFontFamily(fontFamily: string) {
if (!this.userSettings) {
return
this.userSettings = { $type: DataType.UserSettings }
}
this.userSettings.chosenFontFamily = fontFamily
const userSettingsId = `UserSetting-${this.user}-${this.repo}`
data.update<DataType.UserSettings, UserSettings>({
...this.userSettings,
_id: userSettingsId
})
this._persistFonts()
},
setFontSize(fontSize: string) {
if (!this.userSettings) {
return
this.userSettings = { $type: DataType.UserSettings }
}
this.userSettings.chosenFontSize = fontSize
const userSettingsId = `UserSetting-${this.user}-${this.repo}`
data.update<DataType.UserSettings, UserSettings>({
...this.userSettings,
_id: userSettingsId
})
this._persistFonts()
},
setTitleFont(font: string) {
if (!this.userSettings) {
this.userSettings = { $type: DataType.UserSettings }
}
this.userSettings.chosenTitleFont = font
this._persistFonts()
},
setBodyFont(font: string) {
if (!this.userSettings) {
this.userSettings = { $type: DataType.UserSettings }
}
this.userSettings.chosenBodyFont = font
this._persistFonts()
}
}
})

View File

@@ -12,10 +12,15 @@ export const useUserSettings = () => {
watchEffect(() => {
const root = document.documentElement
const fontFamily = store.userSettings?.chosenFontFamily
const fontSize = store.userSettings?.chosenFontSize
const bodyFont = store.userSettings?.chosenBodyFont
const titleFont = store.userSettings?.chosenTitleFont
downloadFont(fontFamily || DEFAULT_FONT_POLICY)
downloadFont(bodyFont || DEFAULT_FONT_POLICY, "--font-family")
downloadFont(
titleFont || bodyFont || DEFAULT_FONT_POLICY,
"--title-font-family"
)
root.style.setProperty("--font-size", fontSize || DEFAULT_FONT_SIZE)
})
}

View File

@@ -1,7 +1,7 @@
import { Octokit } from "@octokit/rest"
import { addMinutes, addSeconds, isBefore } from "date-fns"
import { data } from "@/data/data"
import { data, generateId } from "@/data/data"
import { DataType } from "@/data/DataType.enum"
import { GithubAccessToken } from "@/data/models/GithubAccessToken"
import { GithubToken } from "@/modules/user/interfaces/GithubToken"
@@ -26,7 +26,7 @@ export const needToRefreshToken = async () => {
const accessToken = await data.get<
DataType.GithubAccessToken,
GithubAccessToken
>(data.generateId(DataType.GithubAccessToken, personalTokenId))
>(generateId(DataType.GithubAccessToken, personalTokenId))
if (!accessToken) {
return false
@@ -42,7 +42,7 @@ export const refreshToken = async () => {
const accessToken = await data.get<
DataType.GithubAccessToken,
GithubAccessToken
>(data.generateId(DataType.GithubAccessToken, personalTokenId))
>(generateId(DataType.GithubAccessToken, personalTokenId))
if (!accessToken) {
return null
@@ -74,7 +74,7 @@ export const getAccessToken = async () => {
const response = await data.get<
DataType.GithubAccessToken,
GithubAccessToken
>(data.generateId(DataType.GithubAccessToken, personalTokenId))
>(generateId(DataType.GithubAccessToken, personalTokenId))
return response
}
@@ -94,7 +94,7 @@ export const saveAccessToken = async (githubToken: GithubToken) => {
const accessToken: GithubAccessToken = {
...actualPAT,
_id: data.generateId(DataType.GithubAccessToken, personalTokenId),
_id: generateId(DataType.GithubAccessToken, personalTokenId),
$type: DataType.GithubAccessToken,
token: githubToken.access_token,
expiresIn: githubToken.expires_in,

View File

@@ -104,7 +104,12 @@ router.beforeEach(() => {
}
).startViewTransition(async () => {
resolve()
await nextTick()
await new Promise<void>((r) => {
const unwatch = router.afterEach(() => {
unwatch()
nextTick().then(r)
})
})
})
})
})

6
src/shims-vue.d.ts vendored
View File

@@ -1,3 +1,9 @@
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
}
declare module "pouchdb-adapter-indexeddb"
declare module "@toycode/markdown-it-class"
declare module "markdown-it-block-embed"

View File

@@ -6,6 +6,7 @@
:root {
--primary-color: #ffa4c0;
--font-family: "Libertinus Serif", serif;
--title-font-family: "Libertinus Serif", serif;
--font-size: 13pt;
--font-color: #4a4a4a;
--link: #445fb9;
@@ -24,8 +25,8 @@
@plugin 'daisyui' {
themes:
garden --default,
dim --prefersdark;
emerald --default,
forest --prefersdark;
}
@config '../../tailwind.config.js';
@@ -48,22 +49,10 @@
}
}
html {
overflow-y: auto;
overflow-x: auto;
}
body {
height: 100vh;
scroll-behavior: smooth;
overflow-y: auto;
}
@media screen and (min-width: 769px) {
html,
body {
overflow-y: hidden;
}
height: 100dvh;
scroll-behavior: smooth;
}
.columns {
@@ -77,7 +66,7 @@ body {
}
#app {
height: 100vh;
height: 100dvh;
display: flex;
}

View File

@@ -2,6 +2,6 @@
// Update these values to change the light and dark themes
export const themeConfig = {
light: "garden",
dark: "dim"
light: 'emerald',
dark: 'forest'
}

View File

@@ -6,7 +6,10 @@ const assembleFontLink = (font: string) => {
.replaceAll(" ", "+")}`
}
export const downloadFont = async (font: string): Promise<void> => {
export const downloadFont = async (
font: string,
cssVar = "--font-family"
): Promise<void> => {
const href = assembleFontLink(font)
// check if the href already exists
@@ -23,7 +26,7 @@ export const downloadFont = async (font: string): Promise<void> => {
try {
await new FontFaceObserver(font).load()
document.documentElement.style.setProperty("--font-family", font)
document.documentElement.style.setProperty(cssVar, font)
} catch (error) {
console.warn("error when loading font")
}

View File

@@ -16,6 +16,7 @@ import WelcomeWorld from "@/components/WelcomeWorld.vue"
flex: 1;
flex-direction: column;
align-items: center;
height: 100dvh;
}
.authorize {

View File

@@ -4,7 +4,7 @@ import { useTitle } from "@vueuse/core"
import { computed, nextTick, ref, watch } from "vue"
import { useRouter } from "vue-router"
import BackButton from "@/components/BackButton.vue"
import HomeButton from "@/components/HomeButton.vue"
import SkeletonLoader from "@/components/SkeletonLoader.vue"
import StackedPublicNote from "@/components/StackedPublicNote.vue"
import ThemeSwap from "@/components/ThemeSwap.vue"
@@ -129,13 +129,7 @@ watch(
<main class="public-note-view repo-note note-container">
<div class="note article">
<div class="header">
<back-button
:fallback="{ name: 'PublicNoteListByDidView', params: { shortDid } }"
:prefer-fallback="false"
/>
<img src="/favicon.png" alt="Remanso" class="remanso-logo" />
<home-button />
<theme-swap />
</div>
<div class="subheader">
@@ -197,13 +191,6 @@ watch(
gap: 1rem;
}
.remanso-logo {
width: 32px;
height: 32px;
box-shadow: none;
view-transition-name: remanso-logo;
}
.subheader {
margin: 1rem auto 0;
}

View File

@@ -1,4 +1,6 @@
<script lang="ts" setup>
import { vInfiniteScroll } from "@vueuse/components"
import GoBack from "@/components/GoBack.vue"
import { useGitHubLogin } from "@/hooks/useGitHubLogin.hook"
import { useRepos } from "@/hooks/useRepos.hook"
@@ -6,12 +8,15 @@ import { useRepoList } from "@/modules/repo/hooks/useRepoList.hook"
const { username } = useGitHubLogin()
const { isReady } = useRepos()
const { favoriteRepos, otherRepos, favoriteCheckboxes, toggleCheckbox } =
const { favoriteRepos, otherRepos, favoriteCheckboxes, toggleCheckbox, canLoadMore, loadMore } =
useRepoList()
</script>
<template>
<div class="repo-list">
<div
class="repo-list"
v-infinite-scroll="[loadMore, { canLoadMore: () => canLoadMore }]"
>
<h1 class="title is-1">Repositories</h1>
<go-back />
<div v-if="!isReady">loading...</div>

View File

@@ -11,7 +11,8 @@ const defaultTitleStyles = Array.from(
...acc,
[heading]: {
"margin-top": "0",
"margin-bottom": "0.5em"
"margin-bottom": "0.5em",
"font-family": "var(--title-font-family)"
}
}),
{}

View File

@@ -47,11 +47,6 @@ export default defineConfig(({ command }) => {
sizes: "512x512",
type: "image/png"
},
{
src: "favicon.png",
sizes: "1024x1024",
type: "image/png"
},
{
src: "maskable-icon-512x512.png",
sizes: "512x512",