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:
wkh
2026-09-28 20:09:06 +02:00
co-authored by Claude Opus 5.5
parent 5a0f1fcd78
commit e14bada114
190 changed files with 6077 additions and 722 deletions
+3
View File
@@ -0,0 +1,3 @@
<h1>Link nicht mehr gültig</h1>
<p>Dieser Link wurde schon verwendet oder ist abgelaufen. Fordere in numolus einfach einen neuen an.</p>
<a class="btn" href="/">Zu numolus</a>
+11
View File
@@ -0,0 +1,11 @@
<% if @link.invitation? %>
<h1>Einladung annehmen</h1>
<p><strong><%= @link.invited_by.name %></strong> hat dich zu „<%= @link.document.title %>“ eingeladen. Du wirst dabei als <%= @link.email %> angemeldet.</p>
<% else %>
<h1>Bei numolus anmelden</h1>
<p>Du meldest dich als <strong><%= @link.email %></strong> an.</p>
<% end %>
<%= form_with url: verify_auth_path, method: :post do %>
<%= hidden_field_tag :token, @token %>
<button type="submit"><%= @link.invitation? ? "Annehmen und öffnen" : "Anmelden" %></button>
<% end %>
@@ -0,0 +1,4 @@
<p>Hallo,</p>
<p><strong><%= @inviter %></strong> hat das Dokument „<%= @title %>“ mit dir geteilt – du kannst es <%= @role %>.</p>
<p><a href="<%= @url %>">Dokument öffnen</a></p>
<p style="color:#6e6e73">Der Link ist 7&nbsp;Tage gültig und meldet dich gleichzeitig bei numolus an.</p>
@@ -0,0 +1,7 @@
Hallo,
<%= @inviter %> hat das Dokument „<%= @title %>“ mit dir geteilt – du kannst es <%= @role %>.
<%= @url %>
Der Link ist 7 Tage gültig und meldet dich gleichzeitig bei numolus an.
@@ -0,0 +1,5 @@
<p>Hallo,</p>
<p>mit diesem Link meldest du dich bei numolus an (gültig 15&nbsp;Minuten, einmal verwendbar):</p>
<p><a href="<%= @url %>">Bei numolus anmelden</a></p>
<p>Oder gib diesen Code in numolus ein: <strong style="font-size:18px;letter-spacing:2px"><%= @code %></strong></p>
<p style="color:#6e6e73">Falls du das nicht angefordert hast, kannst du diese Mail ignorieren.</p>
@@ -0,0 +1,9 @@
Hallo,
mit diesem Link meldest du dich bei numolus an (gültig 15 Minuten, einmal verwendbar):
<%= @url %>
Oder gib diesen Code in numolus ein: <%= @code %>
Falls du das nicht angefordert hast, kannst du diese Mail ignorieren.
+30
View File
@@ -0,0 +1,30 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<meta name="robots" content="noindex">
<title>Anmelden – numolus</title>
<%= csrf_meta_tags %>
<style>
:root { --bg: #f5f5f7; --surface: #fff; --text: #1d1d1f; --dim: #6e6e73; --accent: #0a84ff; --border: #d2d2d7; }
@media (prefers-color-scheme: dark) { :root { --bg: #1c1c1e; --surface: #2c2c2e; --text: #f5f5f7; --dim: #a1a1a6; --border: #3a3a3c; } }
body { margin: 0; min-height: 100vh; display: grid; place-items: center; background: var(--bg); color: var(--text);
font: 15px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; padding: 16px; box-sizing: border-box; }
.card { width: min(400px, 100%); background: var(--surface); border: 1px solid var(--border); border-radius: 14px; padding: 28px; box-sizing: border-box; }
h1 { font-size: 20px; margin: 0 0 8px; }
p { margin: 0 0 18px; color: var(--dim); }
button, .btn { display: inline-block; border: 0; border-radius: 9px; background: var(--accent); color: #fff; font: inherit; font-weight: 600;
padding: 10px 18px; cursor: pointer; text-decoration: none; }
.brand { display: flex; align-items: center; gap: 8px; font-weight: 600; margin-bottom: 20px; }
.brand i { display: inline-block; width: 14px; height: 14px; }
</style>
</head>
<body>
<main class="card">
<div class="brand"><i style="background:#FFD400"></i><i style="background:#0057FF"></i><i style="background:#E8384F"></i> numolus</div>
<%= yield %>
</main>
</body>
</html>
+13
View File
@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
<style>
/* Email styles need to be inline */
</style>
</head>
<body>
<%= yield %>
</body>
</html>
+1
View File
@@ -0,0 +1 @@
<%= yield %>
+22
View File
@@ -0,0 +1,22 @@
{
"name": "Numolus",
"icons": [
{
"src": "/icon.png",
"type": "image/png",
"sizes": "512x512"
},
{
"src": "/icon.png",
"type": "image/png",
"sizes": "512x512",
"purpose": "maskable"
}
],
"start_url": "/",
"display": "standalone",
"scope": "/",
"description": "Numolus.",
"theme_color": "red",
"background_color": "red"
}
+26
View File
@@ -0,0 +1,26 @@
// Add a service worker for processing Web Push notifications:
//
// self.addEventListener("push", async (event) => {
// const { title, options } = await event.data.json()
// event.waitUntil(self.registration.showNotification(title, options))
// })
//
// self.addEventListener("notificationclick", function(event) {
// event.notification.close()
// event.waitUntil(
// clients.matchAll({ type: "window" }).then((clientList) => {
// for (let i = 0; i < clientList.length; i++) {
// let client = clientList[i]
// let clientPath = (new URL(client.url)).pathname
//
// if (clientPath == event.notification.data.path && "focus" in client) {
// return client.focus()
// }
// }
//
// if (clients.openWindow) {
// return clients.openWindow(event.notification.data.path)
// }
// })
// )
// })
+59
View File
@@ -0,0 +1,59 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="color-scheme" content="light dark">
<meta name="theme-color" content="#f5f5f7" media="(prefers-color-scheme: light)">
<meta name="theme-color" content="#1c1c1e" media="(prefers-color-scheme: dark)">
<meta
name="description"
content="Numolus – browserbasierte Tabellenkalkulation nach dem Vorbild von Apple Numbers, mit selbst geschriebener Formel-Engine. wicom, Vaduz (FL)."
>
<meta name="author" content="Karl-Heinz Wild">
<meta name="publisher" content="wicom – Webentwicklung & digitale Lösungen">
<meta name="copyright" content="© wicom, Birkenweg 6, FL-9490 Vaduz – https://wicom.li">
<meta name="generator" content="Vue 3 + Vite + Rails">
<%= csrf_meta_tags %>
<meta name="robots" content="index, follow">
<meta name="contact" content="office@wicom.li">
<link rel="author" href="https://wicom.li">
<link rel="publisher" href="https://wicom.li">
<link rel="canonical" href="https://wicom.li/">
<!-- Open Graph -->
<meta property="og:type" content="website">
<meta property="og:site_name" content="wicom – Webentwicklung & digitale Lösungen">
<meta property="og:locale" content="de_LI">
<meta property="og:title" content="Numolus · Tabellenkalkulation">
<meta
property="og:description"
content="Tabellenkalkulation à la Apple Numbers mit eigener Formel-Engine – von wicom, Vaduz (FL)."
>
<meta property="og:url" content="https://wicom.li/">
<meta property="og:image" content="https://wicom.li/numolus-logo.svg">
<!-- Twitter / X Card -->
<meta name="twitter:card" content="summary">
<meta name="twitter:title" content="Numolus · Tabellenkalkulation">
<meta
name="twitter:description"
content="Tabellenkalkulation à la Apple Numbers mit eigener Formel-Engine – wicom (FL)."
>
<meta name="twitter:image" content="https://wicom.li/numolus-logo.svg">
<link
rel="icon"
type="image/svg+xml"
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect x='0' y='0' width='20' height='20' fill='%23FFD400'/%3E%3Crect x='6' y='6' width='20' height='20' fill='%230057FF'/%3E%3Crect x='12' y='12' width='20' height='20' fill='%23E8384F'/%3E%3C/svg%3E"
>
<title>Numolus · Tabellenkalkulation — wicom</title>
</head>
<body>
<div id="app"></div>
<script id="numolus-boot" type="application/json"><%= raw json_escape(@boot.to_json) %></script>
<%= vite_client_tag %>
<%= vite_typescript_tag "application" %>
</body>
</html>