Add Rails backend: accounts via magic link, server documents, sharing
The app becomes a Rails 8 application; the Vue frontend moves to app/frontend and is served through vite_rails. - Without an account documents stay in the browser (IndexedDB). Sign-in is by magic link or the 6-digit code from the same mail; afterwards local documents can be uploaded into the account. - Server documents (/d/<id>) with versioned saves and a single-editor lock per browser tab; others see them read-only and follow by polling. - Sharing via link (works without an account) and via e-mail invitation, roles viewer/editor; the owner manages links and people. - Store gains a read-only mode that blocks all content changes. - Solid Queue, Cache and Cable share the one PostgreSQL database. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+22
@@ -5,3 +5,25 @@ coverage
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
.vscode/*
|
.vscode/*
|
||||||
!.vscode/extensions.json
|
!.vscode/extensions.json
|
||||||
|
|
||||||
|
# Vite Ruby
|
||||||
|
/public/vite*
|
||||||
|
node_modules
|
||||||
|
# Vite uses dotenv and suggests to ignore local-only env files. See
|
||||||
|
# https://vite.dev/guide/env-and-mode.html#env-files
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Rails
|
||||||
|
/.bundle
|
||||||
|
/log/*
|
||||||
|
/tmp/*
|
||||||
|
!/log/.keep
|
||||||
|
!/tmp/.keep
|
||||||
|
!/tmp/pids/
|
||||||
|
/tmp/pids/*
|
||||||
|
!/tmp/pids/.keep
|
||||||
|
/storage/*
|
||||||
|
!/storage/.keep
|
||||||
|
/config/master.key
|
||||||
|
/.env*
|
||||||
|
/vendor/gems
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
ruby-3.4.9
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
source "https://rubygems.org"
|
||||||
|
|
||||||
|
# Bundle edge Rails instead: gem "rails", github: "rails/rails", branch: "main"
|
||||||
|
gem "rails", "~> 8.1.3", ">= 8.1.3.1"
|
||||||
|
# Use postgresql as the database for Active Record
|
||||||
|
gem "pg", "~> 1.1"
|
||||||
|
# Use the Puma web server [https://github.com/puma/puma]
|
||||||
|
gem "puma", ">= 5.0"
|
||||||
|
|
||||||
|
# Use Active Model has_secure_password [https://guides.rubyonrails.org/active_model_basics.html#securepassword]
|
||||||
|
# gem "bcrypt", "~> 3.1.7"
|
||||||
|
|
||||||
|
# Windows does not include zoneinfo files, so bundle the tzinfo-data gem
|
||||||
|
gem "tzinfo-data", platforms: %i[ windows jruby ]
|
||||||
|
|
||||||
|
# Use the database-backed adapters for Rails.cache, Active Job, and Action Cable
|
||||||
|
gem "solid_cache"
|
||||||
|
gem "solid_queue"
|
||||||
|
gem "solid_cable"
|
||||||
|
|
||||||
|
# Reduces boot times through caching; required in config/boot.rb
|
||||||
|
gem "bootsnap", require: false
|
||||||
|
|
||||||
|
group :development, :test do
|
||||||
|
# See https://guides.rubyonrails.org/debugging_rails_applications.html#debugging-with-the-debug-gem
|
||||||
|
gem "debug", platforms: %i[ mri windows ], require: "debug/prelude"
|
||||||
|
|
||||||
|
# Audits gems for known security defects (use config/bundler-audit.yml to ignore issues)
|
||||||
|
gem "bundler-audit", require: false
|
||||||
|
|
||||||
|
# Static analysis for security vulnerabilities [https://brakemanscanner.org/]
|
||||||
|
gem "brakeman", require: false
|
||||||
|
end
|
||||||
|
|
||||||
|
group :development do
|
||||||
|
# Use console on exceptions pages [https://github.com/rails/web-console]
|
||||||
|
gem "web-console"
|
||||||
|
end
|
||||||
|
|
||||||
|
# Vite as the frontend build, integrated into Rails [https://vite-ruby.netlify.app]
|
||||||
|
gem "vite_rails"
|
||||||
|
|
||||||
|
group :development do
|
||||||
|
# Mails open in the browser at /letter_opener instead of being sent
|
||||||
|
gem "letter_opener_web"
|
||||||
|
end
|
||||||
+315
@@ -0,0 +1,315 @@
|
|||||||
|
GEM
|
||||||
|
remote: https://rubygems.org/
|
||||||
|
specs:
|
||||||
|
action_text-trix (2.1.19)
|
||||||
|
railties
|
||||||
|
actioncable (8.1.4)
|
||||||
|
actionpack (= 8.1.4)
|
||||||
|
activesupport (= 8.1.4)
|
||||||
|
nio4r (~> 2.0)
|
||||||
|
websocket-driver (>= 0.6.1)
|
||||||
|
zeitwerk (~> 2.6)
|
||||||
|
actionmailbox (8.1.4)
|
||||||
|
actionpack (= 8.1.4)
|
||||||
|
activejob (= 8.1.4)
|
||||||
|
activerecord (= 8.1.4)
|
||||||
|
activestorage (= 8.1.4)
|
||||||
|
activesupport (= 8.1.4)
|
||||||
|
mail (>= 2.8.0)
|
||||||
|
actionmailer (8.1.4)
|
||||||
|
actionpack (= 8.1.4)
|
||||||
|
actionview (= 8.1.4)
|
||||||
|
activejob (= 8.1.4)
|
||||||
|
activesupport (= 8.1.4)
|
||||||
|
mail (>= 2.8.0)
|
||||||
|
rails-dom-testing (~> 2.2)
|
||||||
|
actionpack (8.1.4)
|
||||||
|
actionview (= 8.1.4)
|
||||||
|
activesupport (= 8.1.4)
|
||||||
|
nokogiri (>= 1.8.5)
|
||||||
|
rack (>= 2.2.4)
|
||||||
|
rack-session (>= 1.0.1)
|
||||||
|
rack-test (>= 0.6.3)
|
||||||
|
rails-dom-testing (~> 2.2)
|
||||||
|
rails-html-sanitizer (~> 1.6)
|
||||||
|
useragent (~> 0.16)
|
||||||
|
actiontext (8.1.4)
|
||||||
|
action_text-trix (~> 2.1.15)
|
||||||
|
actionpack (= 8.1.4)
|
||||||
|
activerecord (= 8.1.4)
|
||||||
|
activestorage (= 8.1.4)
|
||||||
|
activesupport (= 8.1.4)
|
||||||
|
globalid (>= 0.6.0)
|
||||||
|
nokogiri (>= 1.8.5)
|
||||||
|
actionview (8.1.4)
|
||||||
|
activesupport (= 8.1.4)
|
||||||
|
builder (~> 3.1)
|
||||||
|
erubi (~> 1.11)
|
||||||
|
rails-dom-testing (~> 2.2)
|
||||||
|
rails-html-sanitizer (~> 1.6)
|
||||||
|
activejob (8.1.4)
|
||||||
|
activesupport (= 8.1.4)
|
||||||
|
globalid (>= 0.3.6)
|
||||||
|
activemodel (8.1.4)
|
||||||
|
activesupport (= 8.1.4)
|
||||||
|
activerecord (8.1.4)
|
||||||
|
activemodel (= 8.1.4)
|
||||||
|
activesupport (= 8.1.4)
|
||||||
|
timeout (>= 0.4.0)
|
||||||
|
activestorage (8.1.4)
|
||||||
|
actionpack (= 8.1.4)
|
||||||
|
activejob (= 8.1.4)
|
||||||
|
activerecord (= 8.1.4)
|
||||||
|
activesupport (= 8.1.4)
|
||||||
|
marcel (~> 1.0)
|
||||||
|
activesupport (8.1.4)
|
||||||
|
base64
|
||||||
|
bigdecimal
|
||||||
|
concurrent-ruby (~> 1.0, >= 1.3.1)
|
||||||
|
connection_pool (>= 2.2.5)
|
||||||
|
drb
|
||||||
|
i18n (>= 1.6, < 2)
|
||||||
|
json
|
||||||
|
logger (>= 1.4.2)
|
||||||
|
minitest (>= 5.1)
|
||||||
|
securerandom (>= 0.3)
|
||||||
|
tzinfo (~> 2.0, >= 2.0.5)
|
||||||
|
uri (>= 0.13.1)
|
||||||
|
addressable (2.9.0)
|
||||||
|
public_suffix (>= 2.0.2, < 8.0)
|
||||||
|
base64 (0.3.0)
|
||||||
|
bigdecimal (4.1.3)
|
||||||
|
bindex (0.8.1)
|
||||||
|
bootsnap (1.26.0)
|
||||||
|
msgpack (~> 1.5)
|
||||||
|
brakeman (8.0.6)
|
||||||
|
racc
|
||||||
|
builder (3.3.0)
|
||||||
|
bundler-audit (0.9.3)
|
||||||
|
bundler (>= 1.2.0)
|
||||||
|
thor (~> 1.0)
|
||||||
|
childprocess (5.1.0)
|
||||||
|
logger (~> 1.5)
|
||||||
|
concurrent-ruby (1.3.8)
|
||||||
|
connection_pool (3.0.2)
|
||||||
|
crass (1.0.7)
|
||||||
|
date (3.5.1)
|
||||||
|
debug (1.11.1)
|
||||||
|
irb (~> 1.10)
|
||||||
|
reline (>= 0.3.8)
|
||||||
|
drb (2.2.3)
|
||||||
|
dry-cli (1.4.1)
|
||||||
|
erb (6.0.7)
|
||||||
|
erubi (1.13.1)
|
||||||
|
et-orbi (1.4.2)
|
||||||
|
tzinfo
|
||||||
|
fugit (1.14.0)
|
||||||
|
et-orbi (~> 1.4)
|
||||||
|
raabro (~> 1.4)
|
||||||
|
globalid (1.4.0)
|
||||||
|
activesupport (>= 6.1)
|
||||||
|
i18n (1.15.2)
|
||||||
|
concurrent-ruby (~> 1.0)
|
||||||
|
io-console (0.9.4)
|
||||||
|
irb (1.18.0)
|
||||||
|
pp (>= 0.6.0)
|
||||||
|
prism (>= 1.3.0)
|
||||||
|
rdoc (>= 4.0.0)
|
||||||
|
reline (>= 0.4.2)
|
||||||
|
json (3.0.2)
|
||||||
|
launchy (3.1.1)
|
||||||
|
addressable (~> 2.8)
|
||||||
|
childprocess (~> 5.0)
|
||||||
|
logger (~> 1.6)
|
||||||
|
letter_opener (1.10.0)
|
||||||
|
launchy (>= 2.2, < 4)
|
||||||
|
letter_opener_web (3.0.0)
|
||||||
|
actionmailer (>= 6.1)
|
||||||
|
letter_opener (~> 1.9)
|
||||||
|
railties (>= 6.1)
|
||||||
|
rexml
|
||||||
|
logger (1.7.0)
|
||||||
|
loofah (2.25.2)
|
||||||
|
crass (~> 1.0.2)
|
||||||
|
nokogiri (>= 1.12.0)
|
||||||
|
mail (2.9.1)
|
||||||
|
logger
|
||||||
|
mini_mime (>= 0.1.1)
|
||||||
|
net-imap
|
||||||
|
net-pop
|
||||||
|
net-smtp
|
||||||
|
marcel (1.2.1)
|
||||||
|
mini_mime (1.1.5)
|
||||||
|
minitest (6.0.6)
|
||||||
|
drb (~> 2.0)
|
||||||
|
prism (~> 1.5)
|
||||||
|
msgpack (1.8.5)
|
||||||
|
mutex_m (0.3.0)
|
||||||
|
net-imap (0.6.7)
|
||||||
|
date
|
||||||
|
net-protocol
|
||||||
|
net-pop (0.1.2)
|
||||||
|
net-protocol
|
||||||
|
net-protocol (0.4.0)
|
||||||
|
timeout
|
||||||
|
net-smtp (0.5.1)
|
||||||
|
net-protocol
|
||||||
|
nio4r (2.7.5)
|
||||||
|
nokogiri (1.19.4-aarch64-linux-gnu)
|
||||||
|
racc (~> 1.4)
|
||||||
|
nokogiri (1.19.4-aarch64-linux-musl)
|
||||||
|
racc (~> 1.4)
|
||||||
|
nokogiri (1.19.4-arm-linux-gnu)
|
||||||
|
racc (~> 1.4)
|
||||||
|
nokogiri (1.19.4-arm-linux-musl)
|
||||||
|
racc (~> 1.4)
|
||||||
|
nokogiri (1.19.4-arm64-darwin)
|
||||||
|
racc (~> 1.4)
|
||||||
|
nokogiri (1.19.4-x86_64-darwin)
|
||||||
|
racc (~> 1.4)
|
||||||
|
nokogiri (1.19.4-x86_64-linux-gnu)
|
||||||
|
racc (~> 1.4)
|
||||||
|
nokogiri (1.19.4-x86_64-linux-musl)
|
||||||
|
racc (~> 1.4)
|
||||||
|
pg (1.6.3)
|
||||||
|
pg (1.6.3-aarch64-linux)
|
||||||
|
pg (1.6.3-aarch64-linux-musl)
|
||||||
|
pg (1.6.3-arm64-darwin)
|
||||||
|
pg (1.6.3-x86_64-darwin)
|
||||||
|
pg (1.6.3-x86_64-linux)
|
||||||
|
pg (1.6.3-x86_64-linux-musl)
|
||||||
|
pp (0.6.4)
|
||||||
|
prettyprint
|
||||||
|
prettyprint (0.2.0)
|
||||||
|
prism (1.9.0)
|
||||||
|
public_suffix (7.0.5)
|
||||||
|
puma (8.0.2)
|
||||||
|
nio4r (~> 2.0)
|
||||||
|
raabro (1.5.0)
|
||||||
|
racc (1.8.1)
|
||||||
|
rack (3.2.7)
|
||||||
|
rack-proxy (2.0.1)
|
||||||
|
rack (>= 2.0, < 4)
|
||||||
|
rack-session (2.1.2)
|
||||||
|
base64 (>= 0.1.0)
|
||||||
|
rack (>= 3.0.0)
|
||||||
|
rack-test (2.2.0)
|
||||||
|
rack (>= 1.3)
|
||||||
|
rackup (2.3.1)
|
||||||
|
rack (>= 3)
|
||||||
|
rails (8.1.4)
|
||||||
|
actioncable (= 8.1.4)
|
||||||
|
actionmailbox (= 8.1.4)
|
||||||
|
actionmailer (= 8.1.4)
|
||||||
|
actionpack (= 8.1.4)
|
||||||
|
actiontext (= 8.1.4)
|
||||||
|
actionview (= 8.1.4)
|
||||||
|
activejob (= 8.1.4)
|
||||||
|
activemodel (= 8.1.4)
|
||||||
|
activerecord (= 8.1.4)
|
||||||
|
activestorage (= 8.1.4)
|
||||||
|
activesupport (= 8.1.4)
|
||||||
|
bundler (>= 1.15.0)
|
||||||
|
railties (= 8.1.4)
|
||||||
|
rails-dom-testing (2.3.0)
|
||||||
|
activesupport (>= 5.0.0)
|
||||||
|
minitest
|
||||||
|
nokogiri (>= 1.6)
|
||||||
|
rails-html-sanitizer (1.7.1)
|
||||||
|
loofah (~> 2.25, >= 2.25.2)
|
||||||
|
nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
|
||||||
|
railties (8.1.4)
|
||||||
|
actionpack (= 8.1.4)
|
||||||
|
activesupport (= 8.1.4)
|
||||||
|
irb (~> 1.13)
|
||||||
|
rackup (>= 1.0.0)
|
||||||
|
rake (>= 12.2)
|
||||||
|
thor (~> 1.0, >= 1.2.2)
|
||||||
|
tsort (>= 0.2)
|
||||||
|
zeitwerk (~> 2.6)
|
||||||
|
rake (13.4.2)
|
||||||
|
rbs (4.2.0)
|
||||||
|
logger
|
||||||
|
prism (>= 1.6.0)
|
||||||
|
tsort
|
||||||
|
rdoc (8.0.0)
|
||||||
|
erb
|
||||||
|
prism (>= 1.6.0)
|
||||||
|
rbs (>= 4.0.0)
|
||||||
|
tsort
|
||||||
|
reline (0.7.0)
|
||||||
|
io-console (~> 0.5)
|
||||||
|
rexml (3.4.4)
|
||||||
|
securerandom (0.4.1)
|
||||||
|
solid_cable (4.1.0)
|
||||||
|
actioncable (>= 7.2)
|
||||||
|
activejob (>= 7.2)
|
||||||
|
activerecord (>= 7.2)
|
||||||
|
railties (>= 7.2)
|
||||||
|
solid_cache (1.0.10)
|
||||||
|
activejob (>= 7.2)
|
||||||
|
activerecord (>= 7.2)
|
||||||
|
railties (>= 7.2)
|
||||||
|
solid_queue (1.7.0)
|
||||||
|
activejob (>= 7.1)
|
||||||
|
activerecord (>= 7.1)
|
||||||
|
concurrent-ruby (>= 1.3.1)
|
||||||
|
fugit (~> 1.11)
|
||||||
|
railties (>= 7.1)
|
||||||
|
thor (>= 1.3.1)
|
||||||
|
thor (1.5.0)
|
||||||
|
timeout (0.6.1)
|
||||||
|
tsort (0.2.0)
|
||||||
|
tzinfo (2.0.6)
|
||||||
|
concurrent-ruby (~> 1.0)
|
||||||
|
uri (1.1.1)
|
||||||
|
useragent (0.16.11)
|
||||||
|
vite_rails (3.11.1)
|
||||||
|
railties (>= 5.1, < 9)
|
||||||
|
vite_ruby (~> 3.0, >= 3.2.2)
|
||||||
|
vite_ruby (3.11.0)
|
||||||
|
dry-cli (>= 0.7, < 2)
|
||||||
|
logger (~> 1.6)
|
||||||
|
mutex_m
|
||||||
|
rack-proxy (>= 0.6.1)
|
||||||
|
zeitwerk (~> 2.2)
|
||||||
|
web-console (4.3.0)
|
||||||
|
actionview (>= 8.0.0)
|
||||||
|
bindex (>= 0.4.0)
|
||||||
|
railties (>= 8.0.0)
|
||||||
|
websocket-driver (0.8.2)
|
||||||
|
base64
|
||||||
|
websocket-extensions (>= 0.1.0)
|
||||||
|
websocket-extensions (0.1.5)
|
||||||
|
zeitwerk (2.8.3)
|
||||||
|
|
||||||
|
PLATFORMS
|
||||||
|
aarch64-linux
|
||||||
|
aarch64-linux-gnu
|
||||||
|
aarch64-linux-musl
|
||||||
|
arm-linux-gnu
|
||||||
|
arm-linux-musl
|
||||||
|
arm64-darwin
|
||||||
|
x86_64-darwin
|
||||||
|
x86_64-linux
|
||||||
|
x86_64-linux-gnu
|
||||||
|
x86_64-linux-musl
|
||||||
|
|
||||||
|
DEPENDENCIES
|
||||||
|
bootsnap
|
||||||
|
brakeman
|
||||||
|
bundler-audit
|
||||||
|
debug
|
||||||
|
letter_opener_web
|
||||||
|
pg (~> 1.1)
|
||||||
|
puma (>= 5.0)
|
||||||
|
rails (~> 8.1.3, >= 8.1.3.1)
|
||||||
|
solid_cable
|
||||||
|
solid_cache
|
||||||
|
solid_queue
|
||||||
|
tzinfo-data
|
||||||
|
vite_rails
|
||||||
|
web-console
|
||||||
|
|
||||||
|
BUNDLED WITH
|
||||||
|
2.6.9
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
web: bin/rails server -p 3000
|
||||||
|
vite: bin/vite dev
|
||||||
@@ -4,21 +4,34 @@ Eine browserbasierte Tabellenkalkulation nach dem Vorbild von **Apple Numbers**.
|
|||||||
|
|
||||||
1. **Handwerk** — eine von Grund auf selbst geschriebene, schlanke Formel-Engine
|
1. **Handwerk** — eine von Grund auf selbst geschriebene, schlanke Formel-Engine
|
||||||
(Tokenizer, Parser, AST, Abhängigkeitsgraph, Neuberechnung, Zirkelbezug-
|
(Tokenizer, Parser, AST, Abhängigkeitsgraph, Neuberechnung, Zirkelbezug-
|
||||||
Erkennung). Dokumentiert in [`src/engine/README.md`](src/engine/README.md).
|
Erkennung). Dokumentiert in [`app/frontend/engine/README.md`](app/frontend/engine/README.md).
|
||||||
2. **Produkt** — eine polierte, Numbers-nahe Oberfläche: jedes Blatt ist eine
|
2. **Produkt** — eine polierte, Numbers-nahe Oberfläche: jedes Blatt ist eine
|
||||||
**freie Fläche (Canvas)**, auf die man beliebig viele **Tabellen- und
|
**freie Fläche (Canvas)**, auf die man beliebig viele **Tabellen- und
|
||||||
Diagramm-Objekte** setzt, verschiebt, skaliert und befüllt.
|
Diagramm-Objekte** setzt, verschiebt, skaliert und befüllt.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm install
|
bin/setup # Gems, npm-Pakete, Datenbank (PostgreSQL) anlegen
|
||||||
npm run dev # Vite-Devserver
|
bin/dev # Rails (Port 3000) + Vite-Devserver mit HMR (Port 3036)
|
||||||
npm run test # Vitest-Suite (Engine + Store + IO), 570 Tests
|
npm run test # Vitest-Suite (Engine + Store + IO)
|
||||||
npm run build # Typecheck + Produktionsbuild
|
bin/rails test # Server-Tests (Models, API, Anmeldung, Teilen)
|
||||||
|
npm run build # Typecheck + Produktionsbuild nach public/vite
|
||||||
npm run lint # ESLint (flat config)
|
npm run lint # ESLint (flat config)
|
||||||
```
|
```
|
||||||
|
|
||||||
Alles läuft **rein clientseitig** — kein Server, keine Netzwerk-Requests. Datei-
|
Die App ist eine **Rails-8-Anwendung** mit dem Vue-Frontend unter
|
||||||
I/O über File API und Blob-URLs.
|
[`app/frontend/`](app/frontend/) (eingebunden über `vite_rails`). Ohne Konto
|
||||||
|
bleibt alles **im Browser** (IndexedDB). Mit Konto (Anmeldung per Magic Link,
|
||||||
|
kein Passwort) liegen Dokumente auf dem Server und lassen sich teilen – per
|
||||||
|
Link oder per Einladung, mit den Rollen „ansehen“ und „bearbeiten“. Solange
|
||||||
|
es keine Live-Zusammenarbeit gibt, bearbeitet immer nur ein Browser-Tab ein
|
||||||
|
Server-Dokument; alle anderen sehen es schreibgeschützt und folgen den
|
||||||
|
Änderungen. Details: [`docs/konzept-teilen-und-zusammenarbeit.md`](docs/konzept-teilen-und-zusammenarbeit.md).
|
||||||
|
|
||||||
|
Mails landen in der Entwicklung nicht im Postfach, sondern unter
|
||||||
|
<http://localhost:3000/letter_opener>. Produktion braucht `SMTP_ADDRESS`,
|
||||||
|
`SMTP_USER_NAME`, `SMTP_PASSWORD`, `NUMOLUS_MAIL_FROM`, `NUMOLUS_HOST` und
|
||||||
|
`NUMOLUS_DATABASE_PASSWORD`; Solid Queue, Cache und Cable liegen in derselben
|
||||||
|
Datenbank wie die Daten.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -268,7 +281,7 @@ eingebauter Funktionsname: `=MARGE(Umsatz!B2; Umsatz!C2)`.
|
|||||||
sich automatisch umschreiben ließe, nur ihren Aufruf-Namen.
|
sich automatisch umschreiben ließe, nur ihren Aufruf-Namen.
|
||||||
- Ein Aufruf wird **beim Parsen der aufrufenden Zelle in ihre AST eingesetzt**
|
- Ein Aufruf wird **beim Parsen der aufrufenden Zelle in ihre AST eingesetzt**
|
||||||
(Inlining, keine Laufzeit-Closures) — siehe
|
(Inlining, keine Laufzeit-Closures) — siehe
|
||||||
[`src/engine/userFunctions.ts`](src/engine/userFunctions.ts) und die
|
[`app/frontend/engine/userFunctions.ts`](app/frontend/engine/userFunctions.ts) und die
|
||||||
Architektur-Tabelle unten. Dadurch braucht Evaluator/Graph keine Änderung,
|
Architektur-Tabelle unten. Dadurch braucht Evaluator/Graph keine Änderung,
|
||||||
und auch Zellen, die *nur* im Funktionskörper referenziert werden (nicht im
|
und auch Zellen, die *nur* im Funktionskörper referenziert werden (nicht im
|
||||||
Aufruf), lösen bei Änderung korrekt eine Neuberechnung aus.
|
Aufruf), lösen bei Änderung korrekt eine Neuberechnung aus.
|
||||||
@@ -336,7 +349,7 @@ berechenbar ist. Eine `FOR`-Schleife über insgesamt mehr als 10.000 Zellen
|
|||||||
Funktion, deren Ausführung endet, ohne dass `RETURN` je gefeuert hat,
|
Funktion, deren Ausführung endet, ohne dass `RETURN` je gefeuert hat,
|
||||||
ebenso — das Formular warnt davor zusätzlich schon **beim Speichern**, wenn
|
ebenso — das Formular warnt davor zusätzlich schon **beim Speichern**, wenn
|
||||||
nicht erkennbar jeder Codepfad ein `RETURN` erreicht. Details:
|
nicht erkennbar jeder Codepfad ein `RETURN` erreicht. Details:
|
||||||
[`src/engine/README.md`](src/engine/README.md)s „Schleifen"-Abschnitt.
|
[`app/frontend/engine/README.md`](app/frontend/engine/README.md)s „Schleifen"-Abschnitt.
|
||||||
|
|
||||||
#### SUMIF/COUNTIF/AVERAGEIF nachbauen
|
#### SUMIF/COUNTIF/AVERAGEIF nachbauen
|
||||||
|
|
||||||
@@ -644,7 +657,7 @@ unabhängig von der Locale), **Zell-Textfarbe**,
|
|||||||
### Einstellungen (⚙, links neben dem Titel)
|
### Einstellungen (⚙, links neben dem Titel)
|
||||||
|
|
||||||
Ein Zahnrad neben der Überschrift öffnet eine linke Inspector-Schublade
|
Ein Zahnrad neben der Überschrift öffnet eine linke Inspector-Schublade
|
||||||
([`SettingsPanel.vue`](src/components/SettingsPanel.vue)), die die Fläche zur
|
([`SettingsPanel.vue`](app/frontend/components/SettingsPanel.vue)), die die Fläche zur
|
||||||
Seite schiebt (auf schmalen Viewports überlagert sie). Sie bündelt alle
|
Seite schiebt (auf schmalen Viewports überlagert sie). Sie bündelt alle
|
||||||
wichtigen Punkte:
|
wichtigen Punkte:
|
||||||
|
|
||||||
@@ -1034,7 +1047,7 @@ Fehlercode nötig).
|
|||||||
|
|
||||||
## Architektur der Engine-Module
|
## Architektur der Engine-Module
|
||||||
|
|
||||||
Framework-agnostisches ES-Modul unter [`src/engine/`](src/engine/) — **keine**
|
Framework-agnostisches ES-Modul unter [`app/frontend/engine/`](app/frontend/engine/) — **keine**
|
||||||
Formel-/Spreadsheet-Library. Kennt kein Vue, kein DOM. Die adressierbare Einheit
|
Formel-/Spreadsheet-Library. Kennt kein Vue, kein DOM. Die adressierbare Einheit
|
||||||
ist eine **Tabelle**; der Abhängigkeitsgraph läuft dokumentweit über alle
|
ist eine **Tabelle**; der Abhängigkeitsgraph läuft dokumentweit über alle
|
||||||
Tabellen aller Blätter.
|
Tabellen aller Blätter.
|
||||||
@@ -1054,7 +1067,7 @@ Tabellen aller Blätter.
|
|||||||
`graph.topoOrder()` → Zyklus-Zellen `#CIRC`, Rest topologisch auswerten, nicht
|
`graph.topoOrder()` → Zyklus-Zellen `#CIRC`, Rest topologisch auswerten, nicht
|
||||||
betroffene Zellen behalten ihren Wert.
|
betroffene Zellen behalten ihren Wert.
|
||||||
|
|
||||||
**Anbindung an Vue.** [`src/store/document.ts`](src/store/document.ts) ist das
|
**Anbindung an Vue.** [`app/frontend/store/document.ts`](app/frontend/store/document.ts) ist das
|
||||||
„eigene `reactive()`-Modul": es hält eine `Workbook`-Instanz und einen
|
„eigene `reactive()`-Modul": es hält eine `Workbook`-Instanz und einen
|
||||||
`recalcTick`-Ref. Die UI liest `store.cellValue(...)`, das `recalcTick` berührt —
|
`recalcTick`-Ref. Die UI liest `store.cellValue(...)`, das `recalcTick` berührt —
|
||||||
nach jeder Neuberechnung wird der Tick erhöht, jede Formelzelle re-rendert also
|
nach jeder Neuberechnung wird der Tick erhöht, jede Formelzelle re-rendert also
|
||||||
@@ -1123,7 +1136,7 @@ Blatt **„Analyse"** mit drei Tabellen-Objekten (**Umsatz**, **Kosten**,
|
|||||||
**Übersicht**) und einem **Diagramm** (Umsatz je Region), plus ein leeres Blatt
|
**Übersicht**) und einem **Diagramm** (Umsatz je Region), plus ein leeres Blatt
|
||||||
**„Blatt 2"**. „Übersicht" demonstriert tabellenübergreifende Bezüge und die vier
|
**„Blatt 2"**. „Übersicht" demonstriert tabellenübergreifende Bezüge und die vier
|
||||||
Grundrechenarten: `Übersicht!B2 = =SUM(Umsatz!B2:E2)`, `D2 = =B2-C2`,
|
Grundrechenarten: `Übersicht!B2 = =SUM(Umsatz!B2:E2)`, `D2 = =B2-C2`,
|
||||||
`E2 = =D2/B2*100`. Siehe [`src/data/seed.ts`](src/data/seed.ts).
|
`E2 = =D2/B2*100`. Siehe [`app/frontend/data/seed.ts`](app/frontend/data/seed.ts).
|
||||||
|
|
||||||
> **Hinweis zur Aufgabenstellung:** Die Akzeptanzliste nennt „Übersicht!B2 =
|
> **Hinweis zur Aufgabenstellung:** Die Akzeptanzliste nennt „Übersicht!B2 =
|
||||||
> 6070". Die explizit angegebene Nord-Zeile ergibt jedoch
|
> 6070". Die explizit angegebene Nord-Zeile ergibt jedoch
|
||||||
@@ -1498,7 +1511,7 @@ Grundrechenarten: `Übersicht!B2 = =SUM(Umsatz!B2:E2)`, `D2 = =B2-C2`,
|
|||||||
## Projektstruktur
|
## Projektstruktur
|
||||||
|
|
||||||
```
|
```
|
||||||
src/
|
app/frontend/
|
||||||
engine/ # Formel-Engine (Eigenbau) + Unit-Tests
|
engine/ # Formel-Engine (Eigenbau) + Unit-Tests
|
||||||
store/ # reaktiver Dokument-Store (v2 Canvas-Modell), Formatierung
|
store/ # reaktiver Dokument-Store (v2 Canvas-Modell), Formatierung
|
||||||
io/ # csv / json (Eigenbau, inkl. v1→v2), num (ZIP via fflate, lazy), xlsx (SheetJS, lazy)
|
io/ # csv / json (Eigenbau, inkl. v1→v2), num (ZIP via fflate, lazy), xlsx (SheetJS, lazy)
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
# Add your own tasks in files placed in lib/tasks ending in .rake,
|
||||||
|
# for example lib/tasks/capistrano.rake, and they will automatically be available to Rake.
|
||||||
|
|
||||||
|
require_relative "config/application"
|
||||||
|
|
||||||
|
Rails.application.load_tasks
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
module Api
|
||||||
|
class AuthController < BaseController
|
||||||
|
rate_limit to: 20, within: 1.hour, only: :magic_link, with: -> { too_many }
|
||||||
|
rate_limit to: 30, within: 15.minutes, only: :code, with: -> { too_many }
|
||||||
|
|
||||||
|
# POST /api/auth/magic_link { email, return_to }
|
||||||
|
# Always the same answer, so it can't be used to find out who has an account.
|
||||||
|
def magic_link
|
||||||
|
email = params.require(:email).to_s
|
||||||
|
unless MagicLink.throttled?(email)
|
||||||
|
link = MagicLink.issue!(email:, return_to: params[:return_to])
|
||||||
|
AuthMailer.magic_link(link, link.token, link.code).deliver_later
|
||||||
|
end
|
||||||
|
render json: { ok: true }
|
||||||
|
rescue ActiveRecord::RecordInvalid
|
||||||
|
render json: { error: "invalid_email" }, status: :unprocessable_entity
|
||||||
|
end
|
||||||
|
|
||||||
|
# POST /api/auth/code { email, code } -- the 6-digit code from the mail
|
||||||
|
def code
|
||||||
|
link = MagicLink.find_by_code(params.require(:email), params.require(:code))
|
||||||
|
return render(json: { error: "invalid_code" }, status: :unprocessable_entity) unless link
|
||||||
|
start_session_for(link.redeem!)
|
||||||
|
render json: { user: Current.user.as_json, redirect: link.destination }
|
||||||
|
end
|
||||||
|
|
||||||
|
# DELETE /api/auth/session
|
||||||
|
def logout
|
||||||
|
terminate_session
|
||||||
|
render json: { ok: true }
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def too_many = render(json: { error: "rate_limited" }, status: :too_many_requests)
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
module Api
|
||||||
|
class BaseController < ApplicationController
|
||||||
|
# the app sends the CSRF token from the page's meta tag as X-CSRF-Token
|
||||||
|
protect_from_forgery with: :exception
|
||||||
|
|
||||||
|
rescue_from ActiveRecord::RecordNotFound do
|
||||||
|
render json: { error: "not_found" }, status: :not_found
|
||||||
|
end
|
||||||
|
rescue_from ActiveRecord::RecordInvalid do |e|
|
||||||
|
render json: { error: "invalid", messages: e.record.errors.full_messages }, status: :unprocessable_entity
|
||||||
|
end
|
||||||
|
rescue_from ActionController::InvalidAuthenticityToken do
|
||||||
|
render json: { error: "csrf" }, status: :unprocessable_entity
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
# a share link token, for visitors (with or without account) who got the link
|
||||||
|
def share_token = request.headers["X-Share-Token"].presence
|
||||||
|
|
||||||
|
# identifies the lock holder: one browser tab
|
||||||
|
def client_id = request.headers["X-Client-Id"].to_s[0, 64].presence || "anon"
|
||||||
|
|
||||||
|
def lock_holder = "#{Current.user&.id || 'anon'}:#{client_id}"
|
||||||
|
|
||||||
|
def holder_name = Current.user&.name || "Jemand mit dem Link"
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
module Api
|
||||||
|
# The edit lock of a server document: one browser tab edits at a time,
|
||||||
|
# everyone else sees the document read-only until the lock is released or
|
||||||
|
# expires (Document::LOCK_TTL without a heartbeat).
|
||||||
|
class DocumentLocksController < BaseController
|
||||||
|
before_action :load_document
|
||||||
|
|
||||||
|
# POST /api/documents/:id/lock -- take or refresh (heartbeat)
|
||||||
|
def create
|
||||||
|
return render(json: { lock: { mine: false, holder_name: nil } }) unless Document.role_at_least?(@role, :editor)
|
||||||
|
mine = @doc.acquire_lock!(lock_holder, holder_name)
|
||||||
|
render json: { lock: { mine:, holder_name: (@doc.lock_holder_name unless mine) } }
|
||||||
|
end
|
||||||
|
|
||||||
|
# DELETE /api/documents/:id/lock
|
||||||
|
def destroy
|
||||||
|
@doc.release_lock!(lock_holder)
|
||||||
|
head :no_content
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def load_document
|
||||||
|
@doc = Document.find(params[:document_id])
|
||||||
|
@role = @doc.role_for(user: Current.user, token: share_token)
|
||||||
|
raise ActiveRecord::RecordNotFound unless @role
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
module Api
|
||||||
|
# Server documents. Listing and creating need an account; reading and
|
||||||
|
# writing a single document also work with just a share link token.
|
||||||
|
class DocumentsController < BaseController
|
||||||
|
before_action :require_authentication, only: %i[index create]
|
||||||
|
before_action :load_document, except: %i[index create]
|
||||||
|
|
||||||
|
# GET /api/documents -- owned + shared with me
|
||||||
|
def index
|
||||||
|
user = Current.user
|
||||||
|
memberships = user.document_memberships.index_by(&:document_id)
|
||||||
|
docs = user.accessible_documents.includes(:owner, :updated_by).order(content_updated_at: :desc)
|
||||||
|
render json: {
|
||||||
|
documents: docs.map { |d| d.meta_json(role: d.owner_id == user.id ? :owner : memberships[d.id]&.role) }
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
# POST /api/documents { title, content } -- also used to upload a local document
|
||||||
|
def create
|
||||||
|
doc = Current.user.owned_documents.create!(
|
||||||
|
title: params.require(:title), content: content_param, updated_by: Current.user
|
||||||
|
)
|
||||||
|
render json: { document: doc.meta_json(role: :owner) }, status: :created
|
||||||
|
end
|
||||||
|
|
||||||
|
# GET /api/documents/:id[?known_version=n]
|
||||||
|
# With known_version: just the metadata when nothing changed since.
|
||||||
|
def show
|
||||||
|
touch_membership
|
||||||
|
meta = @doc.meta_json(role: @role).merge(lock: @doc.lock_info(lock_holder))
|
||||||
|
if params[:known_version].present? && params[:known_version].to_i == @doc.version
|
||||||
|
render json: { document: meta, unchanged: true }
|
||||||
|
else
|
||||||
|
render json: { document: meta.merge(content: @doc.content) }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# PATCH /api/documents/:id { title } and/or { content, base_version }
|
||||||
|
def update
|
||||||
|
return forbidden unless Document.role_at_least?(@role, :editor)
|
||||||
|
if params.key?(:content)
|
||||||
|
return render(json: { error: "locked", holder_name: @doc.lock_holder_name }, status: :locked) if @doc.locked_by_other?(lock_holder)
|
||||||
|
@doc.with_lock do
|
||||||
|
if params.require(:base_version).to_i != @doc.version
|
||||||
|
return render(json: { error: "conflict", version: @doc.version }, status: :conflict)
|
||||||
|
end
|
||||||
|
@doc.update!(content: content_param, version: @doc.version + 1,
|
||||||
|
content_updated_at: Time.current, updated_by: Current.user)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
@doc.update!(title: params[:title]) if params[:title].present?
|
||||||
|
render json: { document: @doc.meta_json(role: @role) }
|
||||||
|
end
|
||||||
|
|
||||||
|
# DELETE /api/documents/:id -- the owner deletes it; anyone else leaves it
|
||||||
|
def destroy
|
||||||
|
if @role == :owner
|
||||||
|
@doc.destroy!
|
||||||
|
elsif Current.user
|
||||||
|
@doc.document_memberships.where(user: Current.user).delete_all
|
||||||
|
else
|
||||||
|
return forbidden
|
||||||
|
end
|
||||||
|
head :no_content
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def load_document
|
||||||
|
@doc = Document.find(params[:id])
|
||||||
|
@role = @doc.role_for(user: Current.user, token: share_token)
|
||||||
|
raise ActiveRecord::RecordNotFound unless @role # 404, not 403: don't confirm it exists
|
||||||
|
end
|
||||||
|
|
||||||
|
def content_param
|
||||||
|
c = params.require(:content)
|
||||||
|
c.is_a?(String) ? c : c.to_unsafe_h.to_json
|
||||||
|
end
|
||||||
|
|
||||||
|
def touch_membership
|
||||||
|
return unless Current.user
|
||||||
|
@doc.document_memberships.where(user: Current.user).update_all(last_opened_at: Time.current)
|
||||||
|
end
|
||||||
|
|
||||||
|
def forbidden = render(json: { error: "forbidden" }, status: :forbidden)
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
module Api
|
||||||
|
# POST /api/documents/:document_id/join { token }
|
||||||
|
# Redeems a share link. Signed in: the document is added to the account
|
||||||
|
# (a membership). Not signed in: only answers what the token grants -- the
|
||||||
|
# browser keeps the token and sends it along (X-Share-Token).
|
||||||
|
class JoinsController < BaseController
|
||||||
|
def create
|
||||||
|
doc = Document.find(params[:document_id])
|
||||||
|
link = doc.share_links.active.find_by(token: params.require(:token))
|
||||||
|
raise ActiveRecord::RecordNotFound unless link
|
||||||
|
if Current.user
|
||||||
|
DocumentMembership.grant!(document: doc, user: Current.user, role: link.role, source: :link, share_link: link)
|
||||||
|
end
|
||||||
|
render json: { document: doc.meta_json(role: doc.role_for(user: Current.user, token: link.token)) }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
module Api
|
||||||
|
class MeController < BaseController
|
||||||
|
before_action :require_authentication, only: :update
|
||||||
|
|
||||||
|
# GET /api/me
|
||||||
|
def show
|
||||||
|
render json: { user: Current.user&.as_json }
|
||||||
|
end
|
||||||
|
|
||||||
|
# PATCH /api/me { display_name }
|
||||||
|
def update
|
||||||
|
Current.user.update!(params.permit(:display_name))
|
||||||
|
render json: { user: Current.user.as_json }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
module Api
|
||||||
|
# Everything about who has access to a document. Owner only.
|
||||||
|
class SharesController < BaseController
|
||||||
|
before_action :require_authentication
|
||||||
|
before_action :load_owned_document
|
||||||
|
|
||||||
|
# GET /api/documents/:document_id/share
|
||||||
|
def show
|
||||||
|
render json: share_json
|
||||||
|
end
|
||||||
|
|
||||||
|
# POST /api/documents/:document_id/share/links { role }
|
||||||
|
# One active link per role: creating one replaces (revokes) the previous.
|
||||||
|
def create_link
|
||||||
|
role = params.require(:role)
|
||||||
|
return render(json: { error: "invalid_role" }, status: :unprocessable_entity) unless ShareLink.roles.key?(role)
|
||||||
|
@doc.transaction do
|
||||||
|
@doc.share_links.active.where(role:).find_each(&:revoke!)
|
||||||
|
@doc.share_links.create!(role:, created_by: Current.user)
|
||||||
|
end
|
||||||
|
render json: share_json
|
||||||
|
end
|
||||||
|
|
||||||
|
# DELETE /api/documents/:document_id/share/links/:id
|
||||||
|
def revoke_link
|
||||||
|
@doc.share_links.active.find(params[:id]).revoke!
|
||||||
|
render json: share_json
|
||||||
|
end
|
||||||
|
|
||||||
|
# POST /api/documents/:document_id/share/invitations { email, role }
|
||||||
|
def invite
|
||||||
|
role = params.require(:role)
|
||||||
|
return render(json: { error: "invalid_role" }, status: :unprocessable_entity) unless MagicLink.roles.key?(role)
|
||||||
|
link = MagicLink.issue!(email: params.require(:email), purpose: :invitation, document: @doc, role:, invited_by: Current.user)
|
||||||
|
if link.email == Current.user.email
|
||||||
|
link.destroy!
|
||||||
|
return render(json: { error: "self_invite" }, status: :unprocessable_entity)
|
||||||
|
end
|
||||||
|
AuthMailer.invitation(link, link.token).deliver_later
|
||||||
|
render json: share_json
|
||||||
|
end
|
||||||
|
|
||||||
|
# PATCH /api/documents/:document_id/share/members/:user_id { role }
|
||||||
|
def update_member
|
||||||
|
m = @doc.document_memberships.find_by!(user_id: params[:user_id])
|
||||||
|
m.update!(role: params.require(:role))
|
||||||
|
render json: share_json
|
||||||
|
end
|
||||||
|
|
||||||
|
# DELETE /api/documents/:document_id/share/members/:user_id
|
||||||
|
def remove_member
|
||||||
|
@doc.document_memberships.where(user_id: params[:user_id]).delete_all
|
||||||
|
render json: share_json
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def load_owned_document
|
||||||
|
@doc = Current.user.owned_documents.find(params[:document_id])
|
||||||
|
end
|
||||||
|
|
||||||
|
def share_json
|
||||||
|
{
|
||||||
|
links: @doc.share_links.active.order(:created_at).map(&:as_json),
|
||||||
|
members: @doc.document_memberships.includes(:user).order(:created_at).map do |m|
|
||||||
|
{ user_id: m.user_id, name: m.user.name, email: m.user.email, role: m.role, source: m.source }
|
||||||
|
end,
|
||||||
|
invitations: MagicLink.usable.invitation.where(document: @doc).order(:created_at).map do |i|
|
||||||
|
{ email: i.email, role: i.role, expires_at: i.expires_at.iso8601 }
|
||||||
|
end
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
class ApplicationController < ActionController::Base
|
||||||
|
include Authentication
|
||||||
|
|
||||||
|
# Only allow modern browsers supporting webp images, web push, badges, import maps, CSS nesting, and CSS :has.
|
||||||
|
allow_browser versions: :modern
|
||||||
|
end
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# The page behind the link in a sign-in / invitation mail. Opening the link
|
||||||
|
# (GET) only shows a button; the sign-in happens on POST. Mail scanners
|
||||||
|
# fetch links on their own and would otherwise use up the one-time token.
|
||||||
|
class AuthController < ApplicationController
|
||||||
|
layout "auth"
|
||||||
|
rate_limit to: 30, within: 15.minutes, only: :create
|
||||||
|
|
||||||
|
def show
|
||||||
|
@token = params[:token].to_s
|
||||||
|
@link = MagicLink.find_by_token(@token)
|
||||||
|
render :invalid, status: :not_found unless @link
|
||||||
|
end
|
||||||
|
|
||||||
|
def create
|
||||||
|
link = MagicLink.find_by_token(params[:token])
|
||||||
|
return render(:invalid, status: :not_found) unless link
|
||||||
|
start_session_for(link.redeem!)
|
||||||
|
redirect_to link.destination
|
||||||
|
rescue ActiveRecord::RecordNotFound
|
||||||
|
render :invalid, status: :not_found
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# Cookie session (signed session id), resumed on every request. Everything
|
||||||
|
# is usable without signing in; controllers call `require_authentication`
|
||||||
|
# where an account is needed.
|
||||||
|
module Authentication
|
||||||
|
extend ActiveSupport::Concern
|
||||||
|
|
||||||
|
included do
|
||||||
|
before_action :resume_session
|
||||||
|
helper_method :signed_in?
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def signed_in? = Current.user.present?
|
||||||
|
|
||||||
|
def resume_session
|
||||||
|
Current.session = Session.find_by(id: cookies.signed[:session_id]) if cookies.signed[:session_id]
|
||||||
|
touch_session if Current.session
|
||||||
|
end
|
||||||
|
|
||||||
|
def touch_session
|
||||||
|
s = Current.session
|
||||||
|
return if s.last_active_at && s.last_active_at > 1.hour.ago
|
||||||
|
s.update_columns(last_active_at: Time.current)
|
||||||
|
s.user.update_columns(last_seen_at: Time.current)
|
||||||
|
end
|
||||||
|
|
||||||
|
def require_authentication
|
||||||
|
render json: { error: "unauthenticated" }, status: :unauthorized unless signed_in?
|
||||||
|
end
|
||||||
|
|
||||||
|
def start_session_for(user)
|
||||||
|
reset_session
|
||||||
|
Current.session = user.sessions.create!(user_agent: request.user_agent, ip_address: request.remote_ip, last_active_at: Time.current)
|
||||||
|
cookies.signed.permanent[:session_id] = { value: Current.session.id, httponly: true, same_site: :lax, secure: Rails.env.production? }
|
||||||
|
end
|
||||||
|
|
||||||
|
def terminate_session
|
||||||
|
Current.session&.destroy
|
||||||
|
Current.session = nil
|
||||||
|
cookies.delete(:session_id)
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# Serves the Vue app for every client-side route (/, /d/<id>). The signed-in
|
||||||
|
# user is embedded so the app knows it before its first render.
|
||||||
|
class SpaController < ApplicationController
|
||||||
|
layout false
|
||||||
|
|
||||||
|
def show
|
||||||
|
@boot = { user: Current.user&.as_json }
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -8,7 +8,11 @@ import CustomFunctionsModal from './CustomFunctionsModal.vue';
|
|||||||
import NamedRangesModal from './NamedRangesModal.vue';
|
import NamedRangesModal from './NamedRangesModal.vue';
|
||||||
import MacrosModal from './MacrosModal.vue';
|
import MacrosModal from './MacrosModal.vue';
|
||||||
import DocumentsModal from './DocumentsModal.vue';
|
import DocumentsModal from './DocumentsModal.vue';
|
||||||
|
import LoginModal from './LoginModal.vue';
|
||||||
|
import ShareModal from './ShareModal.vue';
|
||||||
|
import UploadModal from './UploadModal.vue';
|
||||||
import { useLibrary } from '../store/library';
|
import { useLibrary } from '../store/library';
|
||||||
|
import { useSession } from '../store/session';
|
||||||
import { parseDocument } from '../io/json';
|
import { parseDocument } from '../io/json';
|
||||||
import TheLogo from './TheLogo.vue';
|
import TheLogo from './TheLogo.vue';
|
||||||
import ToolbarIcon, { type ToolbarIconName } from './ToolbarIcon.vue';
|
import ToolbarIcon, { type ToolbarIconName } from './ToolbarIcon.vue';
|
||||||
@@ -32,6 +36,7 @@ import type { Delimiter } from '../io/csv';
|
|||||||
|
|
||||||
const store = useDocument();
|
const store = useDocument();
|
||||||
const library = useLibrary();
|
const library = useLibrary();
|
||||||
|
const session = useSession();
|
||||||
const { mode: themeMode, cycle: cycleTheme } = useTheme();
|
const { mode: themeMode, cycle: cycleTheme } = useTheme();
|
||||||
const toasts = useToasts();
|
const toasts = useToasts();
|
||||||
|
|
||||||
@@ -110,15 +115,48 @@ const functionsOpen = ref(false);
|
|||||||
const namedRangesOpen = ref(false);
|
const namedRangesOpen = ref(false);
|
||||||
const macrosOpen = ref(false);
|
const macrosOpen = ref(false);
|
||||||
const documentsOpen = ref(false);
|
const documentsOpen = ref(false);
|
||||||
|
const shareOpen = ref(false);
|
||||||
|
const uploadOpen = ref(false);
|
||||||
|
const loginOpen = ref(false);
|
||||||
|
const loginReason = ref<string | undefined>();
|
||||||
|
const accountMenuOpen = ref(false);
|
||||||
|
|
||||||
|
function openLogin(reason?: string) {
|
||||||
|
loginReason.value = reason;
|
||||||
|
documentsOpen.value = false;
|
||||||
|
shareOpen.value = false;
|
||||||
|
loginOpen.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// after signing in: offer once (per account, per browser) to move this
|
||||||
|
// browser's documents into the account
|
||||||
|
onMounted(() => {
|
||||||
|
const user = session.state.user;
|
||||||
|
if (!user || !library.state.docs.length) return;
|
||||||
|
const key = `numolus:upload-asked:${user.id}`;
|
||||||
|
try {
|
||||||
|
if (localStorage.getItem(key)) return;
|
||||||
|
localStorage.setItem(key, '1');
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
uploadOpen.value = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
const statusNote = computed(() => {
|
||||||
|
if (library.state.syncState === 'offline') return 'Offline – wird später gespeichert';
|
||||||
|
if (library.state.syncState === 'saving') return 'Speichert …';
|
||||||
|
return '';
|
||||||
|
});
|
||||||
|
|
||||||
// ---- document title (inline rename) ------------------------------------
|
// ---- document title (inline rename) ------------------------------------
|
||||||
const titleDraft = ref(library.currentTitle.value);
|
const titleDraft = ref(library.currentTitle.value);
|
||||||
watch(library.currentTitle, (t) => (titleDraft.value = t));
|
watch(library.currentTitle, (t) => (titleDraft.value = t));
|
||||||
function commitTitle() {
|
function commitTitle() {
|
||||||
const id = library.state.currentId;
|
const ref = library.state.current;
|
||||||
const t = titleDraft.value.trim();
|
const t = titleDraft.value.trim();
|
||||||
if (!id || !t) titleDraft.value = library.currentTitle.value;
|
if (!ref || !t) titleDraft.value = library.currentTitle.value;
|
||||||
else if (t !== library.currentTitle.value) void library.rename(id, t);
|
else if (t !== library.currentTitle.value) void library.rename(ref, t);
|
||||||
}
|
}
|
||||||
const busy = ref(false);
|
const busy = ref(false);
|
||||||
const fileInput = ref<HTMLInputElement | null>(null);
|
const fileInput = ref<HTMLInputElement | null>(null);
|
||||||
@@ -323,6 +361,7 @@ async function onSample() {
|
|||||||
title="Dokumentname (klicken zum Umbenennen)"
|
title="Dokumentname (klicken zum Umbenennen)"
|
||||||
aria-label="Dokumentname"
|
aria-label="Dokumentname"
|
||||||
:size="Math.max(8, Math.min(28, titleDraft.length + 1))"
|
:size="Math.max(8, Math.min(28, titleDraft.length + 1))"
|
||||||
|
:readonly="library.currentRole.value === 'viewer'"
|
||||||
@keydown.enter.prevent="($event.target as HTMLInputElement).blur()"
|
@keydown.enter.prevent="($event.target as HTMLInputElement).blur()"
|
||||||
@keydown.esc.prevent="titleDraft = library.currentTitle.value; ($event.target as HTMLInputElement).blur()"
|
@keydown.esc.prevent="titleDraft = library.currentTitle.value; ($event.target as HTMLInputElement).blur()"
|
||||||
@blur="commitTitle()"
|
@blur="commitTitle()"
|
||||||
@@ -400,8 +439,15 @@ async function onSample() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<span v-if="store.ui.readOnly" class="ro-badge" :title="store.ui.readOnly">
|
||||||
|
{{ library.state.lockedBy ? `${library.state.lockedBy} bearbeitet` : 'Nur Lesen' }}
|
||||||
|
</span>
|
||||||
|
<span v-else-if="statusNote" class="status-note">{{ statusNote }}</span>
|
||||||
|
|
||||||
<div class="spacer" />
|
<div class="spacer" />
|
||||||
|
|
||||||
|
<button class="btn share" title="Dokument teilen" @click="shareOpen = true">Teilen</button>
|
||||||
|
|
||||||
<div ref="fileMenuWrap" class="menu-wrap">
|
<div ref="fileMenuWrap" class="menu-wrap">
|
||||||
<button class="btn" @click="fileMenuOpen = !fileMenuOpen">Datei ▾</button>
|
<button class="btn" @click="fileMenuOpen = !fileMenuOpen">Datei ▾</button>
|
||||||
<ul v-if="fileMenuOpen" class="menu" @click="fileMenuOpen = false">
|
<ul v-if="fileMenuOpen" class="menu" @click="fileMenuOpen = false">
|
||||||
@@ -430,6 +476,26 @@ async function onSample() {
|
|||||||
<ToolbarIcon :name="themeIcon" />
|
<ToolbarIcon :name="themeIcon" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<div class="menu-wrap">
|
||||||
|
<button
|
||||||
|
v-if="session.state.user"
|
||||||
|
class="btn account"
|
||||||
|
:title="session.state.user.email"
|
||||||
|
@click="accountMenuOpen = !accountMenuOpen"
|
||||||
|
@blur="accountMenuOpen = false"
|
||||||
|
>
|
||||||
|
<span class="avatar" :style="{ background: session.state.user.color }">{{ session.state.user.name.slice(0, 1).toUpperCase() }}</span>
|
||||||
|
{{ session.state.user.name }} ▾
|
||||||
|
</button>
|
||||||
|
<button v-else class="btn" @click="openLogin()">Anmelden</button>
|
||||||
|
<ul v-if="accountMenuOpen" class="menu" @mousedown.prevent>
|
||||||
|
<li class="menu-note">{{ session.state.user?.email }}</li>
|
||||||
|
<li><button @click="accountMenuOpen = false; documentsOpen = true">Dokumente …</button></li>
|
||||||
|
<li class="sep" />
|
||||||
|
<li><button @click="session.logout()">Abmelden</button></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
<input ref="fileInput" type="file" accept=".num,.json,.csv,.xlsx" hidden @change="onFileChosen" />
|
<input ref="fileInput" type="file" accept=".num,.json,.csv,.xlsx" hidden @change="onFileChosen" />
|
||||||
<span v-if="busy" class="busy">Verarbeite …</span>
|
<span v-if="busy" class="busy">Verarbeite …</span>
|
||||||
</header>
|
</header>
|
||||||
@@ -507,7 +573,14 @@ async function onSample() {
|
|||||||
<CustomFunctionsModal :open="functionsOpen" @close="functionsOpen = false" />
|
<CustomFunctionsModal :open="functionsOpen" @close="functionsOpen = false" />
|
||||||
<NamedRangesModal :open="namedRangesOpen" @close="namedRangesOpen = false" />
|
<NamedRangesModal :open="namedRangesOpen" @close="namedRangesOpen = false" />
|
||||||
<MacrosModal :open="macrosOpen" @close="macrosOpen = false" />
|
<MacrosModal :open="macrosOpen" @close="macrosOpen = false" />
|
||||||
<DocumentsModal :open="documentsOpen" @close="documentsOpen = false" />
|
<DocumentsModal :open="documentsOpen" @close="documentsOpen = false" @login="openLogin()" />
|
||||||
|
<ShareModal
|
||||||
|
:open="shareOpen"
|
||||||
|
@close="shareOpen = false"
|
||||||
|
@login="openLogin('Zum Teilen brauchst du ein Konto.')"
|
||||||
|
/>
|
||||||
|
<LoginModal :open="loginOpen" :reason="loginReason" @close="loginOpen = false" />
|
||||||
|
<UploadModal :open="uploadOpen" @close="uploadOpen = false" />
|
||||||
|
|
||||||
<!-- Über / Impressum -->
|
<!-- Über / Impressum -->
|
||||||
<BaseModal :open="aboutOpen" title="Über Numolus" @close="aboutOpen = false">
|
<BaseModal :open="aboutOpen" title="Über Numolus" @close="aboutOpen = false">
|
||||||
@@ -661,6 +734,42 @@ async function onSample() {
|
|||||||
.btn-link:hover {
|
.btn-link:hover {
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
}
|
}
|
||||||
|
.ro-badge,
|
||||||
|
.status-note {
|
||||||
|
font-size: 12px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.ro-badge {
|
||||||
|
padding: 3px 9px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--accent-weak);
|
||||||
|
color: var(--accent);
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: help;
|
||||||
|
}
|
||||||
|
.status-note {
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
.account {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.avatar {
|
||||||
|
display: inline-grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 50%;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.menu-note {
|
||||||
|
padding: 6px 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
.doc-title {
|
.doc-title {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
max-width: 240px;
|
max-width: 240px;
|
||||||
@@ -0,0 +1,322 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* "Dokumente" -- every document the user can open, in up to three groups:
|
||||||
|
* their account's documents (server), documents shared with them (server:
|
||||||
|
* invitations, links), and documents stored only in this browser
|
||||||
|
* (IndexedDB). Open, rename, duplicate, delete/leave, upload, create.
|
||||||
|
*/
|
||||||
|
import { computed, nextTick, ref, watch } from 'vue';
|
||||||
|
import { useLibrary, type DocRef } from '../store/library';
|
||||||
|
import { useSession } from '../store/session';
|
||||||
|
import BaseModal from './BaseModal.vue';
|
||||||
|
|
||||||
|
const props = defineProps<{ open: boolean }>();
|
||||||
|
const emit = defineEmits<{ (e: 'close'): void; (e: 'login'): void }>();
|
||||||
|
|
||||||
|
const library = useLibrary();
|
||||||
|
const session = useSession();
|
||||||
|
|
||||||
|
interface Row {
|
||||||
|
ref: DocRef;
|
||||||
|
title: string;
|
||||||
|
updatedAt: number;
|
||||||
|
note: string;
|
||||||
|
canRename: boolean;
|
||||||
|
canDuplicate: boolean;
|
||||||
|
removeLabel: string;
|
||||||
|
canUpload: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ROLE_NOTE = { owner: '', editor: 'bearbeiten', viewer: 'nur lesen' } as const;
|
||||||
|
|
||||||
|
const groups = computed(() => {
|
||||||
|
const own: Row[] = [];
|
||||||
|
const shared: Row[] = [];
|
||||||
|
for (const d of library.state.serverDocs) {
|
||||||
|
const row: Row = {
|
||||||
|
ref: { source: 'server', id: d.id },
|
||||||
|
title: d.title,
|
||||||
|
updatedAt: Date.parse(d.updated_at),
|
||||||
|
note: d.role === 'owner' ? '' : `von ${d.owner_name} · ${ROLE_NOTE[d.role]}`,
|
||||||
|
canRename: d.role !== 'viewer',
|
||||||
|
canDuplicate: true,
|
||||||
|
removeLabel: d.role === 'owner' ? 'Löschen' : 'Entfernen',
|
||||||
|
canUpload: false,
|
||||||
|
};
|
||||||
|
(d.role === 'owner' ? own : shared).push(row);
|
||||||
|
}
|
||||||
|
for (const l of library.state.linkDocs) {
|
||||||
|
shared.push({
|
||||||
|
ref: { source: 'server', id: l.id },
|
||||||
|
title: l.title,
|
||||||
|
updatedAt: 0,
|
||||||
|
note: `per Link · ${ROLE_NOTE[l.role]}`,
|
||||||
|
canRename: false,
|
||||||
|
canDuplicate: false,
|
||||||
|
removeLabel: 'Entfernen',
|
||||||
|
canUpload: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const local: Row[] = library.state.docs.map((d) => ({
|
||||||
|
ref: { source: 'local', id: d.id },
|
||||||
|
title: d.title,
|
||||||
|
updatedAt: d.updatedAt,
|
||||||
|
note: '',
|
||||||
|
canRename: true,
|
||||||
|
canDuplicate: true,
|
||||||
|
removeLabel: 'Löschen',
|
||||||
|
canUpload: session.signedIn.value,
|
||||||
|
}));
|
||||||
|
return [
|
||||||
|
{ key: 'own', title: 'Mein Konto', rows: own, show: session.signedIn.value },
|
||||||
|
{ key: 'shared', title: 'Mit mir geteilt', rows: shared, show: shared.length > 0 },
|
||||||
|
{
|
||||||
|
key: 'local',
|
||||||
|
title: session.signedIn.value ? 'Nur in diesem Browser' : 'In diesem Browser',
|
||||||
|
rows: local,
|
||||||
|
show: library.state.available && (local.length > 0 || !session.signedIn.value),
|
||||||
|
},
|
||||||
|
].filter((g) => g.show);
|
||||||
|
});
|
||||||
|
|
||||||
|
const isCurrent = (r: DocRef) =>
|
||||||
|
library.state.current?.source === r.source && library.state.current.id === r.id;
|
||||||
|
const keyOf = (r: DocRef) => `${r.source}:${r.id}`;
|
||||||
|
|
||||||
|
const renaming = ref<string | null>(null);
|
||||||
|
const renameValue = ref('');
|
||||||
|
const renameInput = ref<HTMLInputElement[] | null>(null);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.open,
|
||||||
|
(o) => {
|
||||||
|
if (o) {
|
||||||
|
renaming.value = null;
|
||||||
|
void library.refreshServerList();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const fmt = new Intl.DateTimeFormat('de-CH', { dateStyle: 'medium', timeStyle: 'short' });
|
||||||
|
function when(ms: number): string {
|
||||||
|
if (!ms) return '';
|
||||||
|
const diff = Date.now() - ms;
|
||||||
|
if (diff < 60_000) return 'gerade eben';
|
||||||
|
if (diff < 3_600_000) return `vor ${Math.floor(diff / 60_000)} Min.`;
|
||||||
|
return fmt.format(ms);
|
||||||
|
}
|
||||||
|
function subline(r: Row): string {
|
||||||
|
return [isCurrent(r.ref) ? 'geöffnet' : '', r.note, when(r.updatedAt)].filter(Boolean).join(' · ');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openDoc(r: Row) {
|
||||||
|
if (renaming.value) return;
|
||||||
|
if (await library.open(r.ref)) emit('close');
|
||||||
|
}
|
||||||
|
async function startRename(r: Row) {
|
||||||
|
renaming.value = keyOf(r.ref);
|
||||||
|
renameValue.value = r.title;
|
||||||
|
await nextTick();
|
||||||
|
renameInput.value?.[0]?.select();
|
||||||
|
}
|
||||||
|
async function commitRename(r: Row) {
|
||||||
|
if (renaming.value !== keyOf(r.ref)) return;
|
||||||
|
renaming.value = null;
|
||||||
|
if (renameValue.value.trim() && renameValue.value !== r.title) await library.rename(r.ref, renameValue.value);
|
||||||
|
}
|
||||||
|
async function remove(r: Row) {
|
||||||
|
const q =
|
||||||
|
r.removeLabel === 'Löschen'
|
||||||
|
? `„${r.title}" endgültig löschen?`
|
||||||
|
: `„${r.title}" aus deiner Liste entfernen? Du verlierst den Zugriff, bis es wieder mit dir geteilt wird.`;
|
||||||
|
if (confirm(q)) await library.remove(r.ref);
|
||||||
|
}
|
||||||
|
async function upload(r: Row) {
|
||||||
|
await library.upload([r.ref.id]);
|
||||||
|
}
|
||||||
|
async function createNew() {
|
||||||
|
await library.createBlank();
|
||||||
|
emit('close');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<BaseModal :open="open" title="Dokumente" wide @close="emit('close')">
|
||||||
|
<p v-if="!library.state.available && !session.signedIn.value" class="hint">
|
||||||
|
In diesem Browser können keine Dokumente gespeichert werden (IndexedDB nicht verfügbar, z. B. im
|
||||||
|
privaten Modus). Mit einem Konto werden Dokumente auf dem Server gespeichert.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<section v-for="g in groups" :key="g.key" class="group">
|
||||||
|
<h3>{{ g.title }}</h3>
|
||||||
|
<p v-if="!g.rows.length" class="empty">Noch keine Dokumente.</p>
|
||||||
|
<ul v-else class="doc-list">
|
||||||
|
<li v-for="r in g.rows" :key="keyOf(r.ref)" class="doc-row" :class="{ current: isCurrent(r.ref) }">
|
||||||
|
<input
|
||||||
|
v-if="renaming === keyOf(r.ref)"
|
||||||
|
ref="renameInput"
|
||||||
|
v-model="renameValue"
|
||||||
|
class="rename"
|
||||||
|
type="text"
|
||||||
|
@keydown.enter.prevent="commitRename(r)"
|
||||||
|
@keydown.esc.stop.prevent="renaming = null"
|
||||||
|
@blur="commitRename(r)"
|
||||||
|
/>
|
||||||
|
<button v-else class="doc-open" :title="`${r.title} öffnen`" @click="openDoc(r)">
|
||||||
|
<span class="doc-name">{{ r.title }}</span>
|
||||||
|
<span class="doc-when">{{ subline(r) }}</span>
|
||||||
|
</button>
|
||||||
|
<span class="doc-actions">
|
||||||
|
<button v-if="r.canUpload" class="mini" title="In dein Konto hochladen (danach teilbar)" @click="upload(r)">
|
||||||
|
Hochladen
|
||||||
|
</button>
|
||||||
|
<button v-if="r.canRename" class="mini" @click="startRename(r)">Umbenennen</button>
|
||||||
|
<button v-if="r.canDuplicate" class="mini" @click="library.duplicate(r.ref)">Duplizieren</button>
|
||||||
|
<button class="mini danger" @click="remove(r)">{{ r.removeLabel }}</button>
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<p v-if="!session.signedIn.value" class="hint">
|
||||||
|
Dokumente werden nur in diesem Browser gespeichert.
|
||||||
|
<button class="linkish" @click="emit('login')">Anmelden</button>, um sie auf allen Geräten zu haben und zu
|
||||||
|
teilen.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<button @click="emit('close')">Schließen</button>
|
||||||
|
<button class="primary" :disabled="!library.state.available && !session.signedIn.value" @click="createNew">
|
||||||
|
+ Neues Dokument
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
</BaseModal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.group + .group {
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
h3 {
|
||||||
|
margin: 0 0 6px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
.empty {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 12.5px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
.hint {
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-size: 12.5px;
|
||||||
|
line-height: 1.5;
|
||||||
|
margin: 14px 0 0;
|
||||||
|
}
|
||||||
|
.linkish {
|
||||||
|
border: 0;
|
||||||
|
background: none;
|
||||||
|
padding: 0;
|
||||||
|
font: inherit;
|
||||||
|
color: var(--accent);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.doc-list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
max-height: min(40vh, 360px);
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.doc-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 4px 8px 4px 4px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--surface-2);
|
||||||
|
border: 1px solid transparent;
|
||||||
|
}
|
||||||
|
.doc-row.current {
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
.doc-open {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 1px;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
padding: 4px 6px;
|
||||||
|
border-radius: 5px;
|
||||||
|
font: inherit;
|
||||||
|
color: var(--text);
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.doc-open:hover .doc-name {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
.doc-name {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.doc-when {
|
||||||
|
font-size: 11.5px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
.rename {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
border: 1px solid var(--accent);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 5px 8px;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.doc-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
@media (max-width: 560px) {
|
||||||
|
.doc-row {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.doc-actions {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.mini {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--surface);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 3px 9px;
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.mini:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
.mini.danger:hover {
|
||||||
|
border-color: #dc2626;
|
||||||
|
color: #dc2626;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -272,7 +272,8 @@ function onKeydown(e: KeyboardEvent) {
|
|||||||
class="formula-input"
|
class="formula-input"
|
||||||
spellcheck="false"
|
spellcheck="false"
|
||||||
rows="1"
|
rows="1"
|
||||||
placeholder="Wert oder =Formel"
|
:placeholder="store.ui.readOnly ? 'Nur Lesen' : 'Wert oder =Formel'"
|
||||||
|
:readonly="!!store.ui.readOnly"
|
||||||
title="Ziehen an der unteren Kante, um mehr Zeilen zu zeigen"
|
title="Ziehen an der unteren Kante, um mehr Zeilen zu zeigen"
|
||||||
@focus="onFocus"
|
@focus="onFocus"
|
||||||
@blur="onBlur"
|
@blur="onBlur"
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* Sign-in without a password: e-mail → a mail with a link and a 6-digit
|
||||||
|
* code. Clicking the link signs in (in whatever browser opens it); typing
|
||||||
|
* the code here signs in this browser. Both create the account on first use.
|
||||||
|
*/
|
||||||
|
import { nextTick, ref, watch } from 'vue';
|
||||||
|
import { useSession } from '../store/session';
|
||||||
|
import BaseModal from './BaseModal.vue';
|
||||||
|
|
||||||
|
const props = defineProps<{ open: boolean; reason?: string }>();
|
||||||
|
const emit = defineEmits<{ (e: 'close'): void }>();
|
||||||
|
|
||||||
|
const session = useSession();
|
||||||
|
const step = ref<'email' | 'code'>('email');
|
||||||
|
const email = ref('');
|
||||||
|
const code = ref('');
|
||||||
|
const busy = ref(false);
|
||||||
|
const error = ref<string | null>(null);
|
||||||
|
const emailInput = ref<HTMLInputElement | null>(null);
|
||||||
|
const codeInput = ref<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.open,
|
||||||
|
async (o) => {
|
||||||
|
if (!o) return;
|
||||||
|
step.value = 'email';
|
||||||
|
code.value = '';
|
||||||
|
error.value = null;
|
||||||
|
await nextTick();
|
||||||
|
emailInput.value?.focus();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
async function send() {
|
||||||
|
if (!email.value.trim()) return;
|
||||||
|
busy.value = true;
|
||||||
|
error.value = null;
|
||||||
|
try {
|
||||||
|
await session.requestLink(email.value.trim());
|
||||||
|
step.value = 'code';
|
||||||
|
await nextTick();
|
||||||
|
codeInput.value?.focus();
|
||||||
|
} catch (err) {
|
||||||
|
error.value = (err as Error).message.includes('422')
|
||||||
|
? 'Bitte eine gültige E-Mail-Adresse eingeben.'
|
||||||
|
: (err as Error).message;
|
||||||
|
} finally {
|
||||||
|
busy.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function verify() {
|
||||||
|
busy.value = true;
|
||||||
|
error.value = null;
|
||||||
|
try {
|
||||||
|
await session.verifyCode(email.value.trim(), code.value);
|
||||||
|
} catch {
|
||||||
|
error.value = 'Der Code stimmt nicht oder ist abgelaufen.';
|
||||||
|
busy.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<BaseModal :open="open" title="Anmelden" @close="emit('close')">
|
||||||
|
<template v-if="step === 'email'">
|
||||||
|
<p v-if="reason" class="reason">{{ reason }}</p>
|
||||||
|
<p class="lead">
|
||||||
|
Mit einem Konto liegen deine Dokumente auf dem Server – auf allen Geräten verfügbar und teilbar.
|
||||||
|
Du bekommst einen Anmeldelink per E-Mail, ein Passwort gibt es nicht.
|
||||||
|
</p>
|
||||||
|
<form @submit.prevent="send">
|
||||||
|
<label class="row">
|
||||||
|
E-Mail
|
||||||
|
<input ref="emailInput" v-model="email" type="email" autocomplete="email" placeholder="name@beispiel.li" required />
|
||||||
|
</label>
|
||||||
|
</form>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<p class="lead">
|
||||||
|
Wir haben eine Mail an <strong>{{ email }}</strong> geschickt. Klicke auf den Link darin – oder gib hier
|
||||||
|
den 6-stelligen Code ein.
|
||||||
|
</p>
|
||||||
|
<form @submit.prevent="verify">
|
||||||
|
<label class="row">
|
||||||
|
Code
|
||||||
|
<input
|
||||||
|
ref="codeInput"
|
||||||
|
v-model="code"
|
||||||
|
class="code"
|
||||||
|
inputmode="numeric"
|
||||||
|
autocomplete="one-time-code"
|
||||||
|
maxlength="7"
|
||||||
|
placeholder="123456"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</form>
|
||||||
|
<button class="linkish" @click="step = 'email'">Andere Adresse / neuen Link anfordern</button>
|
||||||
|
</template>
|
||||||
|
<p v-if="error" class="error">{{ error }}</p>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<button @click="emit('close')">Abbrechen</button>
|
||||||
|
<button v-if="step === 'email'" class="primary" :disabled="busy || !email.trim()" @click="send">
|
||||||
|
Link senden
|
||||||
|
</button>
|
||||||
|
<button v-else class="primary" :disabled="busy || code.replace(/\D/g, '').length !== 6" @click="verify">
|
||||||
|
Anmelden
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
</BaseModal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.lead,
|
||||||
|
.reason {
|
||||||
|
margin: 0 0 14px;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.reason {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 5px;
|
||||||
|
font-size: 12.5px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
.row input {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 7px 9px;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 14px;
|
||||||
|
background: var(--surface-2);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.code {
|
||||||
|
letter-spacing: 0.25em;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.linkish {
|
||||||
|
margin-top: 10px;
|
||||||
|
border: 0;
|
||||||
|
background: none;
|
||||||
|
padding: 0;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 12.5px;
|
||||||
|
color: var(--accent);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.error {
|
||||||
|
margin: 10px 0 0;
|
||||||
|
font-size: 12.5px;
|
||||||
|
color: #dc2626;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,310 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* "Teilen" for the open document. Sharing needs the document on the server,
|
||||||
|
* which needs an account: a local document is uploaded first (after
|
||||||
|
* signing in). The owner then manages links ("anyone with the link can
|
||||||
|
* view / edit") and people (invited by e-mail, with a role).
|
||||||
|
*/
|
||||||
|
import { computed, ref, watch } from 'vue';
|
||||||
|
import { api, type ShareInfo } from '../io/api';
|
||||||
|
import { useLibrary } from '../store/library';
|
||||||
|
import { useSession } from '../store/session';
|
||||||
|
import { useToasts } from '../composables/useToasts';
|
||||||
|
import BaseModal from './BaseModal.vue';
|
||||||
|
|
||||||
|
const props = defineProps<{ open: boolean }>();
|
||||||
|
const emit = defineEmits<{ (e: 'close'): void; (e: 'login'): void }>();
|
||||||
|
|
||||||
|
const library = useLibrary();
|
||||||
|
const session = useSession();
|
||||||
|
const toasts = useToasts();
|
||||||
|
|
||||||
|
const info = ref<ShareInfo | null>(null);
|
||||||
|
const busy = ref(false);
|
||||||
|
const inviteEmail = ref('');
|
||||||
|
const inviteRole = ref<'viewer' | 'editor'>('editor');
|
||||||
|
|
||||||
|
const current = computed(() => library.state.current);
|
||||||
|
const isServer = computed(() => current.value?.source === 'server');
|
||||||
|
const isOwner = computed(() => isServer.value && library.currentRole.value === 'owner');
|
||||||
|
const docPath = computed(() => `/documents/${current.value?.id}`);
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
info.value = null;
|
||||||
|
if (!isOwner.value) return;
|
||||||
|
try {
|
||||||
|
info.value = await api<ShareInfo>('GET', `${docPath.value}/share`);
|
||||||
|
} catch (err) {
|
||||||
|
toasts.error((err as Error).message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
watch(
|
||||||
|
() => [props.open, current.value?.id, library.currentRole.value] as const,
|
||||||
|
([o]) => {
|
||||||
|
if (o) void load();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
async function call(method: string, path: string, body?: unknown) {
|
||||||
|
busy.value = true;
|
||||||
|
try {
|
||||||
|
info.value = await api<ShareInfo>(method, `${docPath.value}/share${path}`, { body });
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
toasts.error((err as Error).message);
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
busy.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const linkFor = (role: 'viewer' | 'editor') => info.value?.links.find((l) => l.role === role) ?? null;
|
||||||
|
const urlFor = (token: string) => `${location.origin}/d/${current.value?.id}#k=${token}`;
|
||||||
|
|
||||||
|
async function copy(token: string) {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(urlFor(token));
|
||||||
|
toasts.success('Link kopiert.');
|
||||||
|
} catch {
|
||||||
|
toasts.error('Kopieren nicht möglich – bitte den Link markieren und kopieren.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function newLink(role: 'viewer' | 'editor', replace: boolean) {
|
||||||
|
if (replace && !confirm('Neuen Link erzeugen? Der bisherige funktioniert dann nicht mehr.')) return;
|
||||||
|
if (await call('POST', '/links', { role })) {
|
||||||
|
const l = linkFor(role);
|
||||||
|
if (l) await copy(l.token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function revoke(id: number) {
|
||||||
|
if (confirm('Link deaktivieren? Wer das Dokument nur über diesen Link hatte, verliert den Zugriff.')) {
|
||||||
|
await call('DELETE', `/links/${id}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function invite() {
|
||||||
|
const email = inviteEmail.value.trim();
|
||||||
|
if (!email) return;
|
||||||
|
if (await call('POST', '/invitations', { email, role: inviteRole.value })) {
|
||||||
|
toasts.success(`Einladung an ${email} verschickt.`);
|
||||||
|
inviteEmail.value = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function setRole(userId: number, role: string) {
|
||||||
|
await call('PATCH', `/members/${userId}`, { role });
|
||||||
|
}
|
||||||
|
async function removeMember(userId: number, name: string) {
|
||||||
|
if (confirm(`${name} den Zugriff entziehen?`)) await call('DELETE', `/members/${userId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadAndShare() {
|
||||||
|
const id = current.value?.id;
|
||||||
|
if (!id) return;
|
||||||
|
busy.value = true;
|
||||||
|
try {
|
||||||
|
await library.upload([id]);
|
||||||
|
} finally {
|
||||||
|
busy.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const LINK_ROWS = [
|
||||||
|
{ role: 'viewer', label: 'Jeder mit dem Link kann ansehen' },
|
||||||
|
{ role: 'editor', label: 'Jeder mit dem Link kann bearbeiten' },
|
||||||
|
] as const;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<BaseModal :open="open" :title="`Teilen · ${library.currentTitle.value}`" wide @close="emit('close')">
|
||||||
|
<!-- local document -->
|
||||||
|
<template v-if="!isServer">
|
||||||
|
<p v-if="!session.signedIn.value" class="lead">
|
||||||
|
Dieses Dokument liegt nur in diesem Browser. Zum Teilen brauchst du ein Konto – danach wird es in dein
|
||||||
|
Konto hochgeladen.
|
||||||
|
</p>
|
||||||
|
<p v-else class="lead">
|
||||||
|
Dieses Dokument liegt nur in diesem Browser. Zum Teilen wird es in dein Konto hochgeladen (und hier aus
|
||||||
|
dem Browser entfernt).
|
||||||
|
</p>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- server document, not the owner -->
|
||||||
|
<p v-else-if="!isOwner" class="lead">
|
||||||
|
Dieses Dokument gehört {{ library.state.server?.owner_name }}. Nur der Besitzer kann es teilen.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- owner -->
|
||||||
|
<template v-else-if="info">
|
||||||
|
<h3>Link</h3>
|
||||||
|
<ul class="rows">
|
||||||
|
<li v-for="r in LINK_ROWS" :key="r.role" class="link-row">
|
||||||
|
<span class="label">{{ r.label }}</span>
|
||||||
|
<template v-if="linkFor(r.role)">
|
||||||
|
<input class="url" readonly :value="urlFor(linkFor(r.role)!.token)" @focus="($event.target as HTMLInputElement).select()" />
|
||||||
|
<span class="actions">
|
||||||
|
<button class="mini" @click="copy(linkFor(r.role)!.token)">Kopieren</button>
|
||||||
|
<button class="mini" :disabled="busy" @click="newLink(r.role, true)">Neu</button>
|
||||||
|
<button class="mini danger" :disabled="busy" @click="revoke(linkFor(r.role)!.id)">Deaktivieren</button>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
<span v-else class="actions">
|
||||||
|
<button class="mini" :disabled="busy" @click="newLink(r.role, false)">Link erstellen</button>
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<p class="hint">Links funktionieren auch ohne Konto.</p>
|
||||||
|
|
||||||
|
<h3>Personen</h3>
|
||||||
|
<form class="invite" @submit.prevent="invite">
|
||||||
|
<input v-model="inviteEmail" type="email" placeholder="E-Mail-Adresse" required />
|
||||||
|
<select v-model="inviteRole">
|
||||||
|
<option value="editor">kann bearbeiten</option>
|
||||||
|
<option value="viewer">kann ansehen</option>
|
||||||
|
</select>
|
||||||
|
<button class="primary" type="submit" :disabled="busy || !inviteEmail.trim()">Einladen</button>
|
||||||
|
</form>
|
||||||
|
<ul class="rows">
|
||||||
|
<li class="person">
|
||||||
|
<span class="who">
|
||||||
|
<strong>{{ session.state.user?.name }}</strong>
|
||||||
|
<span class="dim">{{ session.state.user?.email }}</span>
|
||||||
|
</span>
|
||||||
|
<span class="dim">Besitzer</span>
|
||||||
|
</li>
|
||||||
|
<li v-for="m in info.members" :key="m.user_id" class="person">
|
||||||
|
<span class="who">
|
||||||
|
<strong>{{ m.name }}</strong>
|
||||||
|
<span class="dim">{{ m.email }}{{ m.source === 'link' ? ' · über Link' : '' }}</span>
|
||||||
|
</span>
|
||||||
|
<select :value="m.role" :disabled="busy" @change="setRole(m.user_id, ($event.target as HTMLSelectElement).value)">
|
||||||
|
<option value="editor">kann bearbeiten</option>
|
||||||
|
<option value="viewer">kann ansehen</option>
|
||||||
|
</select>
|
||||||
|
<button class="mini danger" :disabled="busy" @click="removeMember(m.user_id, m.name)">Entfernen</button>
|
||||||
|
</li>
|
||||||
|
<li v-for="i in info.invitations" :key="i.email + i.expires_at" class="person">
|
||||||
|
<span class="who">
|
||||||
|
<strong>{{ i.email }}</strong>
|
||||||
|
<span class="dim">eingeladen · {{ i.role === 'editor' ? 'kann bearbeiten' : 'kann ansehen' }}</span>
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</template>
|
||||||
|
<p v-else class="lead">Lade …</p>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<button @click="emit('close')">Schließen</button>
|
||||||
|
<button v-if="!isServer && !session.signedIn.value" class="primary" @click="emit('login')">Anmelden</button>
|
||||||
|
<button v-else-if="!isServer" class="primary" :disabled="busy" @click="uploadAndShare">
|
||||||
|
Hochladen und teilen
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
</BaseModal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.lead {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
h3 {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
h3:not(:first-child) {
|
||||||
|
margin-top: 18px;
|
||||||
|
}
|
||||||
|
.hint {
|
||||||
|
margin: 6px 0 0;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
.rows {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
.link-row,
|
||||||
|
.person {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--surface-2);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.label {
|
||||||
|
flex: 1 1 200px;
|
||||||
|
}
|
||||||
|
.url {
|
||||||
|
flex: 1 1 220px;
|
||||||
|
min-width: 0;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 4px 7px;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 12px;
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
.who {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.dim {
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.invite {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.invite input {
|
||||||
|
flex: 1 1 200px;
|
||||||
|
}
|
||||||
|
.invite input,
|
||||||
|
select {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 5px 8px;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
background: var(--surface-2);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.mini {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--surface);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 3px 9px;
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.mini:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
.mini.danger:hover {
|
||||||
|
border-color: #dc2626;
|
||||||
|
color: #dc2626;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -911,6 +911,10 @@ function formulaEditorHandle() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function beginEdit(c: number, r: number, initial?: string) {
|
function beginEdit(c: number, r: number, initial?: string) {
|
||||||
|
if (store.ui.readOnly) {
|
||||||
|
store.setCellInput(props.tableId, c, r, ''); // no-op, only tells the user why
|
||||||
|
return;
|
||||||
|
}
|
||||||
editing.value = { col: c, row: r };
|
editing.value = { col: c, row: r };
|
||||||
editValue.value = initial ?? store.cellInput(props.tableId, c, r);
|
editValue.value = initial ?? store.cellInput(props.tableId, c, r);
|
||||||
keyboardPoint.reset();
|
keyboardPoint.reset();
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* Shown once after signing in when this browser still has local documents:
|
||||||
|
* which of them should move into the account (server)? Uploaded ones are
|
||||||
|
* removed from the browser. "Später" leaves everything as is -- uploading
|
||||||
|
* stays possible from the document list.
|
||||||
|
*/
|
||||||
|
import { ref, watch } from 'vue';
|
||||||
|
import { useLibrary } from '../store/library';
|
||||||
|
import BaseModal from './BaseModal.vue';
|
||||||
|
|
||||||
|
const props = defineProps<{ open: boolean }>();
|
||||||
|
const emit = defineEmits<{ (e: 'close'): void }>();
|
||||||
|
|
||||||
|
const library = useLibrary();
|
||||||
|
const selected = ref<Set<string>>(new Set());
|
||||||
|
const busy = ref(false);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.open,
|
||||||
|
(o) => {
|
||||||
|
if (o) selected.value = new Set(library.state.docs.map((d) => d.id));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
function toggle(id: string) {
|
||||||
|
const s = new Set(selected.value);
|
||||||
|
if (s.has(id)) s.delete(id);
|
||||||
|
else s.add(id);
|
||||||
|
selected.value = s;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
busy.value = true;
|
||||||
|
try {
|
||||||
|
await library.upload([...selected.value]);
|
||||||
|
emit('close');
|
||||||
|
} finally {
|
||||||
|
busy.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<BaseModal :open="open" title="Dokumente in dein Konto übernehmen?" @close="emit('close')">
|
||||||
|
<p class="lead">
|
||||||
|
In diesem Browser liegen noch Dokumente. Ausgewählte werden in dein Konto hochgeladen – dann sind sie
|
||||||
|
auf allen Geräten da und lassen sich teilen – und hier aus dem Browser entfernt.
|
||||||
|
</p>
|
||||||
|
<ul class="list">
|
||||||
|
<li v-for="d in library.state.docs" :key="d.id">
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" :checked="selected.has(d.id)" @change="toggle(d.id)" />
|
||||||
|
{{ d.title }}
|
||||||
|
</label>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<template #footer>
|
||||||
|
<button @click="emit('close')">Später</button>
|
||||||
|
<button class="primary" :disabled="busy || !selected.size" @click="run">
|
||||||
|
{{ busy ? 'Lade hoch …' : `${selected.size} hochladen` }}
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
</BaseModal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.lead {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
max-height: 40vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.list li + li {
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
.list label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--surface-2);
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { createApp } from 'vue';
|
import { createApp } from 'vue';
|
||||||
import App from './App.vue';
|
import App from '../App.vue';
|
||||||
import { useLibrary } from './store/library';
|
import { useLibrary } from '../store/library';
|
||||||
import './styles/main.css';
|
import '../styles/main.css';
|
||||||
|
|
||||||
// open the document (from IndexedDB) before the first render, so the app
|
// open the document (from IndexedDB) before the first render, so the app
|
||||||
// never flashes an empty document first
|
// never flashes an empty document first
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
/**
|
||||||
|
* The numolus server API (Rails, /api). JSON in and out, the session cookie
|
||||||
|
* for the signed-in user, the CSRF token from the page's meta tag, and two
|
||||||
|
* per-request extras: X-Client-Id (this browser tab, the edit-lock holder)
|
||||||
|
* and X-Share-Token (a share link's token, for access via link).
|
||||||
|
*/
|
||||||
|
|
||||||
|
export class ApiError extends Error {
|
||||||
|
constructor(
|
||||||
|
public status: number,
|
||||||
|
public body: Record<string, unknown>,
|
||||||
|
) {
|
||||||
|
super(ApiError.describe(status, body));
|
||||||
|
}
|
||||||
|
static describe(status: number, body: Record<string, unknown>): string {
|
||||||
|
if (status === 0) return 'Keine Verbindung zum Server.';
|
||||||
|
if (status === 401) return 'Nicht angemeldet.';
|
||||||
|
if (status === 404) return 'Nicht gefunden oder kein Zugriff.';
|
||||||
|
if (status === 429) return 'Zu viele Versuche – bitte später noch einmal.';
|
||||||
|
const msgs = body.messages;
|
||||||
|
if (Array.isArray(msgs) && msgs.length) return msgs.join(', ');
|
||||||
|
return `Serverfehler (${status}).`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Role = 'owner' | 'editor' | 'viewer';
|
||||||
|
|
||||||
|
export interface User {
|
||||||
|
id: number;
|
||||||
|
email: string;
|
||||||
|
name: string;
|
||||||
|
display_name: string | null;
|
||||||
|
color: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ServerDocMeta {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
version: number;
|
||||||
|
role: Role;
|
||||||
|
owner_name: string;
|
||||||
|
updated_at: string;
|
||||||
|
updated_by_name: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ServerDoc extends ServerDocMeta {
|
||||||
|
content?: string;
|
||||||
|
lock?: { locked_by_other: boolean; holder_name: string | null };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ShareInfo {
|
||||||
|
links: { id: number; role: 'viewer' | 'editor'; token: string; created_at: string }[];
|
||||||
|
members: { user_id: number; name: string; email: string; role: 'viewer' | 'editor'; source: 'invite' | 'link' }[];
|
||||||
|
invitations: { email: string; role: 'viewer' | 'editor'; expires_at: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One id per browser tab, kept across reloads of that tab. */
|
||||||
|
export const clientId: string = (() => {
|
||||||
|
const key = 'numolus:client-id';
|
||||||
|
try {
|
||||||
|
const existing = sessionStorage.getItem(key);
|
||||||
|
if (existing) return existing;
|
||||||
|
const id = Math.random().toString(36).slice(2) + Date.now().toString(36);
|
||||||
|
sessionStorage.setItem(key, id);
|
||||||
|
return id;
|
||||||
|
} catch {
|
||||||
|
return Math.random().toString(36).slice(2);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
function csrfToken(): string {
|
||||||
|
return document.querySelector<HTMLMetaElement>('meta[name="csrf-token"]')?.content ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RequestOpts {
|
||||||
|
body?: unknown;
|
||||||
|
shareToken?: string | null;
|
||||||
|
/** survives the page being closed (small bodies only, < 64 KB) */
|
||||||
|
keepalive?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function api<T = Record<string, unknown>>(method: string, path: string, opts: RequestOpts = {}): Promise<T> {
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
Accept: 'application/json',
|
||||||
|
'X-CSRF-Token': csrfToken(),
|
||||||
|
'X-Client-Id': clientId,
|
||||||
|
};
|
||||||
|
if (opts.body !== undefined) headers['Content-Type'] = 'application/json';
|
||||||
|
if (opts.shareToken) headers['X-Share-Token'] = opts.shareToken;
|
||||||
|
let res: Response;
|
||||||
|
try {
|
||||||
|
res = await fetch(`/api${path}`, {
|
||||||
|
method,
|
||||||
|
headers,
|
||||||
|
body: opts.body === undefined ? undefined : JSON.stringify(opts.body),
|
||||||
|
credentials: 'same-origin',
|
||||||
|
keepalive: opts.keepalive,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
throw new ApiError(0, {});
|
||||||
|
}
|
||||||
|
if (res.status === 204) return {} as T;
|
||||||
|
const body = (await res.json().catch(() => ({}))) as Record<string, unknown>;
|
||||||
|
if (!res.ok) throw new ApiError(res.status, body);
|
||||||
|
return body as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Data the server put into the page (see app/views/spa/show.html.erb). */
|
||||||
|
export function bootData(): { user: User | null } {
|
||||||
|
try {
|
||||||
|
const el = document.getElementById('numolus-boot');
|
||||||
|
return el ? JSON.parse(el.textContent || '{}') : { user: null };
|
||||||
|
} catch {
|
||||||
|
return { user: null };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.2 KiB |
@@ -630,6 +630,11 @@ function createStore() {
|
|||||||
* any one `TableGrid` instance, so navigating across tables/sheets
|
* any one `TableGrid` instance, so navigating across tables/sheets
|
||||||
* inside it doesn't depend on which grid happened to open it. */
|
* inside it doesn't depend on which grid happened to open it. */
|
||||||
depGraph: null as { tableId: string; col: number; row: number } | null,
|
depGraph: null as { tableId: string; col: number; row: number } | null,
|
||||||
|
/** Why the open document can't be changed right now (shown to the user),
|
||||||
|
* or `null` when it can. Set by the library for shared server documents
|
||||||
|
* (view-only access, or someone else is editing). While set, every
|
||||||
|
* content-changing store function is a no-op -- see READ_ONLY_GUARDED. */
|
||||||
|
readOnly: null as string | null,
|
||||||
});
|
});
|
||||||
/** Copy/cut buffer -- not part of the persisted document. */
|
/** Copy/cut buffer -- not part of the persisted document. */
|
||||||
const clipboard = ref<ClipboardData | null>(null);
|
const clipboard = ref<ClipboardData | null>(null);
|
||||||
@@ -3004,7 +3009,7 @@ function createStore() {
|
|||||||
return JSON.parse(JSON.stringify(doc)) as DocumentState;
|
return JSON.parse(JSON.stringify(doc)) as DocumentState;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
const api = {
|
||||||
doc,
|
doc,
|
||||||
recalcTick,
|
recalcTick,
|
||||||
// undo / redo
|
// undo / redo
|
||||||
@@ -3156,6 +3161,52 @@ function createStore() {
|
|||||||
cancelSave,
|
cancelSave,
|
||||||
engine: () => wb,
|
engine: () => wb,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// one gate instead of a check in every mutation: while the document is
|
||||||
|
// read-only, the content-changing entry points do nothing
|
||||||
|
let lastBlockNote = 0;
|
||||||
|
const table = api as unknown as Record<string, (...args: unknown[]) => unknown>;
|
||||||
|
for (const name of READ_ONLY_GUARDED) {
|
||||||
|
const fn = table[name];
|
||||||
|
table[name] = (...args: unknown[]) => {
|
||||||
|
if (ui.readOnly) {
|
||||||
|
if (Date.now() - lastBlockNote > 3000) {
|
||||||
|
lastBlockNote = Date.now();
|
||||||
|
readOnlyNotifier?.(ui.readOnly);
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return fn(...args);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return api;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Store functions that change document content, blocked while read-only. */
|
||||||
|
const READ_ONLY_GUARDED = [
|
||||||
|
'undo', 'redo',
|
||||||
|
'addSheet', 'renameSheet', 'deleteSheet', 'moveSheet',
|
||||||
|
'addTable', 'addChart', 'addText', 'setTextContent', 'setTextFormat', 'resizeText',
|
||||||
|
'addButton', 'setButtonLabel', 'setButtonMacroId', 'resizeButton',
|
||||||
|
'renameObject', 'deleteObject', 'moveObject', 'resizeChart', 'bringToFront',
|
||||||
|
'setChartView', 'setChartSource', 'setObjectBg', 'setObjectTextColor', 'setTableHideChromeUnfocused',
|
||||||
|
'addCustomFunction', 'updateCustomFunction', 'deleteCustomFunction',
|
||||||
|
'addNamedRange', 'updateNamedRange', 'deleteNamedRange',
|
||||||
|
'addMacro', 'updateMacro', 'deleteMacro', 'runMacro',
|
||||||
|
'setCellInput', 'clearRange', 'addRows', 'addColumns', 'removeLastRow', 'removeLastColumn',
|
||||||
|
'setColWidth', 'setRowHeight', 'setTableRowHeight', 'setTableRows', 'setTableCols',
|
||||||
|
'insertRow', 'deleteRow', 'insertColumn', 'deleteColumn', 'setTableView',
|
||||||
|
'setRangeFormat', 'clearRangeFormat', 'applySelectionFormat', 'clearSelectionFormat', 'clearSelectionCells',
|
||||||
|
'setDocumentLocale', 'setSheetLocale', 'setObjectLocale',
|
||||||
|
'setSheetBackground', 'setSheetForeground', 'setSheetShowGrid', 'setSheetGridSize', 'setCsvDelimiter',
|
||||||
|
'cutSelection', 'pasteClipboard', 'fillSelection',
|
||||||
|
'newDocument', 'resetToSeed', 'appendSheets', 'addTableFromCells', 'replaceTableCells',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/** Tells the user why an edit was ignored (a toast) -- set by the app. */
|
||||||
|
let readOnlyNotifier: ((why: string) => void) | null = null;
|
||||||
|
export function setReadOnlyNotifier(fn: ((why: string) => void) | null) {
|
||||||
|
readOnlyNotifier = fn;
|
||||||
}
|
}
|
||||||
|
|
||||||
let instance: ReturnType<typeof createStore> | null = null;
|
let instance: ReturnType<typeof createStore> | null = null;
|
||||||
@@ -0,0 +1,852 @@
|
|||||||
|
/**
|
||||||
|
* Glue between the document store (one open document) and where documents
|
||||||
|
* live:
|
||||||
|
*
|
||||||
|
* - **local** documents: in this browser's IndexedDB (src/io/library.ts),
|
||||||
|
* URL `/?doc=<id>`. Without an account, that's all there is.
|
||||||
|
* - **server** documents: on the numolus server (src/io/api.ts), URL
|
||||||
|
* `/d/<id>`. With an account, new documents are created there and local
|
||||||
|
* ones can be uploaded; they can be shared. A share link
|
||||||
|
* (`/d/<id>#k=<token>`) also opens a server document without an account --
|
||||||
|
* the token is kept in this browser (localStorage) and sent along.
|
||||||
|
*
|
||||||
|
* Persistence: the store's debounced save lands in `persist()` and goes to
|
||||||
|
* IndexedDB or to the server. Server documents have a single-editor lock
|
||||||
|
* (until live collaboration exists): whoever holds it edits, everyone else
|
||||||
|
* sees the document read-only and follows its changes by polling.
|
||||||
|
*
|
||||||
|
* Other tabs: a BroadcastChannel keeps lists current and lets a local
|
||||||
|
* document open in two tabs follow the other tab's saves.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { computed, reactive } from 'vue';
|
||||||
|
import { useDocument, setPersistHandler, setReadOnlyNotifier, contentFingerprint } from './document';
|
||||||
|
import type { DocumentState } from './types';
|
||||||
|
import { DocumentLibrary, newDocId, type DocMeta } from '../io/library';
|
||||||
|
import { api, ApiError, type Role, type ServerDoc, type ServerDocMeta } from '../io/api';
|
||||||
|
import { validateDocument } from '../io/json';
|
||||||
|
import { createBlankDocument, createSeedDocument } from '../data/seed';
|
||||||
|
import { useToasts } from '../composables/useToasts';
|
||||||
|
import { useSession } from './session';
|
||||||
|
|
||||||
|
/** The single-document localStorage keys used before the library existed --
|
||||||
|
* read once and moved into the library as its first document. */
|
||||||
|
const LEGACY_KEYS = ['numolus:doc:v2', 'numbers-study:doc:v2'];
|
||||||
|
/** share-link tokens this browser holds without an account */
|
||||||
|
const LINKS_KEY = 'numolus:links';
|
||||||
|
const CHANNEL = 'numolus:library';
|
||||||
|
const DEFAULT_TITLE = 'Unbenannt';
|
||||||
|
const APP_TITLE = 'numolus';
|
||||||
|
const UPLOAD_DELAY = 800; // ms after the last change
|
||||||
|
const HEARTBEAT = 30_000; // refresh the edit lock (server TTL is 90 s)
|
||||||
|
const POLL = 10_000; // read-only: look for someone else's changes
|
||||||
|
|
||||||
|
export type DocSource = 'local' | 'server';
|
||||||
|
export interface DocRef {
|
||||||
|
source: DocSource;
|
||||||
|
id: string;
|
||||||
|
}
|
||||||
|
export interface LinkDoc {
|
||||||
|
id: string;
|
||||||
|
token: string;
|
||||||
|
title: string;
|
||||||
|
role: Role;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Msg =
|
||||||
|
| { type: 'list' }
|
||||||
|
| { type: 'saved'; id: string }
|
||||||
|
| { type: 'deleted'; id: string }
|
||||||
|
| { type: 'hello'; id: string }
|
||||||
|
| { type: 'here'; id: string };
|
||||||
|
|
||||||
|
function metaOf(d: ServerDoc): ServerDocMeta {
|
||||||
|
const { id, title, version, role, owner_name, updated_at, updated_by_name } = d;
|
||||||
|
return { id, title, version, role, owner_name, updated_at, updated_by_name };
|
||||||
|
}
|
||||||
|
|
||||||
|
function readLinks(): LinkDoc[] {
|
||||||
|
try {
|
||||||
|
return JSON.parse(localStorage.getItem(LINKS_KEY) || '[]') as LinkDoc[];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function writeLinks(links: LinkDoc[]) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(LINKS_KEY, JSON.stringify(links));
|
||||||
|
} catch {
|
||||||
|
/* private mode -- the link itself still works */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createLibrary() {
|
||||||
|
const store = useDocument();
|
||||||
|
const toasts = useToasts();
|
||||||
|
const session = useSession();
|
||||||
|
|
||||||
|
const state = reactive({
|
||||||
|
ready: false,
|
||||||
|
/** false = IndexedDB unavailable (private mode in some browsers, ...) */
|
||||||
|
available: true,
|
||||||
|
/** local documents (IndexedDB) */
|
||||||
|
docs: [] as DocMeta[],
|
||||||
|
/** server documents of the signed-in user: own + shared with them */
|
||||||
|
serverDocs: [] as ServerDocMeta[],
|
||||||
|
/** documents opened via share link without an account */
|
||||||
|
linkDocs: readLinks(),
|
||||||
|
current: null as DocRef | null,
|
||||||
|
/** the open server document's metadata (title, role, version, ...) */
|
||||||
|
server: null as ServerDocMeta | null,
|
||||||
|
/** someone else holds the edit lock on the open server document */
|
||||||
|
lockedBy: null as string | null,
|
||||||
|
/** server saves waiting or failing */
|
||||||
|
syncState: 'idle' as 'idle' | 'saving' | 'offline',
|
||||||
|
});
|
||||||
|
|
||||||
|
const currentTitle = computed(() => {
|
||||||
|
const c = state.current;
|
||||||
|
if (!c) return DEFAULT_TITLE;
|
||||||
|
if (c.source === 'server') return state.server?.title ?? DEFAULT_TITLE;
|
||||||
|
return state.docs.find((d) => d.id === c.id)?.title ?? DEFAULT_TITLE;
|
||||||
|
});
|
||||||
|
/** role in the open document; local documents are always the user's own */
|
||||||
|
const currentRole = computed<Role>(() =>
|
||||||
|
state.current?.source === 'server' ? (state.server?.role ?? 'viewer') : 'owner',
|
||||||
|
);
|
||||||
|
const canEdit = computed(() => currentRole.value !== 'viewer' && !state.lockedBy);
|
||||||
|
|
||||||
|
let lib: DocumentLibrary | null = null;
|
||||||
|
let channel: BroadcastChannel | null = null;
|
||||||
|
/** content fingerprint of what's saved for the open document */
|
||||||
|
let savedContent = '';
|
||||||
|
/** `updatedAt` of the open local document as this tab last wrote or read it */
|
||||||
|
let knownUpdatedAt = 0;
|
||||||
|
/** local writes run one after another, in order */
|
||||||
|
let writeQueue: Promise<void> = Promise.resolve();
|
||||||
|
const warnedShared = new Set<string>();
|
||||||
|
|
||||||
|
// server document state
|
||||||
|
let uploadTimer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
let uploading: Promise<void> | null = null;
|
||||||
|
let pendingJson: string | null = null;
|
||||||
|
let pendingFp = '';
|
||||||
|
let holdsLock = false;
|
||||||
|
let heartbeatTimer: ReturnType<typeof setInterval> | undefined;
|
||||||
|
let pollTimer: ReturnType<typeof setInterval> | undefined;
|
||||||
|
|
||||||
|
const post = (m: Msg) => channel?.postMessage(m);
|
||||||
|
const enqueue = (job: () => Promise<void>) => {
|
||||||
|
writeQueue = writeQueue.then(job).catch((err) => {
|
||||||
|
console.error(err);
|
||||||
|
toasts.error(`Speichern fehlgeschlagen: ${(err as Error).message}`);
|
||||||
|
});
|
||||||
|
return writeQueue;
|
||||||
|
};
|
||||||
|
const shareToken = (id: string) => state.linkDocs.find((l) => l.id === id)?.token ?? null;
|
||||||
|
const isCurrent = (ref: DocRef) => state.current?.source === ref.source && state.current.id === ref.id;
|
||||||
|
|
||||||
|
async function refreshList() {
|
||||||
|
if (lib) state.docs = await lib.list();
|
||||||
|
}
|
||||||
|
async function refreshServerList() {
|
||||||
|
if (!session.signedIn.value) return;
|
||||||
|
try {
|
||||||
|
state.serverDocs = (await api<{ documents: ServerDocMeta[] }>('GET', '/documents')).documents;
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function uniqueTitle(base: string, taken: string[]): string {
|
||||||
|
const set = new Set(taken.map((t) => t.toLowerCase()));
|
||||||
|
if (!set.has(base.toLowerCase())) return base;
|
||||||
|
for (let n = 2; ; n++) {
|
||||||
|
const t = `${base} ${n}`;
|
||||||
|
if (!set.has(t.toLowerCase())) return t;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setUrl(ref: DocRef, push: boolean) {
|
||||||
|
const url = new URL(location.href);
|
||||||
|
url.hash = '';
|
||||||
|
if (ref.source === 'server') {
|
||||||
|
url.pathname = `/d/${ref.id}`;
|
||||||
|
url.searchParams.delete('doc');
|
||||||
|
} else {
|
||||||
|
url.pathname = '/';
|
||||||
|
url.searchParams.set('doc', ref.id);
|
||||||
|
}
|
||||||
|
if (url.href === location.href) return;
|
||||||
|
if (push) history.pushState(null, '', url);
|
||||||
|
else history.replaceState(null, '', url);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateWindowTitle() {
|
||||||
|
document.title = `${currentTitle.value} – ${APP_TITLE}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyReadOnly() {
|
||||||
|
store.ui.readOnly =
|
||||||
|
state.current?.source !== 'server'
|
||||||
|
? null
|
||||||
|
: currentRole.value === 'viewer'
|
||||||
|
? 'Nur Lesen – du kannst dieses Dokument ansehen, aber nicht bearbeiten.'
|
||||||
|
: state.lockedBy
|
||||||
|
? `${state.lockedBy} bearbeitet gerade – nur Lesen, bis die Bearbeitung beendet ist.`
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Put `next` into the store as the open document. */
|
||||||
|
function show(ref: DocRef, next: DocumentState, keepView = false) {
|
||||||
|
if (keepView && next.sheets.some((s) => s.id === store.doc.activeSheetId)) {
|
||||||
|
// someone else's save: take the content, keep this tab's cursor/sheet
|
||||||
|
next.activeSheetId = store.doc.activeSheetId;
|
||||||
|
next.activeObjectId = store.doc.activeObjectId;
|
||||||
|
next.selection = store.doc.selection;
|
||||||
|
}
|
||||||
|
state.current = ref;
|
||||||
|
store.replaceDocument(next);
|
||||||
|
// it came from where it's saved -- writing it straight back is pointless
|
||||||
|
store.cancelSave();
|
||||||
|
savedContent = contentFingerprint(store.doc);
|
||||||
|
updateWindowTitle();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- persistence -----------------------------------------------------
|
||||||
|
function persist(doc: DocumentState) {
|
||||||
|
const c = state.current;
|
||||||
|
if (!c) return;
|
||||||
|
if (c.source === 'server') return persistServer(doc);
|
||||||
|
if (!lib) return;
|
||||||
|
const id = c.id;
|
||||||
|
const json = JSON.stringify(doc);
|
||||||
|
const fp = contentFingerprint(doc);
|
||||||
|
const changed = fp !== savedContent;
|
||||||
|
savedContent = fp;
|
||||||
|
void enqueue(async () => {
|
||||||
|
const meta = (await lib!.getMeta(id)) ?? {
|
||||||
|
id,
|
||||||
|
title: currentTitle.value,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
};
|
||||||
|
if (!changed) {
|
||||||
|
// only the cursor/active sheet moved: saved so reopening restores
|
||||||
|
// it, but never over newer content another tab has written meanwhile
|
||||||
|
if (meta.updatedAt !== knownUpdatedAt) return;
|
||||||
|
await lib!.put(meta, json);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const updatedAt = Math.max(Date.now(), meta.updatedAt + 1);
|
||||||
|
await lib!.put({ ...meta, updatedAt }, json);
|
||||||
|
if (isCurrent({ source: 'local', id })) knownUpdatedAt = updatedAt;
|
||||||
|
await refreshList();
|
||||||
|
post({ type: 'saved', id });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function persistServer(doc: DocumentState) {
|
||||||
|
if (!canEdit.value) return;
|
||||||
|
const fp = contentFingerprint(doc);
|
||||||
|
if (fp === savedContent && pendingJson === null) return; // cursor move only
|
||||||
|
pendingJson = JSON.stringify(doc);
|
||||||
|
pendingFp = fp;
|
||||||
|
clearTimeout(uploadTimer);
|
||||||
|
uploadTimer = setTimeout(() => void upload(), UPLOAD_DELAY);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Send the pending change of the open server document (one at a time). */
|
||||||
|
async function upload(): Promise<void> {
|
||||||
|
clearTimeout(uploadTimer);
|
||||||
|
uploadTimer = undefined;
|
||||||
|
if (uploading) {
|
||||||
|
await uploading;
|
||||||
|
if (pendingJson !== null) return upload();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const c = state.current;
|
||||||
|
const meta = state.server;
|
||||||
|
if (!c || c.source !== 'server' || !meta || pendingJson === null) return;
|
||||||
|
const json = pendingJson;
|
||||||
|
const fp = pendingFp;
|
||||||
|
pendingJson = null;
|
||||||
|
state.syncState = 'saving';
|
||||||
|
uploading = (async () => {
|
||||||
|
try {
|
||||||
|
const r = await api<{ document: ServerDocMeta }>('PATCH', `/documents/${c.id}`, {
|
||||||
|
body: { content: json, base_version: meta.version },
|
||||||
|
shareToken: shareToken(c.id),
|
||||||
|
});
|
||||||
|
if (isCurrent(c)) {
|
||||||
|
state.server = { ...meta, ...r.document, role: meta.role };
|
||||||
|
savedContent = fp;
|
||||||
|
}
|
||||||
|
state.syncState = 'idle';
|
||||||
|
} catch (err) {
|
||||||
|
await uploadFailed(c, err, json, fp);
|
||||||
|
} finally {
|
||||||
|
uploading = null;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
await uploading;
|
||||||
|
if (pendingJson !== null && isCurrent(c)) return upload();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadFailed(c: DocRef, err: unknown, json: string, fp: string) {
|
||||||
|
if (!(err instanceof ApiError) || err.status === 0 || err.status >= 500) {
|
||||||
|
// offline / server trouble: keep it and try again
|
||||||
|
state.syncState = 'offline';
|
||||||
|
if (pendingJson === null) {
|
||||||
|
pendingJson = json;
|
||||||
|
pendingFp = fp;
|
||||||
|
}
|
||||||
|
clearTimeout(uploadTimer);
|
||||||
|
uploadTimer = setTimeout(() => void upload(), 5000);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
state.syncState = 'idle';
|
||||||
|
// the server's version wins; edits made meanwhile would be based on the
|
||||||
|
// rejected state, so they go too
|
||||||
|
pendingJson = null;
|
||||||
|
clearTimeout(uploadTimer);
|
||||||
|
if (err.status === 423) {
|
||||||
|
holdsLock = false;
|
||||||
|
state.lockedBy = (err.body.holder_name as string) || 'Jemand anderes';
|
||||||
|
toasts.error(`${state.lockedBy} bearbeitet dieses Dokument inzwischen – deine letzte Änderung wurde nicht gespeichert.`);
|
||||||
|
} else if (err.status === 409) {
|
||||||
|
toasts.error('Das Dokument wurde inzwischen anderswo geändert – deine letzte Änderung wurde nicht gespeichert.');
|
||||||
|
} else {
|
||||||
|
toasts.error(`Speichern fehlgeschlagen: ${err.message}`);
|
||||||
|
}
|
||||||
|
if (isCurrent(c)) await reloadServer(c.id, true);
|
||||||
|
startServerTimers();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Before leaving the open server document: send what's pending. */
|
||||||
|
async function flushServer() {
|
||||||
|
store.flushSave();
|
||||||
|
if (pendingJson !== null || uploading) await upload();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- edit lock + polling ---------------------------------------------
|
||||||
|
function stopServerTimers() {
|
||||||
|
clearInterval(heartbeatTimer);
|
||||||
|
clearInterval(pollTimer);
|
||||||
|
heartbeatTimer = pollTimer = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function startServerTimers() {
|
||||||
|
stopServerTimers();
|
||||||
|
if (state.current?.source !== 'server') return;
|
||||||
|
if (holdsLock) heartbeatTimer = setInterval(() => void heartbeat(), HEARTBEAT);
|
||||||
|
else pollTimer = setInterval(() => void poll(), POLL);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Try to take the edit lock of the open server document. */
|
||||||
|
async function takeLock(id: string): Promise<boolean> {
|
||||||
|
if (currentRole.value === 'viewer') return false;
|
||||||
|
try {
|
||||||
|
const r = await api<{ lock: { mine: boolean; holder_name: string | null } }>('POST', `/documents/${id}/lock`, {
|
||||||
|
shareToken: shareToken(id),
|
||||||
|
});
|
||||||
|
holdsLock = r.lock.mine;
|
||||||
|
state.lockedBy = r.lock.mine ? null : r.lock.holder_name || 'Jemand anderes';
|
||||||
|
} catch {
|
||||||
|
holdsLock = false;
|
||||||
|
}
|
||||||
|
applyReadOnly();
|
||||||
|
return holdsLock;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function heartbeat() {
|
||||||
|
const c = state.current;
|
||||||
|
if (c?.source !== 'server') return;
|
||||||
|
const had = holdsLock;
|
||||||
|
await takeLock(c.id);
|
||||||
|
if (had && !holdsLock) {
|
||||||
|
toasts.error(`${state.lockedBy ?? 'Jemand anderes'} bearbeitet dieses Dokument jetzt.`);
|
||||||
|
startServerTimers();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function poll() {
|
||||||
|
const c = state.current;
|
||||||
|
if (c?.source !== 'server' || document.visibilityState === 'hidden') return;
|
||||||
|
if (currentRole.value !== 'viewer' && (await takeLock(c.id))) {
|
||||||
|
await reloadServer(c.id, true);
|
||||||
|
toasts.info('Du kannst das Dokument jetzt bearbeiten.');
|
||||||
|
startServerTimers();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await reloadServer(c.id, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function releaseLock(id: string) {
|
||||||
|
if (!holdsLock) return;
|
||||||
|
holdsLock = false;
|
||||||
|
void api('DELETE', `/documents/${id}/lock`, { shareToken: shareToken(id), keepalive: true }).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fetch the server document again; with `keepView` only when it changed. */
|
||||||
|
async function reloadServer(id: string, keepView: boolean) {
|
||||||
|
const known = keepView ? state.server?.version : undefined;
|
||||||
|
try {
|
||||||
|
const q = known ? `?known_version=${known}` : '';
|
||||||
|
const r = await api<{ document: ServerDoc; unchanged?: boolean }>('GET', `/documents/${id}${q}`, {
|
||||||
|
shareToken: shareToken(id),
|
||||||
|
});
|
||||||
|
if (!isCurrent({ source: 'server', id })) return;
|
||||||
|
const { content } = r.document;
|
||||||
|
state.server = metaOf(r.document);
|
||||||
|
if (!r.unchanged && content !== undefined) {
|
||||||
|
show({ source: 'server', id }, validateDocument(JSON.parse(content)), keepView);
|
||||||
|
}
|
||||||
|
updateWindowTitle();
|
||||||
|
applyReadOnly();
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ApiError && err.status === 404) {
|
||||||
|
toasts.error('Kein Zugriff mehr auf dieses Dokument.');
|
||||||
|
await openFallback();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- leaving / opening -----------------------------------------------
|
||||||
|
async function leaveCurrent() {
|
||||||
|
const c = state.current;
|
||||||
|
if (!c) return;
|
||||||
|
if (c.source === 'server') {
|
||||||
|
await flushServer();
|
||||||
|
stopServerTimers();
|
||||||
|
releaseLock(c.id);
|
||||||
|
state.lockedBy = null;
|
||||||
|
state.server = null;
|
||||||
|
} else {
|
||||||
|
store.flushSave();
|
||||||
|
await writeQueue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openLocal(id: string, opts: { push?: boolean } = {}): Promise<boolean> {
|
||||||
|
if (!lib) return false;
|
||||||
|
if (isCurrent({ source: 'local', id })) return true;
|
||||||
|
const [meta, json] = await Promise.all([lib.getMeta(id), lib.getContent(id)]);
|
||||||
|
if (!meta || json === undefined) return false;
|
||||||
|
let next: DocumentState;
|
||||||
|
try {
|
||||||
|
next = validateDocument(JSON.parse(json));
|
||||||
|
} catch (err) {
|
||||||
|
toasts.error(`Dokument konnte nicht geladen werden: ${(err as Error).message}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
await leaveCurrent();
|
||||||
|
show({ source: 'local', id }, next);
|
||||||
|
knownUpdatedAt = meta.updatedAt;
|
||||||
|
applyReadOnly();
|
||||||
|
setUrl({ source: 'local', id }, opts.push ?? true);
|
||||||
|
post({ type: 'hello', id });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openServer(id: string, opts: { push?: boolean } = {}): Promise<boolean> {
|
||||||
|
if (isCurrent({ source: 'server', id })) return true;
|
||||||
|
let r: { document: ServerDoc };
|
||||||
|
try {
|
||||||
|
r = await api<{ document: ServerDoc }>('GET', `/documents/${id}`, { shareToken: shareToken(id) });
|
||||||
|
} catch (err) {
|
||||||
|
toasts.error(
|
||||||
|
err instanceof ApiError && err.status === 404
|
||||||
|
? 'Dieses Dokument gibt es nicht, oder du hast keinen Zugriff darauf.'
|
||||||
|
: `Dokument konnte nicht geladen werden: ${(err as Error).message}`,
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const { content } = r.document;
|
||||||
|
const meta = metaOf(r.document);
|
||||||
|
let next: DocumentState;
|
||||||
|
try {
|
||||||
|
next = validateDocument(JSON.parse(content ?? ''));
|
||||||
|
} catch (err) {
|
||||||
|
toasts.error(`Dokument konnte nicht geladen werden: ${(err as Error).message}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
await leaveCurrent();
|
||||||
|
state.server = meta;
|
||||||
|
state.lockedBy = null;
|
||||||
|
holdsLock = false;
|
||||||
|
show({ source: 'server', id }, next);
|
||||||
|
// remember the title for link-only documents
|
||||||
|
const link = state.linkDocs.find((l) => l.id === id);
|
||||||
|
if (link && (link.title !== meta.title || link.role !== meta.role)) {
|
||||||
|
Object.assign(link, { title: meta.title, role: meta.role });
|
||||||
|
writeLinks(state.linkDocs);
|
||||||
|
}
|
||||||
|
applyReadOnly();
|
||||||
|
if (meta.role !== 'viewer') await takeLock(id);
|
||||||
|
startServerTimers();
|
||||||
|
setUrl({ source: 'server', id }, opts.push ?? true);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function open(ref: DocRef, opts: { push?: boolean } = {}) {
|
||||||
|
return ref.source === 'server' ? openServer(ref.id, opts) : openLocal(ref.id, opts);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- creating --------------------------------------------------------
|
||||||
|
/** Add a document and open it: on the server when signed in, else locally. */
|
||||||
|
async function create(doc: DocumentState, title: string): Promise<DocRef | null> {
|
||||||
|
const t = title.trim() || DEFAULT_TITLE;
|
||||||
|
if (session.signedIn.value) {
|
||||||
|
try {
|
||||||
|
const r = await api<{ document: ServerDocMeta }>('POST', '/documents', {
|
||||||
|
body: { title: uniqueTitle(t, state.serverDocs.map((d) => d.title)), content: JSON.stringify(doc) },
|
||||||
|
});
|
||||||
|
await refreshServerList();
|
||||||
|
post({ type: 'list' });
|
||||||
|
await openServer(r.document.id);
|
||||||
|
return { source: 'server', id: r.document.id };
|
||||||
|
} catch (err) {
|
||||||
|
toasts.error(`Anlegen auf dem Server fehlgeschlagen: ${(err as Error).message}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!lib) {
|
||||||
|
store.replaceDocument(doc); // no storage at all: at least show it
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const id = newDocId();
|
||||||
|
const now = Date.now();
|
||||||
|
await enqueue(() =>
|
||||||
|
lib!.put({ id, title: uniqueTitle(t, state.docs.map((d) => d.title)), createdAt: now, updatedAt: now }, JSON.stringify(doc)),
|
||||||
|
);
|
||||||
|
await refreshList();
|
||||||
|
post({ type: 'list' });
|
||||||
|
await openLocal(id);
|
||||||
|
return { source: 'local', id };
|
||||||
|
}
|
||||||
|
|
||||||
|
const createBlank = () => create(createBlankDocument(), DEFAULT_TITLE);
|
||||||
|
const createSample = () => create(createSeedDocument(), 'Muster · Umsatzanalyse');
|
||||||
|
|
||||||
|
// ---- managing ----------------------------------------------------------
|
||||||
|
async function rename(ref: DocRef, title: string) {
|
||||||
|
const t = title.trim();
|
||||||
|
if (!t) return;
|
||||||
|
if (ref.source === 'server') {
|
||||||
|
try {
|
||||||
|
const r = await api<{ document: ServerDocMeta }>('PATCH', `/documents/${ref.id}`, {
|
||||||
|
body: { title: t },
|
||||||
|
shareToken: shareToken(ref.id),
|
||||||
|
});
|
||||||
|
if (isCurrent(ref) && state.server) state.server = { ...state.server, title: r.document.title };
|
||||||
|
await refreshServerList();
|
||||||
|
} catch (err) {
|
||||||
|
toasts.error(`Umbenennen fehlgeschlagen: ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (!lib) return;
|
||||||
|
await enqueue(() => lib!.patchMeta(ref.id, { title: t }));
|
||||||
|
await refreshList();
|
||||||
|
}
|
||||||
|
if (isCurrent(ref)) updateWindowTitle();
|
||||||
|
post({ type: 'list' });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function contentOf(ref: DocRef): Promise<{ title: string; json: string } | null> {
|
||||||
|
if (isCurrent(ref)) {
|
||||||
|
if (ref.source === 'server') await flushServer();
|
||||||
|
else {
|
||||||
|
store.flushSave();
|
||||||
|
await writeQueue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (ref.source === 'server') {
|
||||||
|
const r = await api<{ document: ServerDoc }>('GET', `/documents/${ref.id}`, { shareToken: shareToken(ref.id) });
|
||||||
|
return r.document.content === undefined ? null : { title: r.document.title, json: r.document.content };
|
||||||
|
}
|
||||||
|
if (!lib) return null;
|
||||||
|
const [meta, json] = await Promise.all([lib.getMeta(ref.id), lib.getContent(ref.id)]);
|
||||||
|
return meta && json !== undefined ? { title: meta.title, json } : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A copy next to the original (server copies go to the own account). */
|
||||||
|
async function duplicate(ref: DocRef) {
|
||||||
|
try {
|
||||||
|
const src = await contentOf(ref);
|
||||||
|
if (!src) return;
|
||||||
|
const title = `${src.title} (Kopie)`;
|
||||||
|
if (ref.source === 'server') {
|
||||||
|
await api('POST', '/documents', {
|
||||||
|
body: { title: uniqueTitle(title, state.serverDocs.map((d) => d.title)), content: src.json },
|
||||||
|
});
|
||||||
|
await refreshServerList();
|
||||||
|
} else if (lib) {
|
||||||
|
const now = Date.now();
|
||||||
|
await enqueue(() =>
|
||||||
|
lib!.put(
|
||||||
|
{ id: newDocId(), title: uniqueTitle(title, state.docs.map((d) => d.title)), createdAt: now, updatedAt: now },
|
||||||
|
src.json,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await refreshList();
|
||||||
|
}
|
||||||
|
post({ type: 'list' });
|
||||||
|
} catch (err) {
|
||||||
|
toasts.error(`Duplizieren fehlgeschlagen: ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Delete (own / local) or leave (shared with me / link). */
|
||||||
|
async function remove(ref: DocRef) {
|
||||||
|
if (ref.source === 'server') {
|
||||||
|
const wasCurrent = isCurrent(ref);
|
||||||
|
if (wasCurrent) await leaveCurrent();
|
||||||
|
if (session.signedIn.value) {
|
||||||
|
try {
|
||||||
|
await api('DELETE', `/documents/${ref.id}`);
|
||||||
|
} catch (err) {
|
||||||
|
if (!(err instanceof ApiError && err.status === 404)) {
|
||||||
|
toasts.error(`Löschen fehlgeschlagen: ${(err as Error).message}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
state.linkDocs = state.linkDocs.filter((l) => l.id !== ref.id);
|
||||||
|
writeLinks(state.linkDocs);
|
||||||
|
await refreshServerList();
|
||||||
|
post({ type: 'list' });
|
||||||
|
if (wasCurrent) {
|
||||||
|
state.current = null;
|
||||||
|
await openFallback();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!lib) return;
|
||||||
|
await enqueue(() => lib!.remove(ref.id));
|
||||||
|
await refreshList();
|
||||||
|
post({ type: 'deleted', id: ref.id });
|
||||||
|
if (isCurrent(ref)) {
|
||||||
|
state.current = null;
|
||||||
|
await openFallback();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Move local documents into the account (server). Each one is removed from
|
||||||
|
* this browser once it's safely on the server; an open one is reopened
|
||||||
|
* from there. Returns the server id of each uploaded document.
|
||||||
|
*/
|
||||||
|
async function upload_(localIds: string[]): Promise<Map<string, string>> {
|
||||||
|
const done = new Map<string, string>();
|
||||||
|
if (!lib || !session.signedIn.value) return done;
|
||||||
|
for (const id of localIds) {
|
||||||
|
try {
|
||||||
|
const src = await contentOf({ source: 'local', id });
|
||||||
|
if (!src) continue;
|
||||||
|
const r = await api<{ document: ServerDocMeta }>('POST', '/documents', {
|
||||||
|
body: { title: src.title, content: src.json },
|
||||||
|
});
|
||||||
|
done.set(id, r.document.id);
|
||||||
|
const wasCurrent = isCurrent({ source: 'local', id });
|
||||||
|
if (wasCurrent) await openServer(r.document.id, { push: false });
|
||||||
|
await enqueue(() => lib!.remove(id));
|
||||||
|
post({ type: 'deleted', id });
|
||||||
|
} catch (err) {
|
||||||
|
toasts.error(`Hochladen fehlgeschlagen: ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await Promise.all([refreshList(), refreshServerList()]);
|
||||||
|
post({ type: 'list' });
|
||||||
|
return done;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Most recently changed document, or a new blank one. */
|
||||||
|
async function openFallback() {
|
||||||
|
state.current = null;
|
||||||
|
const server = state.serverDocs[0];
|
||||||
|
const local = state.docs[0];
|
||||||
|
const serverNewer = server && (!local || Date.parse(server.updated_at) > local.updatedAt);
|
||||||
|
if (serverNewer && (await openServer(server.id, { push: false }))) return;
|
||||||
|
if (local && (await openLocal(local.id, { push: false }))) return;
|
||||||
|
await createBlank();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- share links -------------------------------------------------------
|
||||||
|
/** Redeem `#k=<token>` for document `id` (from the URL). */
|
||||||
|
async function joinByLink(id: string, token: string) {
|
||||||
|
try {
|
||||||
|
const r = await api<{ document: ServerDocMeta }>('POST', `/documents/${id}/join`, { body: { token } });
|
||||||
|
if (session.signedIn.value) {
|
||||||
|
await refreshServerList(); // it's in the account now
|
||||||
|
} else {
|
||||||
|
state.linkDocs = [
|
||||||
|
{ id, token, title: r.document.title, role: r.document.role },
|
||||||
|
...state.linkDocs.filter((l) => l.id !== id),
|
||||||
|
];
|
||||||
|
writeLinks(state.linkDocs);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toasts.error('Dieser Link ist nicht (mehr) gültig.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** After signing in: documents opened via link become part of the account. */
|
||||||
|
async function adoptLinkDocs() {
|
||||||
|
if (!session.signedIn.value || !state.linkDocs.length) return;
|
||||||
|
for (const l of state.linkDocs) {
|
||||||
|
await api('POST', `/documents/${l.id}/join`, { body: { token: l.token } }).catch(() => {});
|
||||||
|
}
|
||||||
|
state.linkDocs = [];
|
||||||
|
writeLinks([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- other tabs --------------------------------------------------------
|
||||||
|
async function onMessage(m: Msg) {
|
||||||
|
if (m.type === 'list') {
|
||||||
|
await Promise.all([refreshList(), refreshServerList()]);
|
||||||
|
state.linkDocs = readLinks();
|
||||||
|
updateWindowTitle();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const cur = state.current?.source === 'local' ? state.current.id : null;
|
||||||
|
if (m.type === 'saved') {
|
||||||
|
await refreshList();
|
||||||
|
// our own unsaved edit wins (it's written right after); otherwise
|
||||||
|
// follow -- a pending save that's only a cursor move doesn't count
|
||||||
|
const localEdit = store.hasPendingSave() && contentFingerprint(store.doc) !== savedContent;
|
||||||
|
if (m.id === cur && !localEdit && lib) {
|
||||||
|
const [meta, json] = await Promise.all([lib.getMeta(m.id), lib.getContent(m.id)]);
|
||||||
|
if (meta && json !== undefined && isCurrent({ source: 'local', id: m.id })) {
|
||||||
|
show({ source: 'local', id: m.id }, validateDocument(JSON.parse(json)), true);
|
||||||
|
knownUpdatedAt = meta.updatedAt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (m.type === 'deleted') {
|
||||||
|
await refreshList();
|
||||||
|
if (m.id === cur) {
|
||||||
|
toasts.info('Dieses Dokument ist nicht mehr in diesem Browser (gelöscht oder hochgeladen).');
|
||||||
|
state.current = null;
|
||||||
|
await refreshServerList();
|
||||||
|
await openFallback();
|
||||||
|
}
|
||||||
|
} else if (m.id === cur) {
|
||||||
|
// hello / here: the same local document is open in another tab
|
||||||
|
if (m.type === 'hello') post({ type: 'here', id: m.id });
|
||||||
|
if (!warnedShared.has(m.id)) {
|
||||||
|
warnedShared.add(m.id);
|
||||||
|
toasts.info(
|
||||||
|
'Dieses Dokument ist auch in einem anderen Tab geöffnet. Gleichzeitige Änderungen können sich überschreiben.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- start -------------------------------------------------------------
|
||||||
|
async function migrateLegacy() {
|
||||||
|
for (const key of LEGACY_KEYS) {
|
||||||
|
let raw: string | null = null;
|
||||||
|
try {
|
||||||
|
raw = localStorage.getItem(key);
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!raw) continue;
|
||||||
|
try {
|
||||||
|
const doc = validateDocument(JSON.parse(raw));
|
||||||
|
const now = Date.now();
|
||||||
|
await lib!.put({ id: newDocId(), title: 'Mein Dokument', createdAt: now, updatedAt: now }, JSON.stringify(doc));
|
||||||
|
} catch {
|
||||||
|
continue; // corrupt -- leave it where it is
|
||||||
|
}
|
||||||
|
for (const k of LEGACY_KEYS) localStorage.removeItem(k);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What the URL asks for: `/d/<id>[#k=<token>]` or `/?doc=<id>`. */
|
||||||
|
function refFromUrl(): { ref: DocRef | null; token: string | null } {
|
||||||
|
const m = location.pathname.match(/^\/d\/([0-9a-f-]{36})\/?$/i);
|
||||||
|
if (m) {
|
||||||
|
const token = new URLSearchParams(location.hash.slice(1)).get('k');
|
||||||
|
return { ref: { source: 'server', id: m[1] }, token };
|
||||||
|
}
|
||||||
|
const id = new URL(location.href).searchParams.get('doc');
|
||||||
|
return { ref: id ? { source: 'local', id } : null, token: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function init() {
|
||||||
|
setReadOnlyNotifier((why) => toasts.info(why));
|
||||||
|
try {
|
||||||
|
lib = await DocumentLibrary.open();
|
||||||
|
await migrateLegacy();
|
||||||
|
await refreshList();
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
lib = null;
|
||||||
|
state.available = false;
|
||||||
|
if (!session.signedIn.value) {
|
||||||
|
toasts.error('Dokumente können in diesem Browser nicht gespeichert werden (IndexedDB nicht verfügbar).');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await adoptLinkDocs();
|
||||||
|
await refreshServerList();
|
||||||
|
|
||||||
|
if (typeof BroadcastChannel !== 'undefined') {
|
||||||
|
channel = new BroadcastChannel(CHANNEL);
|
||||||
|
channel.onmessage = (e: MessageEvent<Msg>) => void onMessage(e.data);
|
||||||
|
}
|
||||||
|
setPersistHandler(persist);
|
||||||
|
|
||||||
|
const { ref, token } = refFromUrl();
|
||||||
|
if (ref?.source === 'server' && token) await joinByLink(ref.id, token);
|
||||||
|
if (ref && !(await open(ref, { push: false })) && ref.source === 'local') {
|
||||||
|
toasts.error('Das angeforderte Dokument gibt es in diesem Browser nicht.');
|
||||||
|
}
|
||||||
|
if (!state.current) await openFallback();
|
||||||
|
|
||||||
|
window.addEventListener('popstate', () => {
|
||||||
|
const { ref: r } = refFromUrl();
|
||||||
|
if (r && !isCurrent(r)) void open(r, { push: false });
|
||||||
|
});
|
||||||
|
// don't lose the last moments of typing when the tab goes away
|
||||||
|
window.addEventListener('pagehide', () => {
|
||||||
|
store.flushSave();
|
||||||
|
if (state.current?.source === 'server') {
|
||||||
|
if (pendingJson !== null) void upload();
|
||||||
|
releaseLock(state.current.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
window.addEventListener('beforeunload', (e) => {
|
||||||
|
if (pendingJson !== null || uploading) e.preventDefault(); // unsaved server changes
|
||||||
|
});
|
||||||
|
document.addEventListener('visibilitychange', () => {
|
||||||
|
if (document.visibilityState === 'hidden') store.flushSave();
|
||||||
|
});
|
||||||
|
state.ready = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
state,
|
||||||
|
currentTitle,
|
||||||
|
currentRole,
|
||||||
|
canEdit,
|
||||||
|
init,
|
||||||
|
open,
|
||||||
|
create,
|
||||||
|
createBlank,
|
||||||
|
createSample,
|
||||||
|
rename,
|
||||||
|
duplicate,
|
||||||
|
remove,
|
||||||
|
upload: upload_,
|
||||||
|
refreshServerList,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let instance: ReturnType<typeof createLibrary> | null = null;
|
||||||
|
|
||||||
|
export function useLibrary() {
|
||||||
|
if (!instance) instance = createLibrary();
|
||||||
|
return instance;
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
/**
|
||||||
|
* Who is signed in. An account is optional: without one, documents stay in
|
||||||
|
* this browser (IndexedDB); with one, they live on the server and can be
|
||||||
|
* shared. Sign-in is by magic link or the 6-digit code from the same mail.
|
||||||
|
*/
|
||||||
|
import { computed, reactive } from 'vue';
|
||||||
|
import { api, bootData, type User } from '../io/api';
|
||||||
|
|
||||||
|
const state = reactive({ user: bootData().user as User | null });
|
||||||
|
|
||||||
|
export function useSession() {
|
||||||
|
const signedIn = computed(() => !!state.user);
|
||||||
|
|
||||||
|
/** Mail a sign-in link + code. `returnTo` = where the link leads back to. */
|
||||||
|
async function requestLink(email: string, returnTo = location.pathname + location.search) {
|
||||||
|
await api('POST', '/auth/magic_link', { body: { email, return_to: returnTo } });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sign in with the code. The page reloads, so everything starts signed in. */
|
||||||
|
async function verifyCode(email: string, code: string) {
|
||||||
|
await api<{ redirect: string }>('POST', '/auth/code', { body: { email, code } });
|
||||||
|
location.reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function logout() {
|
||||||
|
await api('DELETE', '/auth/session');
|
||||||
|
location.assign('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setDisplayName(display_name: string) {
|
||||||
|
const r = await api<{ user: User }>('PATCH', '/me', { body: { display_name } });
|
||||||
|
state.user = r.user;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { state, signedIn, requestLink, verifyCode, logout, setDisplayName };
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
module ApplicationHelper
|
||||||
|
end
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
class ApplicationJob < ActiveJob::Base
|
||||||
|
# Automatically retry jobs that encountered a deadlock
|
||||||
|
# retry_on ActiveRecord::Deadlocked
|
||||||
|
|
||||||
|
# Most jobs are safe to ignore if the underlying records are no longer available
|
||||||
|
# discard_on ActiveJob::DeserializationError
|
||||||
|
end
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user