Compare commits
65 Commits
28ca9a17a9
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
@@ -5,10 +5,6 @@
|
|||||||
"label": "Vue i18n",
|
"label": "Vue i18n",
|
||||||
"uri": "https://vue-i18n.intlify.dev/guide/introduction.html"
|
"uri": "https://vue-i18n.intlify.dev/guide/introduction.html"
|
||||||
}
|
}
|
||||||
],
|
]
|
||||||
"vite.config.ts": {
|
|
||||||
"label": "Remanso GitHub app",
|
|
||||||
"uri": "https://github.com/organizations/remanso-spance/settings/apps/lite-note"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,37 +0,0 @@
|
|||||||
# Define the checksum file
|
|
||||||
CHECKSUM_FILE=".env.checksum"
|
|
||||||
|
|
||||||
# Calculate the current checksum of the .env file
|
|
||||||
CURRENT_CHECKSUM=$(shasum -a 256 .env | awk '{ print $1 }')
|
|
||||||
|
|
||||||
# Check if checksum file exists
|
|
||||||
if [ -f "$CHECKSUM_FILE" ]; then
|
|
||||||
# Read the previous checksum
|
|
||||||
PREVIOUS_CHECKSUM=$(cat "$CHECKSUM_FILE")
|
|
||||||
|
|
||||||
# Compare the current checksum with the previous checksum
|
|
||||||
if [ "$CURRENT_CHECKSUM" = "$PREVIOUS_CHECKSUM" ]; then
|
|
||||||
echo ".env file has not changed. Skipping Netlify environment import."
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
# If the checksum is different or the file doesn't exist, import the variables
|
|
||||||
echo "Importing environment variables to Netlify..."
|
|
||||||
netlify env:import .env
|
|
||||||
|
|
||||||
if [ $? -ne 0 ]; then
|
|
||||||
echo "Failed to import environment variables to Netlify. Aborting push."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Save the new checksum
|
|
||||||
echo "$CURRENT_CHECKSUM" > "$CHECKSUM_FILE"
|
|
||||||
|
|
||||||
# Stage the checksum file
|
|
||||||
git add "$CHECKSUM_FILE"
|
|
||||||
|
|
||||||
# Amend the last commit with the updated checksum
|
|
||||||
git commit -m "Update .env checksum"
|
|
||||||
|
|
||||||
echo "Environment variables imported successfully."
|
|
||||||
23
.zed/settings.json
Normal file
23
.zed/settings.json
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"format_on_save": "on",
|
||||||
|
"formatter": {
|
||||||
|
"external": {
|
||||||
|
"command": "node_modules/.bin/oxfmt",
|
||||||
|
"arguments": ["--stdin-filepath", "{buffer_path}"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"languages": {
|
||||||
|
"TypeScript": {
|
||||||
|
"language_servers": ["!deno", "..."]
|
||||||
|
},
|
||||||
|
"TSX": {
|
||||||
|
"language_servers": ["!deno", "..."]
|
||||||
|
},
|
||||||
|
"JavaScript": {
|
||||||
|
"language_servers": ["!deno", "..."]
|
||||||
|
},
|
||||||
|
"JSX": {
|
||||||
|
"language_servers": ["!deno", "..."]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
# ---- Stage 1: deps (only invalidated when lockfile changes) ----
|
# ---- Stage 1: deps (only invalidated when lockfile changes) ----
|
||||||
FROM node:22-alpine AS deps
|
FROM node:22-alpine AS deps
|
||||||
|
|
||||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
RUN corepack enable
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY package.json pnpm-lock.yaml ./
|
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
||||||
|
|
||||||
RUN pnpm install --frozen-lockfile
|
RUN pnpm install --frozen-lockfile
|
||||||
|
|
||||||
@@ -13,7 +13,7 @@ RUN pnpm install --frozen-lockfile
|
|||||||
# ---- Stage 2: build (invalidated on any source change) ----
|
# ---- Stage 2: build (invalidated on any source change) ----
|
||||||
FROM node:22-alpine AS builder
|
FROM node:22-alpine AS builder
|
||||||
|
|
||||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
RUN corepack enable
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
|||||||
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)
|
||||||
|
})
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en" data-theme="emerald">
|
<html lang="en" data-theme="light">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
"name": "remanso",
|
"name": "remanso",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
|
"packageManager": "pnpm@11.0.9",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
@@ -12,10 +13,8 @@
|
|||||||
"lint:fix": "oxlint --fix",
|
"lint:fix": "oxlint --fix",
|
||||||
"fmt": "oxfmt",
|
"fmt": "oxfmt",
|
||||||
"fmt:check": "oxfmt --check",
|
"fmt:check": "oxfmt --check",
|
||||||
"prepare": "husky",
|
|
||||||
"theme:light": "esno _scripts/change-theme-light.ts",
|
"theme:light": "esno _scripts/change-theme-light.ts",
|
||||||
"theme:dark": "esno _scripts/change-theme-dark.ts",
|
"theme:dark": "esno _scripts/change-theme-dark.ts"
|
||||||
"generate-pwa-assets": "pwa-assets-generator"
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@atproto/oauth-client-browser": "^0.3.41",
|
"@atproto/oauth-client-browser": "^0.3.41",
|
||||||
@@ -85,7 +84,6 @@
|
|||||||
"eslint-plugin-simple-import-sort": "^12.1.1",
|
"eslint-plugin-simple-import-sort": "^12.1.1",
|
||||||
"eslint-plugin-unused-imports": "^4.4.1",
|
"eslint-plugin-unused-imports": "^4.4.1",
|
||||||
"esno": "^4.8.0",
|
"esno": "^4.8.0",
|
||||||
"husky": "^9.1.7",
|
|
||||||
"oxfmt": "^0.42.0",
|
"oxfmt": "^0.42.0",
|
||||||
"oxlint": "^1.57.0",
|
"oxlint": "^1.57.0",
|
||||||
"prettier": "^3.8.1",
|
"prettier": "^3.8.1",
|
||||||
|
|||||||
10
pnpm-lock.yaml
generated
10
pnpm-lock.yaml
generated
@@ -204,9 +204,6 @@ importers:
|
|||||||
esno:
|
esno:
|
||||||
specifier: ^4.8.0
|
specifier: ^4.8.0
|
||||||
version: 4.8.0
|
version: 4.8.0
|
||||||
husky:
|
|
||||||
specifier: ^9.1.7
|
|
||||||
version: 9.1.7
|
|
||||||
oxfmt:
|
oxfmt:
|
||||||
specifier: ^0.42.0
|
specifier: ^0.42.0
|
||||||
version: 0.42.0
|
version: 0.42.0
|
||||||
@@ -4052,11 +4049,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-nZXjEF2nbo7lIw3mgYjItAfgQXog3OjJogSbKa2CQIIvSGWcKgeJnQlNXip6NglNzYH45nSRiEVimMvYL8DDqQ==}
|
resolution: {integrity: sha512-nZXjEF2nbo7lIw3mgYjItAfgQXog3OjJogSbKa2CQIIvSGWcKgeJnQlNXip6NglNzYH45nSRiEVimMvYL8DDqQ==}
|
||||||
engines: {node: '>=14.18.0'}
|
engines: {node: '>=14.18.0'}
|
||||||
|
|
||||||
husky@9.1.7:
|
|
||||||
resolution: {integrity: sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==}
|
|
||||||
engines: {node: '>=18'}
|
|
||||||
hasBin: true
|
|
||||||
|
|
||||||
ico-endec@0.1.6:
|
ico-endec@0.1.6:
|
||||||
resolution: {integrity: sha512-ZdLU38ZoED3g1j3iEyzcQj+wAkY2xfWNkymszfJPoxucIUhK7NayQ+/C4Kv0nDFMIsbtbEHldv3V8PU494/ueQ==}
|
resolution: {integrity: sha512-ZdLU38ZoED3g1j3iEyzcQj+wAkY2xfWNkymszfJPoxucIUhK7NayQ+/C4Kv0nDFMIsbtbEHldv3V8PU494/ueQ==}
|
||||||
|
|
||||||
@@ -10762,8 +10754,6 @@ snapshots:
|
|||||||
|
|
||||||
human-signals@4.3.1: {}
|
human-signals@4.3.1: {}
|
||||||
|
|
||||||
husky@9.1.7: {}
|
|
||||||
|
|
||||||
ico-endec@0.1.6: {}
|
ico-endec@0.1.6: {}
|
||||||
|
|
||||||
iconv-lite@0.4.24:
|
iconv-lite@0.4.24:
|
||||||
|
|||||||
@@ -1,2 +1,7 @@
|
|||||||
allowBuilds:
|
allowBuilds:
|
||||||
|
'@parcel/watcher': true
|
||||||
core-js: true
|
core-js: true
|
||||||
|
esbuild: true
|
||||||
|
fsevents: true
|
||||||
|
sharp: true
|
||||||
|
vue-demi: true
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 181 KiB |
BIN
public/monochromeicon.png
Normal file
BIN
public/monochromeicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 101 KiB |
@@ -19,8 +19,7 @@ onBeforeMount(async () => {
|
|||||||
if ("error" in token) {
|
if ("error" in token) {
|
||||||
hasError.value = true
|
hasError.value = true
|
||||||
} else {
|
} else {
|
||||||
token.access_token
|
await saveCredentials(token)
|
||||||
saveCredentials(token)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
router.replace({ name: "Home" })
|
router.replace({ name: "Home" })
|
||||||
|
|||||||
@@ -2,8 +2,10 @@
|
|||||||
import { computed, nextTick, onMounted, onUnmounted, toRefs, watch } from "vue"
|
import { computed, nextTick, onMounted, onUnmounted, toRefs, watch } from "vue"
|
||||||
|
|
||||||
import HeaderNote from "@/components/HeaderNote.vue"
|
import HeaderNote from "@/components/HeaderNote.vue"
|
||||||
|
import SignInGithub from "@/components/SignInGithub.vue"
|
||||||
import SkeletonLoader from "@/components/SkeletonLoader.vue"
|
import SkeletonLoader from "@/components/SkeletonLoader.vue"
|
||||||
import StackedNote from "@/components/StackedNote.vue"
|
import StackedNote from "@/components/StackedNote.vue"
|
||||||
|
import { useGitHubLogin } from "@/hooks/useGitHubLogin.hook"
|
||||||
import { useLinks } from "@/hooks/useLinks.hook"
|
import { useLinks } from "@/hooks/useLinks.hook"
|
||||||
import { markdownBuilder } from "@/hooks/useMarkdown.hook"
|
import { markdownBuilder } from "@/hooks/useMarkdown.hook"
|
||||||
import { useNoteView } from "@/hooks/useNoteView.hook"
|
import { useNoteView } from "@/hooks/useNoteView.hook"
|
||||||
@@ -43,6 +45,7 @@ const { listenToClick } = useLinks("note-display")
|
|||||||
const { stackedNotes, scrollToFocusedNote } = useRouteQueryStackedNotes()
|
const { stackedNotes, scrollToFocusedNote } = useRouteQueryStackedNotes()
|
||||||
|
|
||||||
const { titles } = useNoteView()
|
const { titles } = useNoteView()
|
||||||
|
const { isLogged } = useGitHubLogin()
|
||||||
useResizeContainer("note-container", stackedNotes)
|
useResizeContainer("note-container", stackedNotes)
|
||||||
|
|
||||||
const renderedContent = computed(() =>
|
const renderedContent = computed(() =>
|
||||||
@@ -102,6 +105,15 @@ onUnmounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
<slot />
|
<slot />
|
||||||
<skeleton-loader v-if="isLoading" />
|
<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
|
<p
|
||||||
v-else-if="withContent && hasContent"
|
v-else-if="withContent && hasContent"
|
||||||
class="note-display"
|
class="note-display"
|
||||||
@@ -189,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 {
|
.note-display {
|
||||||
padding-bottom: 2rem;
|
padding-bottom: 2rem;
|
||||||
}
|
}
|
||||||
@@ -197,12 +218,6 @@ $header-height: 40px;
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
height: 100%;
|
|
||||||
position: sticky;
|
|
||||||
|
|
||||||
&:not(:first-child) {
|
|
||||||
border-top: 1px solid rgba(18, 19, 58, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.title {
|
.title {
|
||||||
text-align: left;
|
text-align: left;
|
||||||
@@ -210,6 +225,8 @@ $header-height: 40px;
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media screen and (min-width: 769px) {
|
@media screen and (min-width: 769px) {
|
||||||
|
background-color: var(--note-canvas-bg);
|
||||||
|
|
||||||
.repo-title-breadcrumb {
|
.repo-title-breadcrumb {
|
||||||
padding: 0.5rem 1rem 0;
|
padding: 0.5rem 1rem 0;
|
||||||
transform-origin: 0 0;
|
transform-origin: 0 0;
|
||||||
@@ -226,6 +243,11 @@ $header-height: 40px;
|
|||||||
.note {
|
.note {
|
||||||
min-width: var(--note-width);
|
min-width: var(--note-width);
|
||||||
max-width: var(--note-width);
|
max-width: var(--note-width);
|
||||||
|
background-color: var(--color-base-100);
|
||||||
|
}
|
||||||
|
|
||||||
|
.readme {
|
||||||
|
box-shadow: var(--note-sheet-shadow);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -244,7 +266,7 @@ $header-height: 40px;
|
|||||||
|
|
||||||
.note {
|
.note {
|
||||||
width: 100vw;
|
width: 100vw;
|
||||||
height: 100dvh;
|
height: 100svh;
|
||||||
overflow-y: visible;
|
overflow-y: visible;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
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>
|
||||||
@@ -13,18 +13,33 @@ import { useFile } from "@/hooks/useFile.hook"
|
|||||||
import { useGitHubContent } from "@/hooks/useGitHubContent.hook"
|
import { useGitHubContent } from "@/hooks/useGitHubContent.hook"
|
||||||
import { useImages } from "@/hooks/useImages.hook"
|
import { useImages } from "@/hooks/useImages.hook"
|
||||||
import { useLinks } from "@/hooks/useLinks.hook"
|
import { useLinks } from "@/hooks/useLinks.hook"
|
||||||
import { runMermaid, useShikiji } from "@/hooks/useMarkdown.hook"
|
import {
|
||||||
|
renderCodeFile,
|
||||||
|
runMermaid,
|
||||||
|
useShikiji
|
||||||
|
} from "@/hooks/useMarkdown.hook"
|
||||||
|
import { useNoteFreshness } from "@/hooks/useNoteFreshness.hook"
|
||||||
import { useNoteOverlay } from "@/hooks/useNoteOverlay.hook"
|
import { useNoteOverlay } from "@/hooks/useNoteOverlay.hook"
|
||||||
import { useRouteQueryStackedNotes } from "@/hooks/useRouteQueryStackedNotes.hook"
|
import { useRouteQueryStackedNotes } from "@/hooks/useRouteQueryStackedNotes.hook"
|
||||||
import { useTitleNotes } from "@/hooks/useTitleNotes.hook"
|
import { useTitleNotes } from "@/hooks/useTitleNotes.hook"
|
||||||
import { useUserRepoStore } from "@/modules/repo/store/userRepo.store"
|
import { useUserRepoStore } from "@/modules/repo/store/userRepo.store"
|
||||||
import { encodeUTF8ToBase64 } from "@/utils/decodeBase64ToUTF8"
|
import { encodeUTF8ToBase64 } from "@/utils/decodeBase64ToUTF8"
|
||||||
|
import { getFileLanguage, isMarkdownPath } from "@/utils/fileLanguage"
|
||||||
import { filenameToNoteTitle } from "@/utils/noteTitle"
|
import { filenameToNoteTitle } from "@/utils/noteTitle"
|
||||||
|
import { errorMessage } from "@/utils/notif"
|
||||||
|
|
||||||
const LinkedNotes = defineAsyncComponent(
|
const LinkedNotes = defineAsyncComponent(
|
||||||
() => import("@/components/LinkedNotes.vue")
|
() => import("@/components/LinkedNotes.vue")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const NoteFreshnessBadge = defineAsyncComponent(
|
||||||
|
() => import("@/components/NoteFreshnessBadge.vue")
|
||||||
|
)
|
||||||
|
|
||||||
|
const NoteConflictModal = defineAsyncComponent(
|
||||||
|
() => import("@/components/NoteConflictModal.vue")
|
||||||
|
)
|
||||||
|
|
||||||
const EditNote = defineAsyncComponent(
|
const EditNote = defineAsyncComponent(
|
||||||
() => import("@/modules/note/components/EditNote.vue")
|
() => import("@/modules/note/components/EditNote.vue")
|
||||||
)
|
)
|
||||||
@@ -53,6 +68,35 @@ const {
|
|||||||
getEditedSha
|
getEditedSha
|
||||||
} = useFile(sha)
|
} = useFile(sha)
|
||||||
const initialRawContent = ref<string | null>(null)
|
const initialRawContent = ref<string | null>(null)
|
||||||
|
const isMarkdown = computed(() =>
|
||||||
|
path.value ? isMarkdownPath(path.value) : true
|
||||||
|
)
|
||||||
|
const displayedContent = ref("")
|
||||||
|
|
||||||
|
watch(
|
||||||
|
[rawContent, isMarkdown, path],
|
||||||
|
async ([raw, isMd, p]) => {
|
||||||
|
if (!raw) {
|
||||||
|
displayedContent.value = ""
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (isMd) {
|
||||||
|
displayedContent.value = content.value
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const lang = p ? getFileLanguage(p) : null
|
||||||
|
const filename = p?.split("/").pop()
|
||||||
|
const result = await renderCodeFile({ rawContent: raw, lang, filename })
|
||||||
|
if (rawContent.value === raw) {
|
||||||
|
displayedContent.value = result
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(content, (c) => {
|
||||||
|
if (isMarkdown.value) displayedContent.value = c
|
||||||
|
})
|
||||||
const className = computed(() => `stacked-note-${props.index}`)
|
const className = computed(() => `stacked-note-${props.index}`)
|
||||||
const { listenToClick } = useLinks(className.value, sha)
|
const { listenToClick } = useLinks(className.value, sha)
|
||||||
const titleClassName = computed(() => `title-${className.value}`)
|
const titleClassName = computed(() => `title-${className.value}`)
|
||||||
@@ -70,10 +114,34 @@ const { updateFile } = useGitHubContent({
|
|||||||
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 () => {
|
onMounted(async () => {
|
||||||
initialRawContent.value = await getRawContent()
|
initialRawContent.value = await getRawContent()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
path,
|
||||||
|
(p) => {
|
||||||
|
if (p) void checkFreshness()
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
)
|
||||||
|
|
||||||
const { mode, toggleMode } = useEditionMode()
|
const { mode, toggleMode } = useEditionMode()
|
||||||
|
|
||||||
watch([content, mode], () => {
|
watch([content, mode], () => {
|
||||||
@@ -92,13 +160,49 @@ watch([content, mode], () => {
|
|||||||
runMermaid(`.note-${sha.value} .mermaid`)
|
runMermaid(`.note-${sha.value} .mermaid`)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (rawContent.value.includes("```")) {
|
if (isMarkdown.value && rawContent.value.includes("```")) {
|
||||||
useShikiji()
|
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) => {
|
watch(mode, async (newMode) => {
|
||||||
|
if (newMode === "edit") {
|
||||||
|
void checkFreshness()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const hasUserFinishedToEdit =
|
const hasUserFinishedToEdit =
|
||||||
newMode === "read" && rawContent.value !== initialRawContent.value
|
newMode === "read" && rawContent.value !== initialRawContent.value
|
||||||
|
|
||||||
@@ -107,28 +211,59 @@ watch(mode, async (newMode) => {
|
|||||||
}
|
}
|
||||||
if (!path.value) {
|
if (!path.value) {
|
||||||
console.warn("no path found for this file")
|
console.warn("no path found for this file")
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const editedSha = (await getEditedSha()) ?? sha.value
|
await checkFreshness()
|
||||||
const newSha = await updateFile({
|
if (freshnessStatus.value === "outdated") {
|
||||||
content: rawContent.value,
|
conflictOpen.value = true
|
||||||
path: path.value,
|
|
||||||
sha: editedSha
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!newSha) {
|
|
||||||
console.warn("no new SHA found for this file")
|
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
await saveCacheNote(encodeUTF8ToBase64(rawContent.value), {
|
await performSave()
|
||||||
editedSha: newSha
|
|
||||||
})
|
|
||||||
initialRawContent.value = rawContent.value
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const onConflictDiscard = async () => {
|
||||||
|
const 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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -140,77 +275,93 @@ watch(mode, async (newMode) => {
|
|||||||
[`note-${sha}`]: true
|
[`note-${sha}`]: true
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<a
|
<div class="title-stacked-note breadcrumbs text-sm" :class="titleClassName">
|
||||||
class="title-stacked-note-link"
|
<div class="action-bar">
|
||||||
@click.prevent="scrollToFocusedNote({ noteId: props.sha })"
|
<note-freshness-badge
|
||||||
>
|
:status="freshnessStatus"
|
||||||
<div
|
:last-checked-at="lastCheckedAt"
|
||||||
class="title-stacked-note breadcrumbs text-sm"
|
@click="onBadgeClick"
|
||||||
:class="titleClassName"
|
class="action"
|
||||||
|
/>
|
||||||
|
<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({ noteId: props.sha })"
|
||||||
>
|
>
|
||||||
<ul>
|
<ul>
|
||||||
<li v-for="(part, i) in breadcrumbs" :key="i">
|
<li v-for="(part, i) in breadcrumbs" :key="i">
|
||||||
{{ part }}
|
{{ part }}
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</a>
|
||||||
</a>
|
</div>
|
||||||
<section class="text-content">
|
<section class="text-content">
|
||||||
<button
|
<div v-if="mode === 'edit' && isMarkdown" class="edit">
|
||||||
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 v-if="mode === 'edit'" class="edit">
|
|
||||||
<edit-note v-model="rawContent" />
|
<edit-note v-model="rawContent" />
|
||||||
</div>
|
</div>
|
||||||
<div v-if="mode === 'read'" class="note-content" v-html="content"></div>
|
<div
|
||||||
|
v-if="mode === 'read'"
|
||||||
|
class="note-content"
|
||||||
|
v-html="displayedContent"
|
||||||
|
></div>
|
||||||
</section>
|
</section>
|
||||||
<linked-notes v-if="hasBacklinks && content" :sha="sha" />
|
<linked-notes v-if="hasBacklinks && content" :sha="sha" />
|
||||||
|
<note-conflict-modal
|
||||||
|
v-model:open="conflictOpen"
|
||||||
|
@discard="onConflictDiscard"
|
||||||
|
@overwrite="onConflictOverwrite"
|
||||||
|
@cancel="onConflictCancel"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -228,7 +379,7 @@ $border-color: rgba(18, 19, 58, 0.2);
|
|||||||
}
|
}
|
||||||
|
|
||||||
section {
|
section {
|
||||||
padding: 0 0.5rem 2rem;
|
padding: 0 0.5rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -242,7 +393,6 @@ $border-color: rgba(18, 19, 58, 0.2);
|
|||||||
background-color: var(--color-base-100);
|
background-color: var(--color-base-100);
|
||||||
color: var(--color-base-content);
|
color: var(--color-base-content);
|
||||||
font-size: 0.8em;
|
font-size: 0.8em;
|
||||||
overflow: hidden;
|
|
||||||
|
|
||||||
ul,
|
ul,
|
||||||
li {
|
li {
|
||||||
@@ -257,14 +407,25 @@ $border-color: rgba(18, 19, 58, 0.2);
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
scrollbar-width: none;
|
scrollbar-width: none;
|
||||||
|
|
||||||
div {
|
> .edit,
|
||||||
|
> .note-content {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.action-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
.action {
|
.action {
|
||||||
float: right;
|
margin: 0;
|
||||||
margin: 0.2rem;
|
|
||||||
|
&:hover {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
img {
|
img {
|
||||||
vertical-align: bottom;
|
vertical-align: bottom;
|
||||||
@@ -274,9 +435,10 @@ $border-color: rgba(18, 19, 58, 0.2);
|
|||||||
@media screen and (max-width: 768px) {
|
@media screen and (max-width: 768px) {
|
||||||
.stacked-note {
|
.stacked-note {
|
||||||
padding: 0 0.75rem 1rem;
|
padding: 0 0.75rem 1rem;
|
||||||
|
height: 100svh;
|
||||||
|
|
||||||
section {
|
section {
|
||||||
padding: 1rem 0 2rem;
|
padding: 1rem 0;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -291,10 +453,12 @@ $border-color: rgba(18, 19, 58, 0.2);
|
|||||||
.stacked-note {
|
.stacked-note {
|
||||||
border-top: 0;
|
border-top: 0;
|
||||||
border-left: 1px solid $border-color;
|
border-left: 1px solid $border-color;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.title-stacked-note {
|
.title-stacked-note {
|
||||||
padding: 0 1rem;
|
padding: 0;
|
||||||
transform-origin: 0 0;
|
transform-origin: 0 0;
|
||||||
transform: rotate(90deg);
|
transform: rotate(90deg);
|
||||||
}
|
}
|
||||||
@@ -302,6 +466,12 @@ $border-color: rgba(18, 19, 58, 0.2);
|
|||||||
a {
|
a {
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.action-bar {
|
||||||
|
.action {
|
||||||
|
transform: rotate(-90deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media print {
|
@media print {
|
||||||
|
|||||||
@@ -130,7 +130,7 @@ $border-color: rgba(18, 19, 58, 0.2);
|
|||||||
}
|
}
|
||||||
|
|
||||||
section {
|
section {
|
||||||
padding: 0 0.5rem 2rem;
|
padding: 0 0.5rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -144,7 +144,6 @@ $border-color: rgba(18, 19, 58, 0.2);
|
|||||||
background-color: var(--color-base-100);
|
background-color: var(--color-base-100);
|
||||||
color: var(--color-base-content);
|
color: var(--color-base-content);
|
||||||
font-size: 0.8em;
|
font-size: 0.8em;
|
||||||
overflow: hidden;
|
|
||||||
|
|
||||||
ul,
|
ul,
|
||||||
li {
|
li {
|
||||||
@@ -178,7 +177,7 @@ $border-color: rgba(18, 19, 58, 0.2);
|
|||||||
padding: 0 0.75rem 1rem;
|
padding: 0 0.75rem 1rem;
|
||||||
|
|
||||||
section {
|
section {
|
||||||
padding: 1rem 0 2rem;
|
padding: 1rem 0;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -193,6 +192,8 @@ $border-color: rgba(18, 19, 58, 0.2);
|
|||||||
.stacked-note {
|
.stacked-note {
|
||||||
border-top: 0;
|
border-top: 0;
|
||||||
border-left: 1px solid $border-color;
|
border-left: 1px solid $border-color;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.title-stacked-note {
|
.title-stacked-note {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import fontColorContrast from "font-color-contrast"
|
import fontColorContrast from "font-color-contrast"
|
||||||
import { getHex } from "pastel-color"
|
import { getHex } from "pastel-color"
|
||||||
import { computed, ref } from "vue"
|
import { computed, onMounted, ref } from "vue"
|
||||||
import { useRouter } from "vue-router"
|
import { useRouter } from "vue-router"
|
||||||
|
|
||||||
import SignInAtproto from "@/components/SignInAtproto.vue"
|
import SignInAtproto from "@/components/SignInAtproto.vue"
|
||||||
@@ -10,9 +10,12 @@ import ThemeSwap from "@/components/ThemeSwap.vue"
|
|||||||
import { useATProtoLogin } from "@/hooks/useATProtoLogin.hook"
|
import { useATProtoLogin } from "@/hooks/useATProtoLogin.hook"
|
||||||
import { useForm } from "@/hooks/useForm.hook"
|
import { useForm } from "@/hooks/useForm.hook"
|
||||||
import { useGitHubLogin } from "@/hooks/useGitHubLogin.hook"
|
import { useGitHubLogin } from "@/hooks/useGitHubLogin.hook"
|
||||||
|
import { usePublicNoteList } from "@/hooks/usePublicNoteList.hook"
|
||||||
|
import { toShortDid } from "@/modules/atproto/shortDid"
|
||||||
import { useNeedReviewCards } from "@/modules/card/hooks/useNeedReviewCards"
|
import { useNeedReviewCards } from "@/modules/card/hooks/useNeedReviewCards"
|
||||||
import { useLastVisitedRepos } from "@/modules/history/hooks/useLastVisitedRepos.hook"
|
import { useLastVisitedRepos } from "@/modules/history/hooks/useLastVisitedRepos.hook"
|
||||||
import { useFavoriteRepos } from "@/modules/repo/hooks/useFavoriteRepos.hook"
|
import { useFavoriteRepos } from "@/modules/repo/hooks/useFavoriteRepos.hook"
|
||||||
|
import { slugify } from "@/utils/slugify"
|
||||||
|
|
||||||
const { username, accessToken } = useGitHubLogin()
|
const { username, accessToken } = useGitHubLogin()
|
||||||
const { isLoggedIn: isATProtoLoggedIn, handle, avatarUrl } = useATProtoLogin()
|
const { isLoggedIn: isATProtoLoggedIn, handle, avatarUrl } = useATProtoLogin()
|
||||||
@@ -65,10 +68,57 @@ const flashOffset = () => {
|
|||||||
}, 900)
|
}, 900)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const firstHighlighted = ref(false)
|
||||||
|
const flashFirst = () => {
|
||||||
|
firstHighlighted.value = true
|
||||||
|
setTimeout(() => {
|
||||||
|
firstHighlighted.value = false
|
||||||
|
}, 900)
|
||||||
|
}
|
||||||
|
|
||||||
|
const zettelRevealed = ref(false)
|
||||||
|
const toggleZettel = () => {
|
||||||
|
zettelRevealed.value = !zettelRevealed.value
|
||||||
|
}
|
||||||
|
|
||||||
const reviewRepo = computed(() => savedFavoriteRepos.value[0] ?? null)
|
const reviewRepo = computed(() => savedFavoriteRepos.value[0] ?? null)
|
||||||
const showReviewCard = computed(
|
const showReviewCard = computed(
|
||||||
() => cardsToReview.value.length > 0 && reviewRepo.value !== null
|
() => cardsToReview.value.length > 0 && reviewRepo.value !== null
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const {
|
||||||
|
notes: publicNotes,
|
||||||
|
isLoading: publicNotesLoading,
|
||||||
|
onLoadMore: loadPublicNotes,
|
||||||
|
getAuthor: getPublicAuthor
|
||||||
|
} = usePublicNoteList()
|
||||||
|
const recentPublicNotes = computed(() => publicNotes.value.slice(0, 5))
|
||||||
|
const sidebarPublicNotes = computed(() => publicNotes.value.slice(0, 3))
|
||||||
|
const hasPublicNotes = computed(() => recentPublicNotes.value.length > 0)
|
||||||
|
const publicNotesLoadFailed = computed(
|
||||||
|
() => !publicNotesLoading.value && publicNotes.value.length === 0
|
||||||
|
)
|
||||||
|
const formatPublicDate = (iso: string) =>
|
||||||
|
new Date(iso).toLocaleDateString(undefined, {
|
||||||
|
month: "short",
|
||||||
|
day: "numeric"
|
||||||
|
})
|
||||||
|
const publicNoteHandle = (did: string) => {
|
||||||
|
const handle = getPublicAuthor(did)
|
||||||
|
return handle ? `@${handle}` : toShortDid(did)
|
||||||
|
}
|
||||||
|
const publicNoteRoute = (note: { did: string; rkey: string; title: string }) => ({
|
||||||
|
name: "PublicNoteView",
|
||||||
|
params: {
|
||||||
|
shortDid: toShortDid(note.did),
|
||||||
|
rkey: note.rkey,
|
||||||
|
slug: slugify(note.title)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadPublicNotes()
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -297,12 +347,43 @@ const showReviewCard = computed(
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<div class="section-label mono">§ from the network</div>
|
<div class="section-label mono">§ drifting in</div>
|
||||||
|
<ul
|
||||||
|
v-if="hasPublicNotes"
|
||||||
|
class="network-side-list"
|
||||||
|
>
|
||||||
|
<li
|
||||||
|
v-for="note in sidebarPublicNotes"
|
||||||
|
:key="`${note.did}-${note.rkey}`"
|
||||||
|
>
|
||||||
|
<router-link
|
||||||
|
:to="publicNoteRoute(note)"
|
||||||
|
class="network-side-link"
|
||||||
|
>
|
||||||
|
<span class="network-side-title">{{ note.title }}</span>
|
||||||
|
<span class="mono network-side-meta">
|
||||||
|
{{ publicNoteHandle(note.did) }}
|
||||||
|
<span class="li-dot">·</span>
|
||||||
|
{{ formatPublicDate(note.publishedAt) }}
|
||||||
|
</span>
|
||||||
|
</router-link>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<ul
|
||||||
|
v-else-if="publicNotesLoading"
|
||||||
|
class="network-side-list"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<li v-for="i in 3" :key="i" class="network-side-skel">
|
||||||
|
<span class="skel skel-title" />
|
||||||
|
<span class="skel skel-date" />
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
<router-link
|
<router-link
|
||||||
:to="{ name: 'PublicNoteListView' }"
|
:to="{ name: 'PublicNoteListView' }"
|
||||||
class="hw-btn pub-notes-btn"
|
class="network-side-all"
|
||||||
>
|
>
|
||||||
Browse public notes →
|
See all public notes →
|
||||||
</router-link>
|
</router-link>
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
@@ -363,23 +444,62 @@ const showReviewCard = computed(
|
|||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
<!-- CTA 02: Public notes -->
|
<!-- CTA 02: Public notes (live preview) -->
|
||||||
<div class="hero-ed-path">
|
<div class="hero-ed-path hero-ed-path-network">
|
||||||
<div class="hep-head">
|
<div class="hep-head">
|
||||||
<span class="hep-n mono">02</span>
|
<span class="hep-n mono">02</span>
|
||||||
<div>
|
<div>
|
||||||
<div class="hep-t">From the open network</div>
|
<div class="hep-t">From the open network</div>
|
||||||
<div class="hep-d">
|
<div class="hep-d">
|
||||||
Your `.pub.md` files become public. Read public notes
|
Live notes published via ATProto. No account needed —
|
||||||
published via ATProto — no account needed.
|
tap one and read.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="hasPublicNotes"
|
||||||
|
class="network-strip"
|
||||||
|
role="list"
|
||||||
|
>
|
||||||
|
<router-link
|
||||||
|
v-for="note in recentPublicNotes"
|
||||||
|
:key="`${note.did}-${note.rkey}`"
|
||||||
|
:to="publicNoteRoute(note)"
|
||||||
|
class="network-card"
|
||||||
|
role="listitem"
|
||||||
|
>
|
||||||
|
<span class="mono network-card-handle">{{
|
||||||
|
publicNoteHandle(note.did)
|
||||||
|
}}</span>
|
||||||
|
<span class="network-card-title">{{ note.title }}</span>
|
||||||
|
<span class="mono network-card-date">{{
|
||||||
|
formatPublicDate(note.publishedAt)
|
||||||
|
}}</span>
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-else-if="publicNotesLoading"
|
||||||
|
class="network-strip"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-for="i in 3"
|
||||||
|
:key="i"
|
||||||
|
class="network-card network-card--skel"
|
||||||
|
>
|
||||||
|
<span class="skel skel-handle" />
|
||||||
|
<span class="skel skel-title" />
|
||||||
|
<span class="skel skel-date" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="publicNotesLoadFailed" class="network-empty">
|
||||||
|
The pool is quiet right now.
|
||||||
|
</div>
|
||||||
<router-link
|
<router-link
|
||||||
:to="{ name: 'PublicNoteListView' }"
|
:to="{ name: 'PublicNoteListView' }"
|
||||||
class="hw-btn hw-btn-pink hep-btn"
|
class="network-all"
|
||||||
>
|
>
|
||||||
Browse public notes →
|
Browse all public notes →
|
||||||
</router-link>
|
</router-link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -425,7 +545,23 @@ const showReviewCard = computed(
|
|||||||
</p>
|
</p>
|
||||||
<div class="feature-row">
|
<div class="feature-row">
|
||||||
<div class="feat">
|
<div class="feat">
|
||||||
<span class="feat-icon">≣</span>
|
<span class="feat-icon" aria-hidden="true">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M12 4l-8 4l8 4l8 -4l-8 -4" />
|
||||||
|
<path d="M4 12l8 4l8 -4" />
|
||||||
|
<path d="M4 16l8 4l8 -4" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
<h4>Stacked notes</h4>
|
<h4>Stacked notes</h4>
|
||||||
<p>
|
<p>
|
||||||
Click a link and the next note slides in beside the current one.
|
Click a link and the next note slides in beside the current one.
|
||||||
@@ -434,7 +570,22 @@ const showReviewCard = computed(
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="feat">
|
<div class="feat">
|
||||||
<span class="feat-icon">↹</span>
|
<span class="feat-icon" aria-hidden="true">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M9 14l-4 -4l4 -4" />
|
||||||
|
<path d="M5 10h11a4 4 0 1 1 0 8h-1" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
<h4>Automatic backlinks</h4>
|
<h4>Automatic backlinks</h4>
|
||||||
<p>
|
<p>
|
||||||
Every mention becomes a two-way link. See every note that points
|
Every mention becomes a two-way link. See every note that points
|
||||||
@@ -442,7 +593,29 @@ const showReviewCard = computed(
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="feat">
|
<div class="feat">
|
||||||
<span class="feat-icon">◈</span>
|
<span class="feat-icon" aria-hidden="true">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M15 12a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
|
||||||
|
<path d="M11 8a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
|
||||||
|
<path d="M11 16a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
|
||||||
|
<path d="M12 15v-6" />
|
||||||
|
<path d="M15 11l-2 -2" />
|
||||||
|
<path d="M11 7l-1.9 -1.9" />
|
||||||
|
<path
|
||||||
|
d="M13.446 2.6l7.955 7.954a2.045 2.045 0 0 1 0 2.892l-7.955 7.955a2.045 2.045 0 0 1 -2.892 0l-7.955 -7.955a2.045 2.045 0 0 1 0 -2.892l7.955 -7.955a2.045 2.045 0 0 1 2.892 0"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
<h4>Your files, your Git</h4>
|
<h4>Your files, your Git</h4>
|
||||||
<p>
|
<p>
|
||||||
Remanso reads GitHub directly. Edit in your favourite editor.
|
Remanso reads GitHub directly. Edit in your favourite editor.
|
||||||
@@ -464,7 +637,10 @@ const showReviewCard = computed(
|
|||||||
</span>
|
</span>
|
||||||
</h2>
|
</h2>
|
||||||
<div class="demo-notes">
|
<div class="demo-notes">
|
||||||
<article class="note-preview">
|
<article
|
||||||
|
class="note-preview"
|
||||||
|
:class="{ 'note-preview--flash': firstHighlighted }"
|
||||||
|
>
|
||||||
<div class="note-header">
|
<div class="note-header">
|
||||||
<span class="mono note-breadcrumb"
|
<span class="mono note-breadcrumb"
|
||||||
>remanso-space / getting-started</span
|
>remanso-space / getting-started</span
|
||||||
@@ -481,9 +657,10 @@ const showReviewCard = computed(
|
|||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
Luhmann called this the
|
Luhmann called this the
|
||||||
<a class="note-link" href="#" @click.prevent>zettelkasten</a>:
|
<a class="note-link" href="#" @click.prevent="toggleZettel"
|
||||||
a slip-box of thoughts you converse with, rather than a hoard
|
>zettelkasten</a
|
||||||
of pages you re-read.
|
>: a slip-box of thoughts you converse with, rather than a
|
||||||
|
hoard of pages you re-read.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="note-backlinks">
|
<div class="note-backlinks">
|
||||||
@@ -498,33 +675,71 @@ const showReviewCard = computed(
|
|||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
<article
|
<div class="note-stack">
|
||||||
class="note-preview note-preview-offset"
|
<article
|
||||||
:class="{ 'note-preview--flash': offsetHighlighted }"
|
class="note-preview note-preview-offset"
|
||||||
>
|
:class="{ 'note-preview--flash': offsetHighlighted }"
|
||||||
<div class="note-header">
|
>
|
||||||
<span class="mono note-breadcrumb"
|
<div class="note-header">
|
||||||
>remanso-space / getting-started</span
|
<span class="mono note-breadcrumb"
|
||||||
>
|
>remanso-space / getting-started</span
|
||||||
</div>
|
>
|
||||||
<h3 class="note-title">Durable enough</h3>
|
</div>
|
||||||
<div class="note-body">
|
<h3 class="note-title">Durable enough</h3>
|
||||||
<p>
|
<div class="note-body">
|
||||||
A durable note survives its own context. You should be able to
|
<p>
|
||||||
pick it up six months from now and still know what it means.
|
A durable note survives its own context. You should be able
|
||||||
</p>
|
to pick it up six months from now and still know what it
|
||||||
<p>
|
means.
|
||||||
Rule of thumb: write the title as the claim, and the body as
|
</p>
|
||||||
the argument.
|
<p>
|
||||||
</p>
|
Rule of thumb: write the title as the claim, and the body as
|
||||||
</div>
|
the argument.
|
||||||
<div class="note-backlinks">
|
</p>
|
||||||
<div class="backlinks-h mono">↖ linked from</div>
|
</div>
|
||||||
<ul>
|
<div class="note-backlinks">
|
||||||
<li><a @click.prevent href="#">on keeping notes</a></li>
|
<div class="backlinks-h mono">↖ linked from</div>
|
||||||
</ul>
|
<ul>
|
||||||
</div>
|
<li>
|
||||||
</article>
|
<a @click.prevent="flashFirst" href="#"
|
||||||
|
>on keeping notes</a
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
<article
|
||||||
|
class="note-preview note-preview-zettel"
|
||||||
|
:class="{ 'is-revealed': zettelRevealed }"
|
||||||
|
:aria-hidden="!zettelRevealed"
|
||||||
|
>
|
||||||
|
<div class="note-header">
|
||||||
|
<span class="mono note-breadcrumb"
|
||||||
|
>remanso-space / getting-started</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<h3 class="note-title">Zettelkasten</h3>
|
||||||
|
<div class="note-body">
|
||||||
|
<p>
|
||||||
|
A <em>slip-box</em> of atomic notes wired together by links
|
||||||
|
instead of filed away in folders. Niklas Luhmann kept ninety
|
||||||
|
thousand of them in a wooden cabinet and wrote with them,
|
||||||
|
not just about them.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Each <em>Zettel</em> earns its keep by being linked to. Open
|
||||||
|
one, follow a thread, end up somewhere you didn't plan to
|
||||||
|
go.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="note-backlinks">
|
||||||
|
<div class="backlinks-h mono">↖ linked from</div>
|
||||||
|
<ul>
|
||||||
|
<li><a @click.prevent href="#">on keeping notes</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -547,8 +762,7 @@ const showReviewCard = computed(
|
|||||||
<h4>Link, don't nest.</h4>
|
<h4>Link, don't nest.</h4>
|
||||||
<p>
|
<p>
|
||||||
Folders calcify. Links compound. Every
|
Folders calcify. Links compound. Every
|
||||||
<code>[like this](new-idea.md)</code> becomes a door to a new
|
<code>[link](note.md)</code> becomes a door to a new idea.
|
||||||
idea.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
@@ -557,8 +771,8 @@ const showReviewCard = computed(
|
|||||||
<div>
|
<div>
|
||||||
<h4>Let the web emerge.</h4>
|
<h4>Let the web emerge.</h4>
|
||||||
<p>
|
<p>
|
||||||
Don't plan the structure. Write, link, re-read. Structure is a
|
Don't plan the structure. Write, link, re-read. Structure
|
||||||
consequence of attention.
|
emerges from what you return to.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
@@ -607,7 +821,13 @@ const showReviewCard = computed(
|
|||||||
<div class="footer-h">Learn</div>
|
<div class="footer-h">Learn</div>
|
||||||
<a href="#about" class="footer-link">What is Remanso?</a>
|
<a href="#about" class="footer-link">What is Remanso?</a>
|
||||||
<a href="#zk" class="footer-link">Zettelkasten</a>
|
<a href="#zk" class="footer-link">Zettelkasten</a>
|
||||||
<a href="#" class="footer-link">ATProto & Bluesky</a>
|
<a
|
||||||
|
href="https://atproto.com/"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
class="footer-link"
|
||||||
|
>atproto</a
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1043,8 +1263,8 @@ main {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.hero-ed-paths {
|
.hero-ed-paths {
|
||||||
display: grid;
|
display: flex;
|
||||||
grid-template-columns: 1fr 1fr;
|
flex-direction: column;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
margin-top: 0.5rem;
|
margin-top: 0.5rem;
|
||||||
}
|
}
|
||||||
@@ -1069,6 +1289,163 @@ main {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.hero-ed-path-network {
|
||||||
|
gap: 1.25rem;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: none;
|
||||||
|
border-color: var(--hw-rule);
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.network-strip {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.85rem;
|
||||||
|
overflow-x: auto;
|
||||||
|
padding: 0.25rem 0.25rem 0.75rem;
|
||||||
|
margin: 0 -0.25rem;
|
||||||
|
scroll-snap-type: x proximity;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
|
||||||
|
&::-webkit-scrollbar {
|
||||||
|
height: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--hw-pink-wash-2);
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.network-card {
|
||||||
|
flex: 0 0 220px;
|
||||||
|
scroll-snap-align: start;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.45rem;
|
||||||
|
padding: 0.9rem 1rem 0.85rem;
|
||||||
|
background: var(--hw-paper);
|
||||||
|
border: 1px solid var(--hw-rule);
|
||||||
|
border-radius: 4px;
|
||||||
|
text-decoration: none;
|
||||||
|
color: var(--hw-ink);
|
||||||
|
position: relative;
|
||||||
|
min-height: 130px;
|
||||||
|
box-shadow: 0 1px 0 rgba(0, 0, 0, 0.02);
|
||||||
|
transition:
|
||||||
|
transform 0.15s ease,
|
||||||
|
border-color 0.15s ease,
|
||||||
|
box-shadow 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
border-color: var(--hw-pink-wash-2);
|
||||||
|
box-shadow: 0 14px 24px -18px rgba(201, 74, 125, 0.35);
|
||||||
|
|
||||||
|
.network-card-title {
|
||||||
|
color: var(--hw-pink-deep);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.network-card-handle {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: var(--hw-ink-faint);
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.network-card-title {
|
||||||
|
font-family: var(--hw-serif);
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.3;
|
||||||
|
color: var(--hw-ink);
|
||||||
|
flex: 1;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 3;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.network-card-date {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: var(--hw-ink-faint);
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
text-transform: lowercase;
|
||||||
|
margin-top: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.network-card--skel {
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skel {
|
||||||
|
display: block;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
var(--hw-pink-wash) 0%,
|
||||||
|
var(--hw-pink-wash-2) 50%,
|
||||||
|
var(--hw-pink-wash) 100%
|
||||||
|
);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
animation: skel-shimmer 1.4s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skel-handle {
|
||||||
|
height: 0.6rem;
|
||||||
|
width: 60%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skel-title {
|
||||||
|
height: 0.95rem;
|
||||||
|
width: 90%;
|
||||||
|
margin-top: 0.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skel-date {
|
||||||
|
height: 0.55rem;
|
||||||
|
width: 30%;
|
||||||
|
margin-top: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes skel-shimmer {
|
||||||
|
0% {
|
||||||
|
background-position: 200% 0;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
background-position: -200% 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.network-empty {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--hw-ink-faint);
|
||||||
|
font-style: italic;
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.network-all {
|
||||||
|
align-self: flex-start;
|
||||||
|
font-family: var(--hw-mono);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: var(--hw-pink-deep);
|
||||||
|
text-decoration: none;
|
||||||
|
border-bottom: 1px dashed var(--hw-pink-wash-2);
|
||||||
|
padding-bottom: 1px;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--hw-pink-deep);
|
||||||
|
border-bottom-color: var(--hw-pink);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.hep-head {
|
.hep-head {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.85rem;
|
gap: 0.85rem;
|
||||||
@@ -1119,6 +1496,10 @@ main {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.hero-ed-left {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.hero-ed-right {
|
.hero-ed-right {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -1223,16 +1604,19 @@ main {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.feat-icon {
|
.feat-icon {
|
||||||
width: 28px;
|
width: 32px;
|
||||||
height: 28px;
|
height: 32px;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
font-family: var(--hw-mono);
|
|
||||||
font-size: 0.75rem;
|
|
||||||
color: var(--hw-pink-deep);
|
color: var(--hw-pink-deep);
|
||||||
border: 1px solid var(--hw-pink);
|
border: 1px solid var(--hw-pink);
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Demo notes ─────────────────────────────────────────────── */
|
/* ── Demo notes ─────────────────────────────────────────────── */
|
||||||
@@ -1300,6 +1684,39 @@ main {
|
|||||||
animation: note-flash 0.9s ease forwards;
|
animation: note-flash 0.9s ease forwards;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.note-stack {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-preview-zettel {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
margin-top: 2.5rem;
|
||||||
|
transform: rotate(-0.6deg) translate(0, 0) scale(0.97);
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: -1;
|
||||||
|
transition:
|
||||||
|
transform 0.4s cubic-bezier(0.2, 0.7, 0.2, 1),
|
||||||
|
opacity 0.3s ease;
|
||||||
|
|
||||||
|
&.is-revealed {
|
||||||
|
transform: rotate(-2deg) translate(28px, 28px) scale(0.97);
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.note-preview-zettel {
|
||||||
|
&.is-revealed {
|
||||||
|
transform: rotate(-1.5deg) translate(12px, 22px) scale(0.96);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@keyframes note-flash {
|
@keyframes note-flash {
|
||||||
0% {
|
0% {
|
||||||
box-shadow:
|
box-shadow:
|
||||||
@@ -1706,10 +2123,79 @@ img {
|
|||||||
padding: 0.35rem 0.8rem;
|
padding: 0.35rem 0.8rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pub-notes-btn {
|
.network-side-list {
|
||||||
width: 100%;
|
list-style: none;
|
||||||
justify-content: center;
|
padding: 0;
|
||||||
|
margin: 0 0 0.75rem;
|
||||||
|
|
||||||
|
li {
|
||||||
|
border-bottom: 1px dashed var(--hw-rule);
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.network-side-link {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.2rem;
|
||||||
|
padding: 0.65rem 0;
|
||||||
|
text-decoration: none;
|
||||||
|
color: var(--hw-ink);
|
||||||
|
transition: color 0.15s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
.network-side-title {
|
||||||
|
color: var(--hw-pink-deep);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.network-side-title {
|
||||||
|
font-family: var(--hw-serif);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.3;
|
||||||
|
color: var(--hw-ink);
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.network-side-meta {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: var(--hw-ink-faint);
|
||||||
|
display: flex;
|
||||||
|
gap: 0.4rem;
|
||||||
|
align-items: center;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.network-side-skel {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.3rem;
|
||||||
|
padding: 0.65rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.network-side-all {
|
||||||
|
display: inline-block;
|
||||||
margin-top: 0.25rem;
|
margin-top: 0.25rem;
|
||||||
|
font-family: var(--hw-mono);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: var(--hw-pink-deep);
|
||||||
|
text-decoration: none;
|
||||||
|
border-bottom: 1px dashed var(--hw-pink-wash-2);
|
||||||
|
padding-bottom: 1px;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-bottom-color: var(--hw-pink);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Footer ─────────────────────────────────────────────────── */
|
/* ── Footer ─────────────────────────────────────────────────── */
|
||||||
@@ -1868,10 +2354,6 @@ img {
|
|||||||
height: 160px;
|
height: 160px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero-ed-paths {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
.feature-row {
|
.feature-row {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
gap: 1.5rem;
|
gap: 1.5rem;
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ export const useCheckboxCommit = ({
|
|||||||
|
|
||||||
isCommitting.value = true
|
isCommitting.value = true
|
||||||
|
|
||||||
const newSha = await updateFile({
|
const { sha: newSha } = await updateFile({
|
||||||
content: pendingContent.value,
|
content: pendingContent.value,
|
||||||
path: pathValue,
|
path: pathValue,
|
||||||
sha: currentSha.value
|
sha: currentSha.value
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { getOctokit } from "@/modules/repo/services/octo"
|
|||||||
import { encodeUTF8ToBase64 } from "@/utils/decodeBase64ToUTF8"
|
import { encodeUTF8ToBase64 } from "@/utils/decodeBase64ToUTF8"
|
||||||
import { confirmMessage, errorMessage } from "@/utils/notif"
|
import { confirmMessage, errorMessage } from "@/utils/notif"
|
||||||
|
|
||||||
|
const isConflictStatus = (status: number) => status === 409 || status === 422
|
||||||
|
|
||||||
export const useGitHubContent = ({
|
export const useGitHubContent = ({
|
||||||
user,
|
user,
|
||||||
repo
|
repo
|
||||||
@@ -9,6 +11,26 @@ export const useGitHubContent = ({
|
|||||||
user: string
|
user: string
|
||||||
repo: 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 ({
|
const putFile = async ({
|
||||||
content,
|
content,
|
||||||
path,
|
path,
|
||||||
@@ -17,7 +39,7 @@ export const useGitHubContent = ({
|
|||||||
content: string
|
content: string
|
||||||
path: string
|
path: string
|
||||||
sha?: string
|
sha?: string
|
||||||
}) => {
|
}): Promise<{ sha: string | null; conflict: boolean }> => {
|
||||||
try {
|
try {
|
||||||
const octokit = await getOctokit()
|
const octokit = await getOctokit()
|
||||||
|
|
||||||
@@ -35,18 +57,27 @@ export const useGitHubContent = ({
|
|||||||
|
|
||||||
confirmMessage("✅ Note saved")
|
confirmMessage("✅ Note saved")
|
||||||
|
|
||||||
return response?.data.content?.sha ?? null
|
return { sha: response?.data.content?.sha ?? null, conflict: false }
|
||||||
} catch (error) {
|
} 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")
|
errorMessage("❌ Note could not be saved")
|
||||||
console.warn(error)
|
console.warn(error)
|
||||||
|
return { sha: null, conflict: false }
|
||||||
}
|
}
|
||||||
|
|
||||||
return null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
updateFile: async (props: { content: string; path: string; sha: string }) =>
|
fetchLatestSha,
|
||||||
putFile(props),
|
updateFile: async (props: {
|
||||||
|
content: string
|
||||||
|
path: string
|
||||||
|
sha: string
|
||||||
|
}) => putFile(props),
|
||||||
createFile: async (props: { content: string; path: string }) =>
|
createFile: async (props: { content: string; path: string }) =>
|
||||||
putFile(props)
|
putFile(props)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { MarkdownItTabData, MarkdownItTabInfo } from "@mdit/plugin-tab"
|
||||||
import { tab } from "@mdit/plugin-tab"
|
import { tab } from "@mdit/plugin-tab"
|
||||||
import markdownItKatex from "@vscode/markdown-it-katex"
|
import markdownItKatex from "@vscode/markdown-it-katex"
|
||||||
import GithubSlugger from "github-slugger"
|
import GithubSlugger from "github-slugger"
|
||||||
@@ -11,8 +12,10 @@ import MarkdownItGitHubAlerts from "markdown-it-github-alerts"
|
|||||||
import markdownItIframe from "markdown-it-iframe"
|
import markdownItIframe from "markdown-it-iframe"
|
||||||
import Shikiji from "markdown-it-shikiji"
|
import Shikiji from "markdown-it-shikiji"
|
||||||
import mermaid from "mermaid"
|
import mermaid from "mermaid"
|
||||||
|
import type { LanguageRegistration } from "shikiji-core"
|
||||||
import { Ref, toValue } from "vue"
|
import { Ref, toValue } from "vue"
|
||||||
|
|
||||||
|
import alloyGrammar from "@/utils/alloy.tmLanguage.json"
|
||||||
import { decodeBase64ToUTF8 } from "@/utils/decodeBase64ToUTF8"
|
import { decodeBase64ToUTF8 } from "@/utils/decodeBase64ToUTF8"
|
||||||
import { html5Media } from "@/utils/markdown/markdown-html5-media"
|
import { html5Media } from "@/utils/markdown/markdown-html5-media"
|
||||||
import { markdownItTablerIcons } from "@/utils/markdown/markdown-it-tabler-icons"
|
import { markdownItTablerIcons } from "@/utils/markdown/markdown-it-tabler-icons"
|
||||||
@@ -50,6 +53,10 @@ const markdownItMermaidExtractor = (md: MarkdownIt) => {
|
|||||||
|
|
||||||
const slugger = new GithubSlugger()
|
const slugger = new GithubSlugger()
|
||||||
|
|
||||||
|
let tabGroupCounter = 0
|
||||||
|
let currentTabGroup = 0
|
||||||
|
let currentTabActiveSet = false
|
||||||
|
|
||||||
const md = new MarkdownIt({
|
const md = new MarkdownIt({
|
||||||
typographer: true,
|
typographer: true,
|
||||||
quotes: ["«\xA0", "\xA0»", "‹\xA0", "\xA0›"]
|
quotes: ["«\xA0", "\xA0»", "‹\xA0", "\xA0›"]
|
||||||
@@ -70,22 +77,31 @@ const md = new MarkdownIt({
|
|||||||
.use(MarkdownItGitHubAlerts)
|
.use(MarkdownItGitHubAlerts)
|
||||||
.use(markdownItTablerIcons)
|
.use(markdownItTablerIcons)
|
||||||
.use(tab, {
|
.use(tab, {
|
||||||
name: "tabs"
|
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, {
|
.use(markdownItAnchor, {
|
||||||
slugify: (s: string) => slugger.slug(s)
|
slugify: (s: string) => slugger.slug(s)
|
||||||
})
|
})
|
||||||
|
|
||||||
let shikijiInitialized = false
|
let shikijiPromise: Promise<void> | null = null
|
||||||
|
|
||||||
export const useShikiji = async () => {
|
export const useShikiji = (): Promise<void> => {
|
||||||
if (shikijiInitialized) {
|
if (!shikijiPromise) {
|
||||||
return
|
shikijiPromise = Shikiji({
|
||||||
}
|
|
||||||
|
|
||||||
shikijiInitialized = true
|
|
||||||
md.use(
|
|
||||||
await Shikiji({
|
|
||||||
themes: {
|
themes: {
|
||||||
light: "vitesse-light",
|
light: "vitesse-light",
|
||||||
dark: "vitesse-black"
|
dark: "vitesse-black"
|
||||||
@@ -98,10 +114,18 @@ export const useShikiji = async () => {
|
|||||||
"mermaid",
|
"mermaid",
|
||||||
"html",
|
"html",
|
||||||
"css",
|
"css",
|
||||||
"json"
|
"json",
|
||||||
|
{
|
||||||
|
...alloyGrammar,
|
||||||
|
name: "alloy",
|
||||||
|
aliases: ["als"]
|
||||||
|
} as unknown as LanguageRegistration
|
||||||
]
|
]
|
||||||
|
}).then((plugin) => {
|
||||||
|
md.use(plugin)
|
||||||
})
|
})
|
||||||
)
|
}
|
||||||
|
return shikijiPromise
|
||||||
}
|
}
|
||||||
|
|
||||||
let mermaidInitialized = false
|
let mermaidInitialized = false
|
||||||
@@ -139,6 +163,23 @@ const renderMarkdown = (content: string, env?: Record<string, unknown>) => {
|
|||||||
return env ? md.render(content, env) : md.render(content)
|
return env ? md.render(content, env) : md.render(content)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const renderCodeFile = async ({
|
||||||
|
rawContent,
|
||||||
|
lang,
|
||||||
|
filename
|
||||||
|
}: {
|
||||||
|
rawContent: string
|
||||||
|
lang: string | null
|
||||||
|
filename?: string
|
||||||
|
}): Promise<string> => {
|
||||||
|
await useShikiji()
|
||||||
|
const heading = filename ? `# ${filename}\n\n` : ""
|
||||||
|
if (lang !== null) {
|
||||||
|
return renderMarkdown(`${heading}\`\`\`\`${lang}\n${rawContent}\n\`\`\`\``)
|
||||||
|
}
|
||||||
|
return `${renderMarkdown(heading)}<pre><code>${md.utils.escapeHtml(rawContent)}</code></pre>`
|
||||||
|
}
|
||||||
|
|
||||||
export const markdownBuilder = (defaultPrefix?: Ref<string> | string) => {
|
export const markdownBuilder = (defaultPrefix?: Ref<string> | string) => {
|
||||||
const getRawContent = (content: string) => decodeBase64ToUTF8(content)
|
const getRawContent = (content: string) => decodeBase64ToUTF8(content)
|
||||||
const renderFromUTF8 = (content: string, prefix?: string) => {
|
const renderFromUTF8 = (content: string, prefix?: string) => {
|
||||||
|
|||||||
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -40,10 +40,22 @@ export const useOverlay = (listen = true) => {
|
|||||||
}, 80)
|
}, 80)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const scrollToElement = (element: HTMLElement, anchorTop?: number) => {
|
||||||
|
const mainApp = document.getElementById("main-app")
|
||||||
|
if (mainApp && anchorTop !== undefined) {
|
||||||
|
mainApp.scrollTop = anchorTop
|
||||||
|
}
|
||||||
|
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
element.scrollIntoView({ behavior: "smooth", block: "start" })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
x,
|
x,
|
||||||
y,
|
y,
|
||||||
isMobile,
|
isMobile,
|
||||||
scrollToNote
|
scrollToNote,
|
||||||
|
scrollToElement
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { computed, ref } from "vue"
|
import { computed, ref, watch } from "vue"
|
||||||
|
|
||||||
import { useGitHubLogin } from "@/hooks/useGitHubLogin.hook"
|
import { useGitHubLogin } from "@/hooks/useGitHubLogin.hook"
|
||||||
import { RepoBase } from "@/modules/repo/interfaces/RepoBase"
|
import { RepoBase } from "@/modules/repo/interfaces/RepoBase"
|
||||||
@@ -9,18 +9,32 @@ const STALE_TIME_MS = 20 * 60 * 1000
|
|||||||
|
|
||||||
const repos = ref<RepoBase[]>([])
|
const repos = ref<RepoBase[]>([])
|
||||||
const isReady = ref(false)
|
const isReady = ref(false)
|
||||||
|
const isLoading = ref(false)
|
||||||
|
const hasCredentialError = ref(false)
|
||||||
const currentPage = ref(0)
|
const currentPage = ref(0)
|
||||||
const totalCount = ref(0)
|
const totalCount = ref(0)
|
||||||
let lastFetchedAt = 0
|
let lastFetchedAt = 0
|
||||||
|
|
||||||
export const useRepos = () => {
|
const { username, accessToken } = useGitHubLogin()
|
||||||
const { username, accessToken } = useGitHubLogin()
|
|
||||||
|
|
||||||
const loadMore = async () => {
|
const resetState = () => {
|
||||||
if (!accessToken.value || !username.value) {
|
repos.value = []
|
||||||
isReady.value = true
|
currentPage.value = 0
|
||||||
return
|
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 octokit = await getOctokit()
|
||||||
const nextPage = currentPage.value + 1
|
const nextPage = currentPage.value + 1
|
||||||
const repoList = await octokit.request("GET /search/repositories", {
|
const repoList = await octokit.request("GET /search/repositories", {
|
||||||
@@ -38,22 +52,45 @@ export const useRepos = () => {
|
|||||||
repos.value = [...repos.value, ...newItems].sort((a, b) =>
|
repos.value = [...repos.value, ...newItems].sort((a, b) =>
|
||||||
a.name < b.name ? -1 : 1
|
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
|
isReady.value = true
|
||||||
|
isLoading.value = false
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const canLoadMore = computed(() => repos.value.length < totalCount.value)
|
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
|
const isStale = Date.now() - lastFetchedAt > STALE_TIME_MS
|
||||||
if (!isReady.value || isStale) {
|
if (!isReady.value || isStale) {
|
||||||
if (isStale && isReady.value) {
|
if (isStale && isReady.value) {
|
||||||
repos.value = []
|
resetState()
|
||||||
currentPage.value = 0
|
|
||||||
totalCount.value = 0
|
|
||||||
isReady.value = false
|
|
||||||
}
|
}
|
||||||
lastFetchedAt = Date.now()
|
lastFetchedAt = Date.now()
|
||||||
loadMore()
|
loadMore()
|
||||||
}
|
}
|
||||||
|
|
||||||
return { repos, isReady, canLoadMore, loadMore }
|
return { repos, isReady, hasCredentialError, canLoadMore, loadMore }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export const useResizeContainer = (
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isMobile.value) {
|
if (isMobile.value) {
|
||||||
container.style.height = `${(stackedNotes.value.length + 1) * 100}vh`
|
container.style.height = `${(stackedNotes.value.length + 1) * 100}svh`
|
||||||
} else {
|
} else {
|
||||||
container.style.minWidth = `${
|
container.style.minWidth = `${
|
||||||
getNoteWidth() * (stackedNotes.value.length + 1)
|
getNoteWidth() * (stackedNotes.value.length + 1)
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ export const useRouteQueryStackedNotes = () => {
|
|||||||
})
|
})
|
||||||
const { height } = useWindowSize()
|
const { height } = useWindowSize()
|
||||||
|
|
||||||
const { scrollToNote, isMobile } = useOverlay(false)
|
const { scrollToNote, scrollToElement, isMobile } = useOverlay(false)
|
||||||
|
|
||||||
const scrollToHashInNote = (
|
const scrollToHashInNote = (
|
||||||
cleanSha: string,
|
cleanSha: string,
|
||||||
@@ -47,31 +47,52 @@ export const useRouteQueryStackedNotes = () => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 = {
|
type ScrollToFocusedNoteOptions = {
|
||||||
noteId?: string | null
|
noteId?: string | null
|
||||||
notes?: string[]
|
notes?: string[]
|
||||||
hash?: string
|
hash?: string
|
||||||
smoothHash?: boolean
|
smoothHash?: boolean
|
||||||
|
anchorTop?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
const scrollToFocusedNote = ({
|
const scrollToFocusedNote = ({
|
||||||
noteId = null,
|
noteId = null,
|
||||||
notes = stackedNotes.value,
|
notes = stackedNotes.value,
|
||||||
hash,
|
hash,
|
||||||
smoothHash = false
|
smoothHash = false,
|
||||||
|
anchorTop
|
||||||
}: ScrollToFocusedNoteOptions = {}) => {
|
}: ScrollToFocusedNoteOptions = {}) => {
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
const index = noteId ? notes.findIndex((nid) => nid === noteId) : 0
|
const index = noteId ? notes.findIndex((nid) => nid === noteId) : 0
|
||||||
|
|
||||||
if (isMobile.value) {
|
if (isMobile.value) {
|
||||||
if (noteId) {
|
if (noteId) {
|
||||||
const cleanNoteId = noteId.replaceAll(":", "-")
|
scrollToNoteElement(noteId.replaceAll(":", "-"), index, anchorTop)
|
||||||
const element = document.querySelector(
|
|
||||||
`.note-${cleanNoteId}`
|
|
||||||
) as HTMLElement
|
|
||||||
|
|
||||||
const top = (index + 1) * (element?.clientHeight ?? height.value)
|
|
||||||
scrollToNote(top)
|
|
||||||
} else {
|
} else {
|
||||||
scrollToNote(0)
|
scrollToNote(0)
|
||||||
}
|
}
|
||||||
@@ -96,11 +117,15 @@ export const useRouteQueryStackedNotes = () => {
|
|||||||
selector?: string,
|
selector?: string,
|
||||||
hash?: string
|
hash?: string
|
||||||
) => {
|
) => {
|
||||||
|
const anchorTop =
|
||||||
|
document.getElementById("main-app")?.scrollTop ?? undefined
|
||||||
|
|
||||||
if (stackedNotes.value.includes(sha)) {
|
if (stackedNotes.value.includes(sha)) {
|
||||||
scrollToFocusedNote({
|
scrollToFocusedNote({
|
||||||
noteId: selector ?? sha,
|
noteId: selector ?? sha,
|
||||||
hash,
|
hash,
|
||||||
smoothHash: true
|
smoothHash: true,
|
||||||
|
anchorTop
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -121,7 +146,7 @@ export const useRouteQueryStackedNotes = () => {
|
|||||||
stackedNotes.value = newStackedNotes
|
stackedNotes.value = newStackedNotes
|
||||||
}
|
}
|
||||||
|
|
||||||
scrollToFocusedNote({ noteId: selector ?? sha, hash })
|
scrollToFocusedNote({ noteId: selector ?? sha, hash, anchorTop })
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -121,20 +121,23 @@ export const queryFileContent = async (
|
|||||||
repo: string,
|
repo: string,
|
||||||
sha: string
|
sha: string
|
||||||
) => {
|
) => {
|
||||||
const octokit = await getOctokit()
|
|
||||||
|
|
||||||
if (!user || !repo) {
|
if (!user || !repo) {
|
||||||
null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
const file = await octokit.request(
|
try {
|
||||||
"GET /repos/{owner}/{repo}/git/blobs/{file_sha}",
|
const octokit = await getOctokit()
|
||||||
{
|
const file = await octokit.request(
|
||||||
owner: user,
|
"GET /repos/{owner}/{repo}/git/blobs/{file_sha}",
|
||||||
repo: repo,
|
{
|
||||||
file_sha: sha
|
owner: user,
|
||||||
}
|
repo: repo,
|
||||||
)
|
file_sha: sha
|
||||||
|
}
|
||||||
return file?.data.content ?? null
|
)
|
||||||
|
return file?.data.content ?? null
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("queryFileContent failed", { user, repo, sha, error })
|
||||||
|
return null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { defineStore } from "pinia"
|
import { defineStore } from "pinia"
|
||||||
|
import { toRaw } from "vue"
|
||||||
|
|
||||||
import { data, generateId } from "@/data/data"
|
import { data, generateId } from "@/data/data"
|
||||||
import { DataType } from "@/data/DataType.enum"
|
import { DataType } from "@/data/DataType.enum"
|
||||||
@@ -156,7 +157,7 @@ export const useUserRepoStore = defineStore("USER_REPO_STATE", {
|
|||||||
chosenFontSize: _s,
|
chosenFontSize: _s,
|
||||||
chosenFontFamily: _f,
|
chosenFontFamily: _f,
|
||||||
...repoConfig
|
...repoConfig
|
||||||
} = this.userSettings
|
} = toRaw(this.userSettings)
|
||||||
data.update<DataType.UserSettings, UserSettings>({
|
data.update<DataType.UserSettings, UserSettings>({
|
||||||
...repoConfig,
|
...repoConfig,
|
||||||
_id: userSettingsId
|
_id: userSettingsId
|
||||||
@@ -165,8 +166,10 @@ export const useUserRepoStore = defineStore("USER_REPO_STATE", {
|
|||||||
|
|
||||||
getCachedMainReadme(user, repo).then(async (cachedReadme) => {
|
getCachedMainReadme(user, repo).then(async (cachedReadme) => {
|
||||||
if (requestId !== this._requestId) return
|
if (requestId !== this._requestId) return
|
||||||
this.readme = cachedReadme
|
if (cachedReadme) this.readme = cachedReadme
|
||||||
this.readme = await getMainReadme(user, repo)
|
const fetched = await getMainReadme(user, repo)
|
||||||
|
if (requestId !== this._requestId) return
|
||||||
|
this.readme = fetched
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
addFile(file: RepoFile) {
|
addFile(file: RepoFile) {
|
||||||
@@ -184,7 +187,10 @@ export const useUserRepoStore = defineStore("USER_REPO_STATE", {
|
|||||||
DataType.SavedRepo,
|
DataType.SavedRepo,
|
||||||
`${this.user}-${this.repo}`
|
`${this.user}-${this.repo}`
|
||||||
)
|
)
|
||||||
const newFiles = [...this.files.filter((f) => f.sha !== file.sha), file]
|
const newFiles = [
|
||||||
|
...toRaw(this.files).filter((f) => f.sha !== file.sha),
|
||||||
|
toRaw(file)
|
||||||
|
]
|
||||||
data.update<DataType.SavedRepo, SavedRepo>({
|
data.update<DataType.SavedRepo, SavedRepo>({
|
||||||
_id: savedRepoId,
|
_id: savedRepoId,
|
||||||
$type: DataType.SavedRepo,
|
$type: DataType.SavedRepo,
|
||||||
|
|||||||
@@ -95,8 +95,9 @@ export const router = createRouter({
|
|||||||
routes
|
routes
|
||||||
})
|
})
|
||||||
|
|
||||||
router.beforeEach(() => {
|
router.beforeEach((to, from) => {
|
||||||
if (!("startViewTransition" in document)) return
|
if (!("startViewTransition" in document)) return
|
||||||
|
if (to.path === from.path) return
|
||||||
return new Promise<void>((resolve) => {
|
return new Promise<void>((resolve) => {
|
||||||
;(
|
;(
|
||||||
document as Document & {
|
document as Document & {
|
||||||
|
|||||||
@@ -13,6 +13,12 @@
|
|||||||
--light-link: lighten(#445fb9, 45%);
|
--light-link: lighten(#445fb9, 45%);
|
||||||
--background-color: #ffffff;
|
--background-color: #ffffff;
|
||||||
--note-width: 500px;
|
--note-width: 500px;
|
||||||
|
--note-canvas-bg: color-mix(
|
||||||
|
in oklch,
|
||||||
|
var(--color-base-100) 60%,
|
||||||
|
var(--color-base-200)
|
||||||
|
);
|
||||||
|
--note-sheet-shadow: 1px 0 8px rgb(0 0 0 / 6%);
|
||||||
--color-contrast-content: var(--color-success);
|
--color-contrast-content: var(--color-success);
|
||||||
--notyf-margin: 0.5rem;
|
--notyf-margin: 0.5rem;
|
||||||
}
|
}
|
||||||
@@ -25,8 +31,8 @@
|
|||||||
|
|
||||||
@plugin 'daisyui' {
|
@plugin 'daisyui' {
|
||||||
themes:
|
themes:
|
||||||
emerald --default,
|
light --default,
|
||||||
forest --prefersdark;
|
dracula --prefersdark;
|
||||||
}
|
}
|
||||||
|
|
||||||
@config '../../tailwind.config.js';
|
@config '../../tailwind.config.js';
|
||||||
@@ -89,18 +95,36 @@ a {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
a.title-stacked-note-link {
|
.title-stacked-note {
|
||||||
color: var(--color-base-content);
|
color: var(--color-base-content);
|
||||||
display: block;
|
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 0;
|
top: 0;
|
||||||
|
overflow: visible;
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
a.title-stacked-note-link {
|
||||||
|
display: block;
|
||||||
|
overflow: visible;
|
||||||
&:hover {
|
&:hover {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.title-stacked-note ul,
|
||||||
|
.title-stacked-note li {
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 0;
|
||||||
|
padding-left: 0;
|
||||||
|
text-decoration: none;
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
.notyf__toast {
|
.notyf__toast {
|
||||||
border-radius: revert-layer;
|
border-radius: revert-layer;
|
||||||
border: none;
|
border: none;
|
||||||
@@ -233,3 +257,12 @@ iframe {
|
|||||||
.todo-notes li:has(> input[type="checkbox"]) {
|
.todo-notes li:has(> input[type="checkbox"]) {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tabs :where(pre):not(:where([class~="not-prose"], [class~="not-prose"] *)) {
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 0;
|
||||||
|
padding-top: 0.2rem;
|
||||||
|
padding-inline-end: 0.2rem;
|
||||||
|
padding-bottom: 0.2rem;
|
||||||
|
padding-inline-start: 0.2rem;
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,6 +2,6 @@
|
|||||||
// Update these values to change the light and dark themes
|
// Update these values to change the light and dark themes
|
||||||
|
|
||||||
export const themeConfig = {
|
export const themeConfig = {
|
||||||
light: "emerald",
|
light: 'light',
|
||||||
dark: "forest"
|
dark: 'dracula'
|
||||||
}
|
}
|
||||||
|
|||||||
296
src/utils/alloy.tmLanguage.json
Normal file
296
src/utils/alloy.tmLanguage.json
Normal file
@@ -0,0 +1,296 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://raw.githubusercontent.com/martinring/tmlanguage/master/tmlanguage.json",
|
||||||
|
"name": "Alloy",
|
||||||
|
"scopeName": "source.als",
|
||||||
|
"patterns": [
|
||||||
|
{ "include": "#comments" },
|
||||||
|
{ "include": "#declaration" },
|
||||||
|
{ "include": "#expression" },
|
||||||
|
{ "include": "#built-in" },
|
||||||
|
{ "include": "#keywords" },
|
||||||
|
{ "include": "#digit" }
|
||||||
|
],
|
||||||
|
"repository": {
|
||||||
|
"comments": {
|
||||||
|
"patterns": [
|
||||||
|
{ "begin": "/\\*", "end": "\\*/", "name": "comment.block.alloy" },
|
||||||
|
{ "begin": "//", "end": "\n", "name": "comment.line.double-slash" },
|
||||||
|
{ "begin": "--", "end": "\n", "name": "comment.line.double-dash" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"keywords": {
|
||||||
|
"patterns": [
|
||||||
|
{ "include": "#define" },
|
||||||
|
{ "include": "#modifier" },
|
||||||
|
{ "include": "#operator" },
|
||||||
|
{ "include": "#control" },
|
||||||
|
{ "include": "#variable" }
|
||||||
|
],
|
||||||
|
"repository": {
|
||||||
|
"define": {
|
||||||
|
"patterns": [
|
||||||
|
{ "match": "\\b(sig)\\b", "name": "keyword.language.sig.alloy" },
|
||||||
|
{ "match": "\\b(fact)\\b", "name": "keyword.language.fact.alloy" },
|
||||||
|
{ "match": "\\b(pred)\\b", "name": "keyword.language.pred.alloy" },
|
||||||
|
{ "match": "\\b(fun)\\b", "name": "keyword.language.fun.alloy" },
|
||||||
|
{
|
||||||
|
"match": "\\b(module)\\b",
|
||||||
|
"name": "keyword.language.module.alloy"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"match": "\\b(extends)\\b",
|
||||||
|
"name": "keyword.language.extends.alloy"
|
||||||
|
},
|
||||||
|
{ "match": ":", "name": "keyword.other.colon.alloy" },
|
||||||
|
{
|
||||||
|
"match": "\\b(check)\\b",
|
||||||
|
"name": "keyword.language.check.alloy"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"match": "\\b(assert)\\b",
|
||||||
|
"name": "keyword.language.assert.alloy"
|
||||||
|
},
|
||||||
|
{ "match": "\\b(run)\\b", "name": "keyword.language.run.alloy" },
|
||||||
|
{ "match": "\\b(open)\\b", "name": "keyword.other.open.alloy" },
|
||||||
|
{ "match": "\\b(as)\\b", "name": "keyword.other.as.alloy" },
|
||||||
|
{ "match": "\\b(in)\\b", "name": "keyword.other.in.alloy" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"modifier": {
|
||||||
|
"patterns": [
|
||||||
|
{ "match": "\\b(var)\\b", "name": "keyword.modifier.var.alloy" },
|
||||||
|
{
|
||||||
|
"match": "\\b(private)\\b",
|
||||||
|
"name": "keyword.modifier.private.alloy"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"match": "\\b(abstract)\\b",
|
||||||
|
"name": "keyword.modifier.abstract.alloy"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"match": "\\b(all|disj|lone|no|one|set|seq|some|sum|univ|none)\\b",
|
||||||
|
"name": "keyword.modifier.set.alloy"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"operator": {
|
||||||
|
"patterns": [
|
||||||
|
{ "include": "#temporal" },
|
||||||
|
{ "include": "#unary" },
|
||||||
|
{ "include": "#binary" }
|
||||||
|
],
|
||||||
|
"repository": {
|
||||||
|
"temporal": {
|
||||||
|
"patterns": [
|
||||||
|
{
|
||||||
|
"match": "\\b(always|eventually|after|before|historically|once|prev)\\b",
|
||||||
|
"name": "keyword.operator.temporal.unary.alloy"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"match": "\\b(until|releases|since|triggered)\\b",
|
||||||
|
"name": "keyword.operator.temporal.binary.alloy"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"unary": {
|
||||||
|
"patterns": [
|
||||||
|
{
|
||||||
|
"match": "!|#|~|\\*|\\^|(\\b(not)\\b)",
|
||||||
|
"name": "keyword.operator.unary.alloy"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"binary": {
|
||||||
|
"patterns": [
|
||||||
|
{
|
||||||
|
"match": "(?:\\|\\|)|&&|<=>|=>|&|\\+|-|\\+\\+|<:|:>|\\.|=|->",
|
||||||
|
"name": "keyword.operator.binary.alloy"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"match": "\\b(and|or|iff|implies|else|in)\\b",
|
||||||
|
"name": "keyword.operator.binary.alloy"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"match": "=|<|>|=<|>=",
|
||||||
|
"name": "keyword.operator.binary.alloy"
|
||||||
|
},
|
||||||
|
{ "match": ",", "name": "keyword.other.comma.alloy" },
|
||||||
|
{ "match": "\\|", "name": "keyword.other.split.alloy" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"variable": {
|
||||||
|
"patterns": [
|
||||||
|
{ "match": "\\b(let)\\b", "name": "keyword.language.let.alloy" },
|
||||||
|
{ "match": "\\b(this)\\b", "name": "keyword.language.this.alloy" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"control": {
|
||||||
|
"patterns": [
|
||||||
|
{ "match": "\\b(for)\\b", "name": "keyword.control.for.alloy" },
|
||||||
|
{ "match": "\\b(but)\\b", "name": "keyword.control.but.alloy" },
|
||||||
|
{
|
||||||
|
"match": "\\b(exactly)\\b",
|
||||||
|
"name": "keyword.control.exactly.alloy"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"match": "\\b(expect)\\b",
|
||||||
|
"name": "keyword.control.expect.alloy"
|
||||||
|
},
|
||||||
|
{ "match": "\\b(steps)\\b", "name": "keyword.control.steps.alloy" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"declaration": {
|
||||||
|
"patterns": [
|
||||||
|
{ "include": "#module" },
|
||||||
|
{ "include": "#predict" },
|
||||||
|
{ "include": "#signature" },
|
||||||
|
{ "include": "#fact" },
|
||||||
|
{ "include": "#fun" }
|
||||||
|
],
|
||||||
|
"repository": {
|
||||||
|
"module": {
|
||||||
|
"patterns": [
|
||||||
|
{
|
||||||
|
"match": "(module)\\b\\s*((?:\\w|'|_|\\d|/)+)",
|
||||||
|
"captures": {
|
||||||
|
"1": { "name": "keyword.language.module.alloy" },
|
||||||
|
"2": { "name": "support.class.module.alloy" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"predict": {
|
||||||
|
"patterns": [
|
||||||
|
{
|
||||||
|
"match": "(pred)\\b\\s*((?:\\w|'|_|\\d|/)+)",
|
||||||
|
"captures": {
|
||||||
|
"1": { "name": "keyword.language.pred.alloy" },
|
||||||
|
"2": { "name": "entity.name.function.pred.alloy" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"signature": {
|
||||||
|
"patterns": [
|
||||||
|
{
|
||||||
|
"begin": "(abstract)?\\s*(lone|some|one)?\\s*(var)?\\s*(sig)\\b\\s*",
|
||||||
|
"end": "(?=\\{)",
|
||||||
|
"beginCaptures": {
|
||||||
|
"1": { "name": "keyword.modifier.abstract.alloy" },
|
||||||
|
"2": { "name": "keyword.modifier.set.alloy" },
|
||||||
|
"3": { "name": "keyword.modifier.var.alloy" },
|
||||||
|
"4": { "name": "keyword.language.sig.alloy" }
|
||||||
|
},
|
||||||
|
"patterns": [
|
||||||
|
{
|
||||||
|
"begin": "(extends)",
|
||||||
|
"end": "(?=\\{)",
|
||||||
|
"beginCaptures": {
|
||||||
|
"1": { "name": "keyword.language.extends.alloy" }
|
||||||
|
},
|
||||||
|
"patterns": [
|
||||||
|
{
|
||||||
|
"match": "(?:\\w|'|_|\\d|/)+",
|
||||||
|
"name": "entity.other.inherited-class.alloy"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"begin": "(in)",
|
||||||
|
"end": "(?=\\{)",
|
||||||
|
"beginCaptures": {
|
||||||
|
"1": { "name": "keyword.other.in.alloy" }
|
||||||
|
},
|
||||||
|
"patterns": [
|
||||||
|
{
|
||||||
|
"match": "(?:\\w|'|_|\\d|/)+",
|
||||||
|
"name": "entity.other.inherited-class.alloy"
|
||||||
|
},
|
||||||
|
{ "match": "\\+", "name": "keyword.operator.binary.alloy" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"match": "(?:\\w|'|_|\\d|/)+",
|
||||||
|
"name": "entity.name.type.signature.alloy"
|
||||||
|
},
|
||||||
|
{ "match": ",", "name": "keyword.other.comma.alloy" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"fact": {
|
||||||
|
"patterns": [
|
||||||
|
{
|
||||||
|
"match": "(fact)\\b\\s*((?:\\w|'|_|\\d|/)+)",
|
||||||
|
"captures": {
|
||||||
|
"1": { "name": "keyword.language.fact.alloy" },
|
||||||
|
"2": { "name": "entity.name.function.fact.alloy" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"fun": {
|
||||||
|
"patterns": [
|
||||||
|
{
|
||||||
|
"match": "(fun)\\b\\s*((?:\\w|'|_|\\d|/)+)",
|
||||||
|
"captures": {
|
||||||
|
"1": { "name": "keyword.language.fun.alloy" },
|
||||||
|
"2": { "name": "entity.name.function.fun.alloy" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"expression": {
|
||||||
|
"patterns": [
|
||||||
|
{
|
||||||
|
"match": "(check)\\b\\s*((?:\\w|'|_|\\d|/)+)",
|
||||||
|
"captures": {
|
||||||
|
"1": { "name": "keyword.language.check.alloy" },
|
||||||
|
"2": { "name": "entity.name.function.check.alloy" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"match": "(assert)\\b\\s*((?:\\w|'|_|\\d|/)+)",
|
||||||
|
"captures": {
|
||||||
|
"1": { "name": "keyword.language.assert.alloy" },
|
||||||
|
"2": { "name": "entity.name.function.check.alloy" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"digit": {
|
||||||
|
"patterns": [
|
||||||
|
{ "match": "\\b(\\d+)\\b", "name": "constant.numeric.alloy" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"built-in": {
|
||||||
|
"patterns": [
|
||||||
|
{
|
||||||
|
"match": "\\b(plus|minus|mul|div|rem|sum)\\[",
|
||||||
|
"captures": { "1": { "name": "support.function.numeric.alloy" } }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"match": "\\b(open)\\b\\s*((?:\\w|'|_|\\d|/)+)\\[",
|
||||||
|
"captures": {
|
||||||
|
"1": { "name": "keyword.other.open.alloy" },
|
||||||
|
"2": { "name": "support.class.module.alloy" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"match": "(/(?:\\w|'|_|\\d|/)+)",
|
||||||
|
"captures": { "1": { "name": "support.function.order.alloy" } }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"match": "((?:\\w|'|_|\\d)+)\\s*\\[",
|
||||||
|
"captures": { "1": { "name": "support.function.order.alloy" } }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,33 +1,56 @@
|
|||||||
import FontFaceObserver from "fontfaceobserver"
|
import FontFaceObserver from "fontfaceobserver"
|
||||||
|
|
||||||
const assembleFontLink = (font: string) => {
|
const GENERIC_FAMILIES = new Set([
|
||||||
return `https://api.fonts.coollabs.io/css2?display=swap&family=${font
|
"serif",
|
||||||
.replaceAll(",", "&family=")
|
"sans-serif",
|
||||||
.replaceAll(" ", "+")}`
|
"monospace",
|
||||||
|
"cursive",
|
||||||
|
"fantasy",
|
||||||
|
"system-ui",
|
||||||
|
"ui-serif",
|
||||||
|
"ui-sans-serif",
|
||||||
|
"ui-monospace",
|
||||||
|
"ui-rounded"
|
||||||
|
])
|
||||||
|
|
||||||
|
const parseWebFontFamilies = (font: string): string[] =>
|
||||||
|
font
|
||||||
|
.split(",")
|
||||||
|
.map((f) => f.trim().replace(/^["']|["']$/g, ""))
|
||||||
|
.filter((f) => f && !GENERIC_FAMILIES.has(f))
|
||||||
|
|
||||||
|
const assembleFontLink = (families: string[]): string | null => {
|
||||||
|
if (families.length === 0) return null
|
||||||
|
return `https://api.fonts.coollabs.io/css2?display=swap&${families
|
||||||
|
.map((f) => `family=${f.replaceAll(" ", "+")}`)
|
||||||
|
.join("&")}`
|
||||||
}
|
}
|
||||||
|
|
||||||
export const downloadFont = async (
|
export const downloadFont = async (
|
||||||
font: string,
|
font: string,
|
||||||
cssVar = "--font-family"
|
cssVar = "--font-family"
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
const href = assembleFontLink(font)
|
const families = parseWebFontFamilies(font)
|
||||||
|
const href = assembleFontLink(families)
|
||||||
|
|
||||||
// check if the href already exists
|
if (href) {
|
||||||
const existingLink = document.querySelector(`link[href="${href}"]`)
|
const alreadyLoaded = Array.from(
|
||||||
|
document.head.querySelectorAll<HTMLLinkElement>('link[rel="stylesheet"]')
|
||||||
|
).some((link) => link.href === href)
|
||||||
|
|
||||||
if (!existingLink) {
|
if (!alreadyLoaded) {
|
||||||
const link = document.createElement("link")
|
const link = document.createElement("link")
|
||||||
link.href = href
|
link.href = href
|
||||||
link.rel = "stylesheet"
|
link.rel = "stylesheet"
|
||||||
|
document.head.appendChild(link)
|
||||||
|
}
|
||||||
|
|
||||||
document.head.appendChild(link)
|
try {
|
||||||
|
await new FontFaceObserver(families[0]).load()
|
||||||
|
} catch {
|
||||||
|
console.warn("error when loading font")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
document.documentElement.style.setProperty(cssVar, font)
|
||||||
await new FontFaceObserver(font).load()
|
|
||||||
|
|
||||||
document.documentElement.style.setProperty(cssVar, font)
|
|
||||||
} catch (error) {
|
|
||||||
console.warn("error when loading font")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
31
src/utils/fileLanguage.ts
Normal file
31
src/utils/fileLanguage.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
const EXT_TO_LANG: Record<string, string> = {
|
||||||
|
sh: "bash",
|
||||||
|
bash: "bash",
|
||||||
|
js: "javascript",
|
||||||
|
mjs: "javascript",
|
||||||
|
cjs: "javascript",
|
||||||
|
ts: "typescript",
|
||||||
|
mts: "typescript",
|
||||||
|
cts: "typescript",
|
||||||
|
md: "markdown",
|
||||||
|
mdx: "markdown",
|
||||||
|
html: "html",
|
||||||
|
htm: "html",
|
||||||
|
css: "css",
|
||||||
|
scss: "css",
|
||||||
|
json: "json",
|
||||||
|
jsonc: "json",
|
||||||
|
als: "alloy"
|
||||||
|
}
|
||||||
|
|
||||||
|
const MARKDOWN_EXTS = new Set(["md", "mdx"])
|
||||||
|
|
||||||
|
export function isMarkdownPath(path: string): boolean {
|
||||||
|
const ext = path.split(".").pop()?.toLowerCase() ?? ""
|
||||||
|
return MARKDOWN_EXTS.has(ext)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getFileLanguage(path: string): string | null {
|
||||||
|
const ext = path.split(".").pop()?.toLowerCase() ?? ""
|
||||||
|
return EXT_TO_LANG[ext] ?? null
|
||||||
|
}
|
||||||
@@ -82,7 +82,7 @@ watch(mode, async (newMode) => {
|
|||||||
newContent.value
|
newContent.value
|
||||||
}`
|
}`
|
||||||
|
|
||||||
const newSha = await createFile({
|
const { sha: newSha } = await createFile({
|
||||||
content,
|
content,
|
||||||
path: newContentPath
|
path: newContentPath
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -241,7 +241,6 @@ watch(
|
|||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
position: sticky;
|
|
||||||
|
|
||||||
.title {
|
.title {
|
||||||
text-align: left;
|
text-align: left;
|
||||||
@@ -249,6 +248,8 @@ watch(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media screen and (min-width: 769px) {
|
@media screen and (min-width: 769px) {
|
||||||
|
background-color: var(--note-canvas-bg);
|
||||||
|
|
||||||
.repo-title-breadcrumb {
|
.repo-title-breadcrumb {
|
||||||
padding: 0.5rem 1rem 0;
|
padding: 0.5rem 1rem 0;
|
||||||
transform-origin: 0 0;
|
transform-origin: 0 0;
|
||||||
@@ -266,6 +267,11 @@ watch(
|
|||||||
.note {
|
.note {
|
||||||
min-width: var(--note-width);
|
min-width: var(--note-width);
|
||||||
max-width: var(--note-width);
|
max-width: var(--note-width);
|
||||||
|
background-color: var(--color-base-100);
|
||||||
|
}
|
||||||
|
|
||||||
|
.article {
|
||||||
|
box-shadow: var(--note-sheet-shadow);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,12 +2,13 @@
|
|||||||
import { vInfiniteScroll } from "@vueuse/components"
|
import { vInfiniteScroll } from "@vueuse/components"
|
||||||
|
|
||||||
import GoBack from "@/components/GoBack.vue"
|
import GoBack from "@/components/GoBack.vue"
|
||||||
|
import SignInGithub from "@/components/SignInGithub.vue"
|
||||||
import { useGitHubLogin } from "@/hooks/useGitHubLogin.hook"
|
import { useGitHubLogin } from "@/hooks/useGitHubLogin.hook"
|
||||||
import { useRepos } from "@/hooks/useRepos.hook"
|
import { useRepos } from "@/hooks/useRepos.hook"
|
||||||
import { useRepoList } from "@/modules/repo/hooks/useRepoList.hook"
|
import { useRepoList } from "@/modules/repo/hooks/useRepoList.hook"
|
||||||
|
|
||||||
const { username } = useGitHubLogin()
|
const { username } = useGitHubLogin()
|
||||||
const { isReady } = useRepos()
|
const { isReady, hasCredentialError } = useRepos()
|
||||||
const {
|
const {
|
||||||
favoriteRepos,
|
favoriteRepos,
|
||||||
otherRepos,
|
otherRepos,
|
||||||
@@ -26,6 +27,10 @@ const {
|
|||||||
<h1 class="title is-1">Repositories</h1>
|
<h1 class="title is-1">Repositories</h1>
|
||||||
<go-back />
|
<go-back />
|
||||||
<div v-if="!isReady">loading...</div>
|
<div v-if="!isReady">loading...</div>
|
||||||
|
<div v-else-if="hasCredentialError">
|
||||||
|
<p>Your GitHub credentials are invalid or expired.</p>
|
||||||
|
<sign-in-github />
|
||||||
|
</div>
|
||||||
<div v-else class="columns is-centered">
|
<div v-else class="columns is-centered">
|
||||||
<div class="column is-one-third">
|
<div class="column is-one-third">
|
||||||
<table
|
<table
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
<script lang="ts" setup></script>
|
<script lang="ts" setup>
|
||||||
|
import SignInGithub from "@/components/SignInGithub.vue"
|
||||||
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<main class="space-cowboy content">
|
<main class="space-cowboy content">
|
||||||
@@ -43,6 +45,7 @@
|
|||||||
<router-link class="button is-links" :to="{ name: 'Home' }"
|
<router-link class="button is-links" :to="{ name: 'Home' }"
|
||||||
>return to homepage</router-link
|
>return to homepage</router-link
|
||||||
>
|
>
|
||||||
|
<sign-in-github />
|
||||||
</main>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ export default defineConfig(({ command }) => {
|
|||||||
"pwa-512x512.png",
|
"pwa-512x512.png",
|
||||||
"masked-icon.png",
|
"masked-icon.png",
|
||||||
"maskable-icon-512x512.png",
|
"maskable-icon-512x512.png",
|
||||||
"monochrome-icon.png",
|
"monochromeicon.png",
|
||||||
"assets/*.svg"
|
"assets/*.svg"
|
||||||
],
|
],
|
||||||
manifest: {
|
manifest: {
|
||||||
@@ -54,7 +54,7 @@ export default defineConfig(({ command }) => {
|
|||||||
purpose: "maskable"
|
purpose: "maskable"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
src: "monochrome-icon.png",
|
src: "monochromeicon.png",
|
||||||
sizes: "1024x1024",
|
sizes: "1024x1024",
|
||||||
type: "image/png",
|
type: "image/png",
|
||||||
purpose: "monochrome"
|
purpose: "monochrome"
|
||||||
|
|||||||
Reference in New Issue
Block a user