mirror of
https://github.com/priyanshujain/margin.git
synced 2026-10-02 19:17:03 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b4097cc47f | ||
|
|
72d698e427 | ||
|
|
8b83f80d52 | ||
|
|
39609fbd09 | ||
|
|
3177b2f1eb | ||
|
|
19c4823d3d |
No files matched your search
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "margin-app",
|
"name": "margin-app",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.1.2",
|
"version": "0.1.3",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
Generated
+1
-1
@@ -4704,7 +4704,7 @@ checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "margin-app"
|
name = "margin-app"
|
||||||
version = "0.1.1"
|
version = "0.1.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"harper-core",
|
"harper-core",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "margin-app"
|
name = "margin-app"
|
||||||
version = "0.1.2"
|
version = "0.1.3"
|
||||||
description = "A Tauri App"
|
description = "A Tauri App"
|
||||||
authors = ["you"]
|
authors = ["you"]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|||||||
@@ -176,12 +176,13 @@ fn collect_grammar(harper: &mut Harper, text: &str, chars: &[char], issues: &mut
|
|||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn proof_text(
|
pub async fn proof_text(
|
||||||
app: tauri::AppHandle,
|
app: tauri::AppHandle,
|
||||||
state: tauri::State<'_, ProofState>,
|
|
||||||
text: String,
|
text: String,
|
||||||
spelling: bool,
|
spelling: bool,
|
||||||
grammar: bool,
|
grammar: bool,
|
||||||
) -> Result<Vec<Issue>, String> {
|
) -> Result<Vec<Issue>, String> {
|
||||||
let mut guard = state.inner().lock().map_err(|e| e.to_string())?;
|
tauri::async_runtime::spawn_blocking(move || -> Result<Vec<Issue>, String> {
|
||||||
|
let state = app.state::<ProofState>();
|
||||||
|
let mut guard = state.lock().map_err(|e| e.to_string())?;
|
||||||
if guard.is_none() {
|
if guard.is_none() {
|
||||||
*guard = Some(Engine {
|
*guard = Some(Engine {
|
||||||
speller: None,
|
speller: None,
|
||||||
@@ -206,6 +207,9 @@ pub async fn proof_text(
|
|||||||
collect_grammar(engine.harper.as_mut().unwrap(), &text, &chars, &mut issues);
|
collect_grammar(engine.harper.as_mut().unwrap(), &text, &chars, &mut issues);
|
||||||
}
|
}
|
||||||
Ok(issues)
|
Ok(issues)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "margin",
|
"productName": "margin",
|
||||||
"version": "0.1.2",
|
"version": "0.1.3",
|
||||||
"identifier": "studio.margin.app",
|
"identifier": "studio.margin.app",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "pnpm dev",
|
"beforeDevCommand": "pnpm dev",
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import type { CSSProperties, ReactNode } from "react";
|
||||||
|
import type { Device } from "../devices";
|
||||||
|
import type { Book } from "../model/book";
|
||||||
|
import { CoverArt } from "./CoverArt";
|
||||||
|
|
||||||
|
interface DeviceFrameProps {
|
||||||
|
device: Device;
|
||||||
|
eyebrow?: ReactNode;
|
||||||
|
title?: ReactNode;
|
||||||
|
html?: string;
|
||||||
|
cover?: Book;
|
||||||
|
}
|
||||||
|
|
||||||
|
function frameVars(device: Device): CSSProperties {
|
||||||
|
return {
|
||||||
|
"--dv-bg": device.bg,
|
||||||
|
"--dv-ink": device.ink,
|
||||||
|
"--dv-fs": device.fontSize,
|
||||||
|
"--dv-lh": device.lineHeight,
|
||||||
|
"--dv-mx": device.marginX,
|
||||||
|
"--dv-my": device.marginY,
|
||||||
|
"--dv-bezel": device.bezel,
|
||||||
|
"--dv-bezel-w": device.bezelWidth,
|
||||||
|
"--dv-radius": device.radius,
|
||||||
|
"--dv-aspect": device.aspect,
|
||||||
|
} as CSSProperties;
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusBar() {
|
||||||
|
return (
|
||||||
|
<div className="device-statusbar">
|
||||||
|
<span className="device-clock">9:41</span>
|
||||||
|
<span className="device-batt" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DeviceFrame({ device, eyebrow, title, html, cover }: DeviceFrameProps) {
|
||||||
|
return (
|
||||||
|
<div className="device-stage">
|
||||||
|
<div className="device-frame" data-kind={device.kind} style={frameVars(device)}>
|
||||||
|
<div className="device-screen">
|
||||||
|
{device.kind === "tablet" && <StatusBar />}
|
||||||
|
<div className="device-content">
|
||||||
|
{cover ? (
|
||||||
|
<CoverArt book={cover} />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="device-opener">
|
||||||
|
{eyebrow && <div className="device-eyebrow">{eyebrow}</div>}
|
||||||
|
{title && <div className="device-title">{title}</div>}
|
||||||
|
</div>
|
||||||
|
<div className="device-body" dangerouslySetInnerHTML={{ __html: html ?? "" }} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{device.kind === "eink" && (
|
||||||
|
<div className="device-footer">
|
||||||
|
<span className="device-foot-title">{cover ? device.label : title}</span>
|
||||||
|
<span className="device-batt" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+109
-37
@@ -1,12 +1,15 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { type ReactNode, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { generateHTML } from "@tiptap/core";
|
import { generateHTML } from "@tiptap/core";
|
||||||
import { COVER_ID, useBook } from "../store/useBook";
|
import { COVER_ID, useBook } from "../store/useBook";
|
||||||
import { type TrimSize, bodyNumber, chapterKind } from "../model/book";
|
import { type Chapter, type TrimSize, bodyNumber, chapterKind } from "../model/book";
|
||||||
import { editorExtensions } from "../editor/extensions";
|
import { editorExtensions } from "../editor/extensions";
|
||||||
import { chapterToPdfInputs, coverToPdfInputs } from "../export/typst";
|
import { chapterToPdfInputs, coverToPdfInputs } from "../export/typst";
|
||||||
import { compilePdf, isDesktop } from "../ipc";
|
import { compilePdf, isDesktop } from "../ipc";
|
||||||
|
import { DEVICES, type Device, findDevice } from "../devices";
|
||||||
|
import { usePreviewMode } from "../store/usePreviewMode";
|
||||||
import { PdfPreview } from "./PdfPreview";
|
import { PdfPreview } from "./PdfPreview";
|
||||||
import { CoverArt } from "./CoverArt";
|
import { CoverArt } from "./CoverArt";
|
||||||
|
import { DeviceFrame } from "./DeviceFrame";
|
||||||
|
|
||||||
const TRIMS: { value: TrimSize; label: string }[] = [
|
const TRIMS: { value: TrimSize; label: string }[] = [
|
||||||
{ value: "6x9", label: "6 × 9 in" },
|
{ value: "6x9", label: "6 × 9 in" },
|
||||||
@@ -15,26 +18,77 @@ const TRIMS: { value: TrimSize; label: string }[] = [
|
|||||||
{ value: "a5", label: "A5" },
|
{ value: "a5", label: "A5" },
|
||||||
];
|
];
|
||||||
|
|
||||||
function TrimSelect() {
|
function PreviewSelect() {
|
||||||
const trim = useBook((s) => s.book?.settings.trim ?? "6x9");
|
const trim = useBook((s) => s.book?.settings.trim ?? "6x9");
|
||||||
const setSettings = useBook((s) => s.setSettings);
|
const setSettings = useBook((s) => s.setSettings);
|
||||||
|
const mode = usePreviewMode((s) => s.mode);
|
||||||
|
const setMode = usePreviewMode((s) => s.setMode);
|
||||||
|
const value = mode === "print" ? trim : mode;
|
||||||
return (
|
return (
|
||||||
<select
|
<select
|
||||||
className="trim-select"
|
className="trim-select"
|
||||||
value={trim}
|
value={value}
|
||||||
onChange={(e) => setSettings({ trim: e.target.value as TrimSize })}
|
onChange={(e) => {
|
||||||
title="Trim size"
|
const v = e.target.value;
|
||||||
|
if (findDevice(v)) {
|
||||||
|
setMode(v as Device["id"]);
|
||||||
|
} else {
|
||||||
|
setSettings({ trim: v as TrimSize });
|
||||||
|
setMode("print");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
title="Preview format"
|
||||||
>
|
>
|
||||||
|
<optgroup label="Print">
|
||||||
{TRIMS.map((t) => (
|
{TRIMS.map((t) => (
|
||||||
<option key={t.value} value={t.value}>
|
<option key={t.value} value={t.value}>
|
||||||
{t.label}
|
{t.label}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
|
</optgroup>
|
||||||
|
<optgroup label="Devices">
|
||||||
|
{DEVICES.map((d) => (
|
||||||
|
<option key={d.id} value={d.id}>
|
||||||
|
{d.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</optgroup>
|
||||||
</select>
|
</select>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function DockHead({ meta }: { meta?: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="dock-head">
|
||||||
|
<span className="label">Preview</span>
|
||||||
|
<div className="dock-tools">
|
||||||
|
{meta}
|
||||||
|
<PreviewSelect />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function chapterEyebrow(chapter: Chapter, chapters: Chapter[], idx: number): string {
|
||||||
|
const kind = chapterKind(chapter);
|
||||||
|
return kind === "body" ? `Chapter ${bodyNumber(chapters, idx) ?? ""}` : kind === "front" ? "Front matter" : "Back matter";
|
||||||
|
}
|
||||||
|
|
||||||
|
function useActiveChapter() {
|
||||||
|
const book = useBook((s) => s.book);
|
||||||
|
const activeChapterId = useBook((s) => s.activeChapterId);
|
||||||
|
const chapters = book?.chapters ?? [];
|
||||||
|
const idx = chapters.findIndex((c) => c.id === activeChapterId);
|
||||||
|
const chapter = chapters[idx] ?? chapters[0];
|
||||||
|
const html = useMemo(() => (chapter ? generateHTML(chapter.content, editorExtensions) : ""), [chapter]);
|
||||||
|
const eyebrow = chapter ? chapterEyebrow(chapter, chapters, idx) : "";
|
||||||
|
return { book, coverActive: activeChapterId === COVER_ID, chapters, idx, chapter, html, eyebrow };
|
||||||
|
}
|
||||||
|
|
||||||
export function Dock() {
|
export function Dock() {
|
||||||
|
const mode = usePreviewMode((s) => s.mode);
|
||||||
|
const device = findDevice(mode);
|
||||||
|
if (device) return <DeviceDock device={device} />;
|
||||||
return isDesktop ? <PdfDock /> : <HtmlDock />;
|
return isDesktop ? <PdfDock /> : <HtmlDock />;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,13 +121,7 @@ function PdfDock() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="dock">
|
<section className="dock">
|
||||||
<div className="dock-head">
|
<DockHead meta={busy ? <span className="meta">updating…</span> : null} />
|
||||||
<span className="label">Preview</span>
|
|
||||||
<div className="dock-tools">
|
|
||||||
{busy && <span className="meta">updating…</span>}
|
|
||||||
<TrimSelect />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{error ? (
|
{error ? (
|
||||||
<pre className="dock-error">{error}</pre>
|
<pre className="dock-error">{error}</pre>
|
||||||
) : pdf ? (
|
) : pdf ? (
|
||||||
@@ -86,26 +134,14 @@ function PdfDock() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function HtmlDock() {
|
function HtmlDock() {
|
||||||
const book = useBook((s) => s.book);
|
const { book, coverActive, chapters, idx, chapter, html, eyebrow } = useActiveChapter();
|
||||||
const activeChapterId = useBook((s) => s.activeChapterId);
|
|
||||||
const coverActive = activeChapterId === COVER_ID;
|
|
||||||
const chapters = book?.chapters ?? [];
|
|
||||||
const idx = chapters.findIndex((c) => c.id === activeChapterId);
|
|
||||||
const chapter = chapters[idx] ?? chapters[0];
|
|
||||||
const html = useMemo(() => (chapter ? generateHTML(chapter.content, editorExtensions) : ""), [chapter]);
|
|
||||||
|
|
||||||
if (!book) return null;
|
if (!book) return null;
|
||||||
|
|
||||||
if (coverActive) {
|
if (coverActive) {
|
||||||
return (
|
return (
|
||||||
<section className="dock">
|
<section className="dock">
|
||||||
<div className="dock-head">
|
<DockHead meta={<span className="meta">Cover</span>} />
|
||||||
<span className="label">Preview</span>
|
|
||||||
<div className="dock-tools">
|
|
||||||
<span className="meta">Cover</span>
|
|
||||||
<TrimSelect />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="cover-stage">
|
<div className="cover-stage">
|
||||||
<CoverArt book={book} />
|
<CoverArt book={book} />
|
||||||
</div>
|
</div>
|
||||||
@@ -115,21 +151,15 @@ function HtmlDock() {
|
|||||||
|
|
||||||
if (!chapter) return null;
|
if (!chapter) return null;
|
||||||
|
|
||||||
const kind = chapterKind(chapter);
|
|
||||||
const eyebrow =
|
|
||||||
kind === "body" ? `Chapter ${bodyNumber(chapters, idx) ?? ""}` : kind === "front" ? "Front matter" : "Back matter";
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="dock">
|
<section className="dock">
|
||||||
<div className="dock-head">
|
<DockHead
|
||||||
<span className="label">Preview</span>
|
meta={
|
||||||
<div className="dock-tools">
|
|
||||||
<span className="meta">
|
<span className="meta">
|
||||||
{idx + 1} of {chapters.length}
|
{idx + 1} of {chapters.length}
|
||||||
</span>
|
</span>
|
||||||
<TrimSelect />
|
}
|
||||||
</div>
|
/>
|
||||||
</div>
|
|
||||||
<div className="page">
|
<div className="page">
|
||||||
<div className="p-opener">
|
<div className="p-opener">
|
||||||
<div className="p-num">{eyebrow}</div>
|
<div className="p-num">{eyebrow}</div>
|
||||||
@@ -141,3 +171,45 @@ function HtmlDock() {
|
|||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function DeviceDock({ device }: { device: Device }) {
|
||||||
|
const { book, coverActive, chapters, idx, chapter, html, eyebrow } = useActiveChapter();
|
||||||
|
|
||||||
|
if (!book) return null;
|
||||||
|
|
||||||
|
if (coverActive) {
|
||||||
|
return (
|
||||||
|
<section className="dock">
|
||||||
|
<DockHead meta={<span className="meta">Cover</span>} />
|
||||||
|
<DeviceFrame device={device} cover={book} />
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!chapter) {
|
||||||
|
return (
|
||||||
|
<section className="dock">
|
||||||
|
<DockHead />
|
||||||
|
<div className="dock-empty">No chapter selected.</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="dock">
|
||||||
|
<DockHead
|
||||||
|
meta={
|
||||||
|
<span className="meta">
|
||||||
|
{idx + 1} of {chapters.length}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<DeviceFrame
|
||||||
|
device={device}
|
||||||
|
eyebrow={eyebrow}
|
||||||
|
title={chapter.noTitle ? undefined : chapter.title || "Untitled"}
|
||||||
|
html={html}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -263,6 +263,7 @@ export function EditorView() {
|
|||||||
)}
|
)}
|
||||||
</header>
|
</header>
|
||||||
<Editor
|
<Editor
|
||||||
|
bookId={book.id}
|
||||||
chapterId={chapter.id}
|
chapterId={chapter.id}
|
||||||
content={chapter.content}
|
content={chapter.content}
|
||||||
onChange={(content) => setChapterContent(chapter.id, content)}
|
onChange={(content) => setChapterContent(chapter.id, content)}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import * as pdfjs from "pdfjs-dist";
|
import * as pdfjs from "pdfjs-dist";
|
||||||
import workerUrl from "pdfjs-dist/build/pdf.worker.min.mjs?url";
|
import workerUrl from "pdfjs-dist/build/pdf.worker.min.mjs?url";
|
||||||
|
|
||||||
@@ -6,6 +6,7 @@ pdfjs.GlobalWorkerOptions.workerSrc = workerUrl;
|
|||||||
|
|
||||||
export function PdfPreview({ data }: { data: Uint8Array }) {
|
export function PdfPreview({ data }: { data: Uint8Array }) {
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const container = ref.current;
|
const container = ref.current;
|
||||||
@@ -13,6 +14,7 @@ export function PdfPreview({ data }: { data: Uint8Array }) {
|
|||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
|
try {
|
||||||
const doc = await pdfjs.getDocument({ data: data.slice() }).promise;
|
const doc = await pdfjs.getDocument({ data: data.slice() }).promise;
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
const width = container.clientWidth;
|
const width = container.clientWidth;
|
||||||
@@ -30,12 +32,17 @@ export function PdfPreview({ data }: { data: Uint8Array }) {
|
|||||||
canvas.width = viewport.width;
|
canvas.width = viewport.width;
|
||||||
canvas.height = viewport.height;
|
canvas.height = viewport.height;
|
||||||
canvas.style.width = "100%";
|
canvas.style.width = "100%";
|
||||||
const context = canvas.getContext("2d")!;
|
await page.render({ canvas, viewport }).promise;
|
||||||
await page.render({ canvas, canvasContext: context, viewport }).promise;
|
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
canvases.push(canvas);
|
canvases.push(canvas);
|
||||||
}
|
}
|
||||||
container.replaceChildren(...canvases);
|
container.replaceChildren(...canvases);
|
||||||
|
setError(null);
|
||||||
|
} catch (e) {
|
||||||
|
if (cancelled) return;
|
||||||
|
container.replaceChildren();
|
||||||
|
setError(String(e));
|
||||||
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
@@ -43,5 +50,10 @@ export function PdfPreview({ data }: { data: Uint8Array }) {
|
|||||||
};
|
};
|
||||||
}, [data]);
|
}, [data]);
|
||||||
|
|
||||||
return <div className="pdf-pages" ref={ref} />;
|
return (
|
||||||
|
<>
|
||||||
|
{error && <pre className="dock-error">{error}</pre>}
|
||||||
|
<div className="pdf-pages" ref={ref} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
+115
@@ -0,0 +1,115 @@
|
|||||||
|
export type DeviceId = "kindle" | "kobo" | "ipad" | "iphone" | "android";
|
||||||
|
export type PreviewMode = "print" | DeviceId;
|
||||||
|
export type DeviceKind = "eink" | "phone" | "tablet";
|
||||||
|
|
||||||
|
export interface Device {
|
||||||
|
id: DeviceId;
|
||||||
|
label: string;
|
||||||
|
kind: DeviceKind;
|
||||||
|
bg: string;
|
||||||
|
ink: string;
|
||||||
|
fontSize: number;
|
||||||
|
lineHeight: number;
|
||||||
|
marginX: number;
|
||||||
|
marginY: number;
|
||||||
|
aspect: number;
|
||||||
|
bezel: string;
|
||||||
|
bezelWidth: number;
|
||||||
|
radius: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEVICES: Device[] = [
|
||||||
|
{
|
||||||
|
id: "kindle",
|
||||||
|
label: "Kindle Paperwhite",
|
||||||
|
kind: "eink",
|
||||||
|
bg: "#f4f3ee",
|
||||||
|
ink: "#2b2b2b",
|
||||||
|
fontSize: 16,
|
||||||
|
lineHeight: 1.55,
|
||||||
|
marginX: 34,
|
||||||
|
marginY: 40,
|
||||||
|
aspect: 1240 / 1680,
|
||||||
|
bezel: "#1c1c1c",
|
||||||
|
bezelWidth: 18,
|
||||||
|
radius: 14,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "kobo",
|
||||||
|
label: "Kobo",
|
||||||
|
kind: "eink",
|
||||||
|
bg: "#f6f5f0",
|
||||||
|
ink: "#262626",
|
||||||
|
fontSize: 16,
|
||||||
|
lineHeight: 1.6,
|
||||||
|
marginX: 34,
|
||||||
|
marginY: 40,
|
||||||
|
aspect: 1264 / 1680,
|
||||||
|
bezel: "#2a2a2a",
|
||||||
|
bezelWidth: 16,
|
||||||
|
radius: 16,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "ipad",
|
||||||
|
label: "iPad",
|
||||||
|
kind: "tablet",
|
||||||
|
bg: "#fbfaf7",
|
||||||
|
ink: "#1d1d1f",
|
||||||
|
fontSize: 18,
|
||||||
|
lineHeight: 1.65,
|
||||||
|
marginX: 56,
|
||||||
|
marginY: 56,
|
||||||
|
aspect: 1640 / 2360,
|
||||||
|
bezel: "#3a3a3c",
|
||||||
|
bezelWidth: 20,
|
||||||
|
radius: 28,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "iphone",
|
||||||
|
label: "iPhone",
|
||||||
|
kind: "phone",
|
||||||
|
bg: "#ffffff",
|
||||||
|
ink: "#1d1d1f",
|
||||||
|
fontSize: 17,
|
||||||
|
lineHeight: 1.6,
|
||||||
|
marginX: 26,
|
||||||
|
marginY: 30,
|
||||||
|
aspect: 1179 / 2556,
|
||||||
|
bezel: "#1a1a1c",
|
||||||
|
bezelWidth: 12,
|
||||||
|
radius: 44,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "android",
|
||||||
|
label: "Android phone",
|
||||||
|
kind: "phone",
|
||||||
|
bg: "#ffffff",
|
||||||
|
ink: "#202124",
|
||||||
|
fontSize: 16,
|
||||||
|
lineHeight: 1.6,
|
||||||
|
marginX: 24,
|
||||||
|
marginY: 28,
|
||||||
|
aspect: 1080 / 2400,
|
||||||
|
bezel: "#0f0f0f",
|
||||||
|
bezelWidth: 11,
|
||||||
|
radius: 36,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const DEVICE_MAP = new Map<string, Device>(DEVICES.map((d) => [d.id, d]));
|
||||||
|
|
||||||
|
const KEY = "margin-preview-mode";
|
||||||
|
|
||||||
|
export function findDevice(id: string): Device | undefined {
|
||||||
|
return DEVICE_MAP.get(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function initialPreviewMode(): PreviewMode {
|
||||||
|
const saved = localStorage.getItem(KEY);
|
||||||
|
if (saved === "print" || (saved && DEVICE_MAP.has(saved))) return saved as PreviewMode;
|
||||||
|
return "print";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function persistPreviewMode(mode: PreviewMode) {
|
||||||
|
localStorage.setItem(KEY, mode);
|
||||||
|
}
|
||||||
+39
-18
@@ -2,25 +2,20 @@ import { useEffect, useRef } from "react";
|
|||||||
import { EditorContent, useEditor, type Editor as TiptapEditor } from "@tiptap/react";
|
import { EditorContent, useEditor, type Editor as TiptapEditor } from "@tiptap/react";
|
||||||
import type { JSONContent } from "@tiptap/core";
|
import type { JSONContent } from "@tiptap/core";
|
||||||
import { editorExtensions } from "./extensions";
|
import { editorExtensions } from "./extensions";
|
||||||
|
import { loadPosition, savePosition } from "./positions";
|
||||||
|
|
||||||
interface EditorProps {
|
interface EditorProps {
|
||||||
|
bookId: string;
|
||||||
chapterId: string;
|
chapterId: string;
|
||||||
content: JSONContent;
|
content: JSONContent;
|
||||||
onChange: (content: JSONContent) => void;
|
onChange: (content: JSONContent) => void;
|
||||||
onReady: (editor: TiptapEditor | null) => void;
|
onReady: (editor: TiptapEditor | null) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ChapterPosition {
|
export function Editor({ bookId, chapterId, content, onChange, onReady }: EditorProps) {
|
||||||
from: number;
|
|
||||||
to: number;
|
|
||||||
scroll: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Editor({ chapterId, content, onChange, onReady }: EditorProps) {
|
|
||||||
const onChangeRef = useRef(onChange);
|
const onChangeRef = useRef(onChange);
|
||||||
onChangeRef.current = onChange;
|
onChangeRef.current = onChange;
|
||||||
|
|
||||||
const positions = useRef(new Map<string, ChapterPosition>());
|
|
||||||
const openChapter = useRef(chapterId);
|
const openChapter = useRef(chapterId);
|
||||||
|
|
||||||
const editor = useEditor({
|
const editor = useEditor({
|
||||||
@@ -37,32 +32,58 @@ export function Editor({ chapterId, content, onChange, onReady }: EditorProps) {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!editor) return;
|
if (!editor) return;
|
||||||
|
let active = true;
|
||||||
const scroller = editor.view.dom.closest(".editor-pane") as HTMLElement | null;
|
const scroller = editor.view.dom.closest(".editor-pane") as HTMLElement | null;
|
||||||
|
|
||||||
const previous = openChapter.current;
|
const previous = openChapter.current;
|
||||||
if (previous !== chapterId) {
|
if (previous !== chapterId) {
|
||||||
const { from, to } = editor.state.selection;
|
const { from, to } = editor.state.selection;
|
||||||
positions.current.set(previous, { from, to, scroll: scroller?.scrollTop ?? 0 });
|
savePosition(bookId, previous, { from, to, scroll: scroller?.scrollTop ?? 0 });
|
||||||
}
|
}
|
||||||
openChapter.current = chapterId;
|
openChapter.current = chapterId;
|
||||||
|
|
||||||
editor.commands.setContent(content, { emitUpdate: false });
|
editor.commands.setContent(content, { emitUpdate: false });
|
||||||
|
|
||||||
const saved = positions.current.get(chapterId);
|
const saved = loadPosition(bookId, chapterId);
|
||||||
const size = editor.state.doc.content.size;
|
const size = editor.state.doc.content.size;
|
||||||
editor.commands.setTextSelection(
|
const selection = saved ? { from: Math.min(saved.from, size), to: Math.min(saved.to, size) } : 0;
|
||||||
saved ? { from: Math.min(saved.from, size), to: Math.min(saved.to, size) } : 0
|
editor.chain().setTextSelection(selection).focus(undefined, { scrollIntoView: false }).run();
|
||||||
);
|
|
||||||
|
|
||||||
if (scroller) {
|
if (scroller) {
|
||||||
const top = saved?.scroll ?? 0;
|
const top = saved?.scroll ?? 0;
|
||||||
scroller.scrollTop = top;
|
const restore = () => {
|
||||||
requestAnimationFrame(() => {
|
if (active) scroller.scrollTop = top;
|
||||||
scroller.scrollTop = top;
|
};
|
||||||
});
|
restore();
|
||||||
|
requestAnimationFrame(restore);
|
||||||
|
document.fonts?.ready.then(restore).catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
active = false;
|
||||||
|
};
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [chapterId, editor]);
|
}, [chapterId, editor, bookId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!editor) return;
|
||||||
|
const scroller = editor.view.dom.closest(".editor-pane") as HTMLElement | null;
|
||||||
|
let timer: ReturnType<typeof setTimeout>;
|
||||||
|
const persist = () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
timer = setTimeout(() => {
|
||||||
|
const { from, to } = editor.state.selection;
|
||||||
|
savePosition(bookId, openChapter.current, { from, to, scroll: scroller?.scrollTop ?? 0 });
|
||||||
|
}, 400);
|
||||||
|
};
|
||||||
|
editor.on("selectionUpdate", persist);
|
||||||
|
scroller?.addEventListener("scroll", persist, { passive: true });
|
||||||
|
return () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
editor.off("selectionUpdate", persist);
|
||||||
|
scroller?.removeEventListener("scroll", persist);
|
||||||
|
};
|
||||||
|
}, [editor, bookId]);
|
||||||
|
|
||||||
return <EditorContent editor={editor} className="editor-host" />;
|
return <EditorContent editor={editor} className="editor-host" />;
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
export interface ChapterPosition {
|
||||||
|
from: number;
|
||||||
|
to: number;
|
||||||
|
scroll: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const KEY = "margin-positions";
|
||||||
|
|
||||||
|
type Store = Record<string, Record<string, ChapterPosition>>;
|
||||||
|
|
||||||
|
function readAll(): Store {
|
||||||
|
try {
|
||||||
|
return JSON.parse(localStorage.getItem(KEY) || "{}") as Store;
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadPosition(bookId: string, chapterId: string): ChapterPosition | null {
|
||||||
|
return readAll()[bookId]?.[chapterId] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function savePosition(bookId: string, chapterId: string, position: ChapterPosition): void {
|
||||||
|
const all = readAll();
|
||||||
|
(all[bookId] ||= {})[chapterId] = position;
|
||||||
|
localStorage.setItem(KEY, JSON.stringify(all));
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { create } from "zustand";
|
||||||
|
import { type PreviewMode, initialPreviewMode, persistPreviewMode } from "../devices";
|
||||||
|
|
||||||
|
interface PreviewState {
|
||||||
|
mode: PreviewMode;
|
||||||
|
setMode: (mode: PreviewMode) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const usePreviewMode = create<PreviewState>((set) => ({
|
||||||
|
mode: initialPreviewMode(),
|
||||||
|
setMode: (mode) => {
|
||||||
|
persistPreviewMode(mode);
|
||||||
|
set({ mode });
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -1992,3 +1992,194 @@ body.resizing {
|
|||||||
background: var(--accent-wash);
|
background: var(--accent-wash);
|
||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.device-stage {
|
||||||
|
max-width: 420px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding-block: 6px;
|
||||||
|
container-type: inline-size;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-frame {
|
||||||
|
--dv-scale: calc(100cqi / 380);
|
||||||
|
width: 100%;
|
||||||
|
padding: calc(var(--dv-bezel-w) * var(--dv-scale));
|
||||||
|
background: var(--dv-bezel);
|
||||||
|
border-radius: calc(var(--dv-radius) * var(--dv-scale));
|
||||||
|
box-shadow: var(--shadow-page);
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-screen {
|
||||||
|
aspect-ratio: var(--dv-aspect);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--dv-bg);
|
||||||
|
color: var(--dv-ink);
|
||||||
|
border-radius: calc(max(0px, (var(--dv-radius) - var(--dv-bezel-w)) * var(--dv-scale)));
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-statusbar {
|
||||||
|
flex: none;
|
||||||
|
height: calc(30 * var(--dv-scale));
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0 calc(18 * var(--dv-scale));
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: calc(11 * var(--dv-scale));
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--dv-ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-clock {
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-batt {
|
||||||
|
position: relative;
|
||||||
|
flex: none;
|
||||||
|
width: calc(22 * var(--dv-scale));
|
||||||
|
height: calc(11 * var(--dv-scale));
|
||||||
|
border: calc(1.4 * var(--dv-scale)) solid var(--dv-ink);
|
||||||
|
border-radius: calc(3 * var(--dv-scale));
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-batt::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: calc(1.5 * var(--dv-scale));
|
||||||
|
bottom: calc(1.5 * var(--dv-scale));
|
||||||
|
left: calc(1.5 * var(--dv-scale));
|
||||||
|
width: 55%;
|
||||||
|
background: var(--dv-ink);
|
||||||
|
border-radius: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-batt::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
right: calc(-3 * var(--dv-scale));
|
||||||
|
top: 30%;
|
||||||
|
height: 40%;
|
||||||
|
width: calc(2 * var(--dv-scale));
|
||||||
|
border-radius: 1px;
|
||||||
|
background: var(--dv-ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-content {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: calc(var(--dv-my) * var(--dv-scale)) calc(var(--dv-mx) * var(--dv-scale));
|
||||||
|
font-family: var(--font-book);
|
||||||
|
font-size: calc(var(--dv-fs) * var(--dv-scale));
|
||||||
|
line-height: var(--dv-lh);
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-frame[data-kind="eink"] .device-content::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-opener {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: calc(20 * var(--dv-scale));
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-eyebrow {
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: calc(10 * var(--dv-scale));
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.22em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
opacity: 0.5;
|
||||||
|
margin-bottom: calc(8 * var(--dv-scale));
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-title {
|
||||||
|
font-family: var(--font-book);
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: calc(24 * var(--dv-scale));
|
||||||
|
line-height: 1.2;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-body p {
|
||||||
|
font-family: var(--font-book);
|
||||||
|
hyphens: auto;
|
||||||
|
margin: 0 0 0.8em;
|
||||||
|
color: var(--dv-ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-body p[data-indent="true"] {
|
||||||
|
text-indent: 1.2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-frame[data-kind="eink"] .device-body p {
|
||||||
|
text-align: justify;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-frame[data-kind="phone"] .device-body p,
|
||||||
|
.device-frame[data-kind="tablet"] .device-body p {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-body h1,
|
||||||
|
.device-body h2,
|
||||||
|
.device-body h3 {
|
||||||
|
font-family: var(--font-book);
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.25;
|
||||||
|
margin: 1.2em 0 0.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-body blockquote {
|
||||||
|
margin: 0 0 0.8em;
|
||||||
|
padding-left: 1em;
|
||||||
|
border-left: 2px solid currentColor;
|
||||||
|
font-style: italic;
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-body hr {
|
||||||
|
border: none;
|
||||||
|
border-top: 1px solid currentColor;
|
||||||
|
width: calc(40 * var(--dv-scale));
|
||||||
|
margin: calc(16 * var(--dv-scale)) auto;
|
||||||
|
opacity: 0.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-body img {
|
||||||
|
display: block;
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
margin: 0.8em auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-footer {
|
||||||
|
flex: none;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: calc(10 * var(--dv-scale));
|
||||||
|
padding: calc(8 * var(--dv-scale)) calc(var(--dv-mx) * var(--dv-scale));
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: calc(11 * var(--dv-scale));
|
||||||
|
color: var(--dv-ink);
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-foot-title {
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-content .cover-art {
|
||||||
|
width: 100%;
|
||||||
|
margin: auto;
|
||||||
|
box-shadow: none;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
+1
-1
@@ -26,7 +26,7 @@ export default defineConfig(async () => ({
|
|||||||
: undefined,
|
: undefined,
|
||||||
watch: {
|
watch: {
|
||||||
// 3. tell Vite to ignore watching `src-tauri`
|
// 3. tell Vite to ignore watching `src-tauri`
|
||||||
ignored: ["**/src-tauri/**"],
|
ignored: ["**/src-tauri/**", "**/website/**"],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
@@ -3,5 +3,5 @@ import { defineConfig } from "astro/config";
|
|||||||
// The canonical URL is only used for absolute links (OG tags, sitemap).
|
// The canonical URL is only used for absolute links (OG tags, sitemap).
|
||||||
// Change it to the real domain when the site goes live.
|
// Change it to the real domain when the site goes live.
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
site: "https://margin.studio",
|
site: "https://margin.73ai.org",
|
||||||
});
|
});
|
||||||
@@ -6,7 +6,8 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "astro dev",
|
"dev": "astro dev",
|
||||||
"build": "astro build",
|
"build": "astro build",
|
||||||
"preview": "astro preview"
|
"preview": "astro preview",
|
||||||
|
"deploy": "astro build && npx wrangler pages deploy dist --project-name=margin --commit-dirty=true"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"astro": "^5.6.1"
|
"astro": "^5.6.1"
|
||||||
|
|||||||
@@ -1,37 +1,70 @@
|
|||||||
---
|
---
|
||||||
// A faithful CSS mock-up of the Margin desktop app: chapter sidebar, the
|
// A faithful CSS mock-up of the Margin desktop app: the All-books sidebar with
|
||||||
// writing surface set in Literata, and the live PDF preview dock. The editor
|
// front-matter / chapter sections, the writing surface set in Literata with live
|
||||||
// shows live proofing underlines; the PDF dock shows the clean typeset page.
|
// proofing, and the live PDF preview dock with its trim selector.
|
||||||
---
|
---
|
||||||
|
|
||||||
<div class="win" role="img" aria-label="The Margin app: a chapter sidebar, a writing surface with live proofing, and a live PDF page preview.">
|
<div class="win" role="img" aria-label="The Margin app: a chapter sidebar, a writing surface with live proofing, and a live PDF page preview.">
|
||||||
<div class="win__bar">
|
<div class="win__bar">
|
||||||
<span class="dot"></span><span class="dot"></span><span class="dot"></span>
|
<span class="win__chrome"><span class="dot"></span><span class="dot"></span><span class="dot"></span></span>
|
||||||
<span class="win__title">The Lighthouse Keeper</span>
|
<span class="win__title">The Lighthouse Keeper</span>
|
||||||
<span class="win__menu">Export ▾</span>
|
<span class="win__actions">
|
||||||
|
<span class="ibtn"><svg class="i" viewBox="0 0 24 24"><path d="M11 4a7 7 0 1 0 0 14 7 7 0 0 0 0-14zM20 20l-4-4" /></svg></span>
|
||||||
|
<span class="ibtn"><svg class="i" viewBox="0 0 24 24"><path d="M4 17l4-10 4 10M5.4 13.4h5.2M15 17l2.5 2.5L22 14" /></svg></span>
|
||||||
|
<span class="ibtn"><svg class="i" viewBox="0 0 24 24"><path d="M4 7h16M4 12h12M4 17h7M13.5 18.5c1-1.2 2-1.2 3 0s2 1.2 3 0" /></svg></span>
|
||||||
|
<span class="ibtn"><svg class="i" viewBox="0 0 24 24"><path d="M3 5v14M21 5v14M7 12h10M7 12l3-3M7 12l3 3M17 12l-3-3M17 12l-3 3" /></svg></span>
|
||||||
|
<span class="ibtn"><svg class="i" viewBox="0 0 24 24"><path d="M5 13v6h14v-6M12 16V3M8 7l4-4 4 4" /></svg></span>
|
||||||
|
<span class="ibtn"><svg class="i" viewBox="0 0 24 24"><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z" /></svg></span>
|
||||||
|
<span class="ibtn is-on"><svg class="i" viewBox="0 0 24 24"><path d="M3 4.5h18v15H3zM14 4.5v15" /></svg></span>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="win__body">
|
<div class="win__body">
|
||||||
<aside class="side">
|
<aside class="side">
|
||||||
<p class="side__label">Manuscript</p>
|
<span class="side__brand">
|
||||||
<ul>
|
<svg class="i" viewBox="0 0 24 24"><path d="M14 7l-5 5 5 5" /></svg>All books
|
||||||
<li>Title Page</li>
|
</span>
|
||||||
<li>Dedication</li>
|
<span class="side__cover">
|
||||||
<li class="is-active">1 · The Cliff</li>
|
<svg class="i" viewBox="0 0 24 24"><path d="M5 4h11l3 3v13H5zM16 4v4h3" /></svg>Cover
|
||||||
<li>2 · A Light at Sea</li>
|
</span>
|
||||||
<li>3 · Salt and Silence</li>
|
<span class="side__add">
|
||||||
<li>4 · The Long Night</li>
|
<svg class="i" viewBox="0 0 24 24"><path d="M12 5v14M5 12h14" /></svg>Add page
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<div class="side__scroll">
|
||||||
|
<div class="side__section">
|
||||||
|
<p class="side__label">Front matter</p>
|
||||||
|
<ul class="side__list">
|
||||||
|
<li class="row"><span class="grip"></span><span class="num"></span><span class="text"><span class="title">Title Page</span></span></li>
|
||||||
|
<li class="row"><span class="grip"></span><span class="num"></span><span class="text"><span class="title">Dedication</span></span></li>
|
||||||
</ul>
|
</ul>
|
||||||
<p class="side__label">Book setup</p>
|
</div>
|
||||||
<ul class="side__meta">
|
|
||||||
<li>Trim · 5 × 8 in</li>
|
<div class="side__section">
|
||||||
<li>Language · English</li>
|
<p class="side__label">Chapters</p>
|
||||||
|
<ul class="side__list">
|
||||||
|
<li class="row is-active">
|
||||||
|
<span class="grip"><svg class="i" viewBox="0 0 24 24"><path d="M9 6h.01M15 6h.01M9 12h.01M15 12h.01M9 18h.01M15 18h.01" /></svg></span>
|
||||||
|
<span class="num">1</span>
|
||||||
|
<span class="text">
|
||||||
|
<span class="title">The Cliff</span>
|
||||||
|
<span class="meta">Edited yesterday</span>
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
<li class="row"><span class="grip"></span><span class="num">2</span><span class="text"><span class="title">A Light at Sea</span></span></li>
|
||||||
|
<li class="row"><span class="grip"></span><span class="num">3</span><span class="text"><span class="title">Salt and Silence</span></span></li>
|
||||||
|
<li class="row"><span class="grip"></span><span class="num">4</span><span class="text"><span class="title">The Long Night</span></span></li>
|
||||||
</ul>
|
</ul>
|
||||||
|
<span class="side__newchapter">
|
||||||
|
<svg class="i" viewBox="0 0 24 24"><path d="M12 5v14M5 12h14" /></svg>New chapter
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<main class="page">
|
<main class="page">
|
||||||
<div class="page__sheet">
|
<div class="page__sheet">
|
||||||
<p class="chapno">Chapter One</p>
|
<p class="chapno">Chapter 1</p>
|
||||||
<h4 class="chaptitle">The Cliff</h4>
|
<h4 class="chaptitle">The Cliff</h4>
|
||||||
<p>
|
<p>
|
||||||
The lighthouse had stood on the headland for ninety years, and in all that time it had
|
The lighthouse had stood on the headland for ninety years, and in all that time it had
|
||||||
@@ -47,10 +80,17 @@
|
|||||||
</main>
|
</main>
|
||||||
|
|
||||||
<aside class="dock">
|
<aside class="dock">
|
||||||
<p class="dock__label">Live PDF · pg 7</p>
|
<div class="dock__head">
|
||||||
<div class="dock__pdf">
|
<span class="dock__label">Preview</span>
|
||||||
<span class="rh">THE LIGHTHOUSE KEEPER</span>
|
<span class="dock__trim">
|
||||||
<p class="pch">Chapter One</p>
|
5 × 8 in
|
||||||
|
<svg class="i" viewBox="0 0 24 24"><path d="M8 10l4-4 4 4M8 14l4 4 4-4" /></svg>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="dock__pages">
|
||||||
|
<div class="dock__page">
|
||||||
|
<p class="pch">Chapter 1</p>
|
||||||
<p class="pdt">The Cliff</p>
|
<p class="pdt">The Cliff</p>
|
||||||
<p class="pp pp--first">
|
<p class="pp pp--first">
|
||||||
The lighthouse had stood on the headland for ninety years, and in all that time it had
|
The lighthouse had stood on the headland for ninety years, and in all that time it had
|
||||||
@@ -61,7 +101,15 @@
|
|||||||
of habit, and watched the lamp turn its slow circle across the water.
|
of habit, and watched the lamp turn its slow circle across the water.
|
||||||
</p>
|
</p>
|
||||||
<p class="pp">The sea, tonight, was the colour of pewter.</p>
|
<p class="pp">The sea, tonight, was the colour of pewter.</p>
|
||||||
<span class="pg">7</span>
|
<span class="pg">1</span>
|
||||||
|
</div>
|
||||||
|
<div class="dock__page dock__page--peek">
|
||||||
|
<p class="pp pp--first">
|
||||||
|
Morning came grey and slow. She brewed coffee in the keeper's kitchen and listened to the
|
||||||
|
gulls argue over the rocks below.
|
||||||
|
</p>
|
||||||
|
<p class="pp">The radio crackled once, then fell silent.</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
@@ -76,115 +124,231 @@
|
|||||||
box-shadow: var(--shadow-deep);
|
box-shadow: var(--shadow-deep);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
.i {
|
||||||
|
width: 15px;
|
||||||
|
height: 15px;
|
||||||
|
flex: none;
|
||||||
|
fill: none;
|
||||||
|
stroke: currentColor;
|
||||||
|
stroke-width: 1.6;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
}
|
||||||
|
|
||||||
.win__bar {
|
.win__bar {
|
||||||
display: flex;
|
display: grid;
|
||||||
|
grid-template-columns: 1fr auto 1fr;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
height: 44px;
|
||||||
height: 40px;
|
padding-inline: 14px;
|
||||||
padding-inline: 16px;
|
|
||||||
border-bottom: 1px solid var(--line);
|
border-bottom: 1px solid var(--line);
|
||||||
background: var(--sidebar);
|
background: var(--shell);
|
||||||
font-size: 0.78rem;
|
}
|
||||||
color: var(--ink-faint);
|
.win__chrome {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
}
|
}
|
||||||
.dot {
|
.dot {
|
||||||
width: 11px;
|
width: 11px;
|
||||||
height: 11px;
|
height: 11px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
}
|
}
|
||||||
.win__bar .dot:nth-child(1) {
|
.win__chrome .dot:nth-child(1) {
|
||||||
background: #ff5f57;
|
background: #ff5f57;
|
||||||
}
|
}
|
||||||
.win__bar .dot:nth-child(2) {
|
.win__chrome .dot:nth-child(2) {
|
||||||
background: #febc2e;
|
background: #febc2e;
|
||||||
}
|
}
|
||||||
.win__bar .dot:nth-child(3) {
|
.win__chrome .dot:nth-child(3) {
|
||||||
background: #28c840;
|
background: #28c840;
|
||||||
}
|
}
|
||||||
.win__title {
|
.win__title {
|
||||||
margin-inline: auto;
|
grid-column: 2;
|
||||||
font-weight: 500;
|
font-size: 0.82rem;
|
||||||
}
|
letter-spacing: 0.01em;
|
||||||
.win__menu {
|
|
||||||
color: var(--ink-soft);
|
color: var(--ink-soft);
|
||||||
font-weight: 500;
|
|
||||||
}
|
}
|
||||||
|
.win__actions {
|
||||||
|
grid-column: 3;
|
||||||
|
justify-self: end;
|
||||||
|
display: flex;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
.ibtn {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
border-radius: var(--r-sm);
|
||||||
|
color: var(--ink-soft);
|
||||||
|
}
|
||||||
|
.ibtn.is-on {
|
||||||
|
color: var(--accent);
|
||||||
|
background: var(--accent-wash);
|
||||||
|
box-shadow: inset 0 0 0 1px var(--line-strong);
|
||||||
|
}
|
||||||
|
|
||||||
.win__body {
|
.win__body {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 172px 1fr 196px;
|
grid-template-columns: 196px 1fr 200px;
|
||||||
min-height: 392px;
|
min-height: 432px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.side {
|
.side {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
background: var(--sidebar);
|
background: var(--sidebar);
|
||||||
border-right: 1px solid var(--line);
|
border-right: 1px solid var(--line);
|
||||||
padding: 16px 12px;
|
padding: 12px 10px;
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
}
|
}
|
||||||
|
.side__brand,
|
||||||
|
.side__cover,
|
||||||
|
.side__add,
|
||||||
|
.side__newchapter {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
border-radius: var(--r-sm);
|
||||||
|
color: var(--ink-soft);
|
||||||
|
}
|
||||||
|
.side__brand {
|
||||||
|
color: var(--ink-faint);
|
||||||
|
font-size: 0.74rem;
|
||||||
|
}
|
||||||
|
.side__cover {
|
||||||
|
color: var(--ink);
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
.side__add {
|
||||||
|
color: var(--ink-faint);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.side__scroll {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.side__section + .side__section {
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
.side__label {
|
.side__label {
|
||||||
font-size: 0.66rem;
|
font-size: 0.62rem;
|
||||||
letter-spacing: 0.1em;
|
letter-spacing: 0.12em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
color: var(--ink-faint);
|
color: var(--ink-faint);
|
||||||
font-weight: 650;
|
font-weight: 650;
|
||||||
margin: 0 0 8px;
|
margin: 0 0 6px;
|
||||||
|
padding-inline: 8px;
|
||||||
}
|
}
|
||||||
.side ul {
|
.side__list {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
margin: 0 0 18px;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 2px;
|
gap: 1px;
|
||||||
}
|
}
|
||||||
.side li {
|
.row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 14px 16px 1fr;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
padding: 5px 8px;
|
padding: 5px 8px;
|
||||||
border-radius: var(--r-sm);
|
border-radius: var(--r-sm);
|
||||||
color: var(--ink-soft);
|
color: var(--ink-soft);
|
||||||
}
|
}
|
||||||
.side li.is-active {
|
.row .grip {
|
||||||
background: var(--accent-wash);
|
|
||||||
color: var(--ink);
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
.side__meta li {
|
|
||||||
color: var(--ink-faint);
|
color: var(--ink-faint);
|
||||||
font-size: 0.74rem;
|
opacity: 0;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
}
|
||||||
|
.row .grip .i {
|
||||||
|
width: 13px;
|
||||||
|
height: 13px;
|
||||||
|
}
|
||||||
|
.row .num {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: var(--ink-faint);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.row .text {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.row .title {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.row .meta {
|
||||||
|
font-size: 0.66rem;
|
||||||
|
color: var(--ink-faint);
|
||||||
|
margin-top: 1px;
|
||||||
|
}
|
||||||
|
.row.is-active {
|
||||||
|
background: var(--raised);
|
||||||
|
color: var(--ink);
|
||||||
|
box-shadow: 0 1px 2px rgba(35, 32, 27, 0.08), inset 0 0 0 1px var(--line);
|
||||||
|
}
|
||||||
|
.row.is-active .grip {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
.row.is-active .num {
|
||||||
|
color: var(--ink-soft);
|
||||||
|
}
|
||||||
|
.side__newchapter {
|
||||||
|
color: var(--ink-faint);
|
||||||
|
font-size: 0.76rem;
|
||||||
|
margin-top: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.page {
|
.page {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: 26px 18px;
|
padding: 30px 18px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
.page__sheet {
|
.page__sheet {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 360px;
|
max-width: 372px;
|
||||||
font-family: var(--font-book);
|
font-family: var(--font-book);
|
||||||
font-size: 0.92rem;
|
font-size: 0.92rem;
|
||||||
line-height: 1.62;
|
line-height: 1.6;
|
||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
}
|
}
|
||||||
.page__sheet p {
|
.page__sheet p {
|
||||||
margin: 0 0 0.7em;
|
margin: 0;
|
||||||
text-indent: 1.1em;
|
text-indent: 1.3em;
|
||||||
|
text-align: justify;
|
||||||
|
hyphens: auto;
|
||||||
|
-webkit-hyphens: auto;
|
||||||
|
}
|
||||||
|
.page__sheet p + p {
|
||||||
|
margin-top: 0.2em;
|
||||||
}
|
}
|
||||||
.chapno {
|
.chapno {
|
||||||
text-indent: 0 !important;
|
text-indent: 0 !important;
|
||||||
text-align: center;
|
text-align: center !important;
|
||||||
font-family: var(--font-ui);
|
font-family: var(--font-ui);
|
||||||
font-size: 0.62rem;
|
font-size: 0.6rem;
|
||||||
letter-spacing: 0.18em;
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.24em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
color: var(--ink-faint);
|
color: var(--ink-faint);
|
||||||
margin-bottom: 0.4em !important;
|
margin: 0 0 0.5em !important;
|
||||||
}
|
}
|
||||||
.chaptitle {
|
.chaptitle {
|
||||||
|
text-indent: 0;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
font-size: 1.5rem;
|
font-size: 1.5rem;
|
||||||
font-weight: 560;
|
font-weight: 540;
|
||||||
margin: 0 0 1.1em;
|
letter-spacing: -0.01em;
|
||||||
|
margin: 0 0 1.4em;
|
||||||
|
}
|
||||||
|
.chaptitle + p {
|
||||||
|
text-indent: 0;
|
||||||
}
|
}
|
||||||
.u-spell {
|
.u-spell {
|
||||||
text-decoration: wavy underline var(--danger);
|
text-decoration: wavy underline var(--danger);
|
||||||
@@ -200,17 +364,45 @@
|
|||||||
.dock {
|
.dock {
|
||||||
background: var(--shell);
|
background: var(--shell);
|
||||||
border-left: 1px solid var(--line);
|
border-left: 1px solid var(--line);
|
||||||
padding: 16px 14px;
|
padding: 14px 14px 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.dock__head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 12px;
|
||||||
}
|
}
|
||||||
.dock__label {
|
.dock__label {
|
||||||
font-size: 0.66rem;
|
font-size: 0.62rem;
|
||||||
letter-spacing: 0.1em;
|
letter-spacing: 0.12em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
color: var(--ink-faint);
|
color: var(--ink-faint);
|
||||||
font-weight: 650;
|
font-weight: 650;
|
||||||
margin: 0 0 12px;
|
|
||||||
}
|
}
|
||||||
.dock__pdf {
|
.dock__trim {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: 0.66rem;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
padding: 3px 5px 3px 8px;
|
||||||
|
border: 1px solid var(--line-strong);
|
||||||
|
border-radius: var(--r-sm);
|
||||||
|
background: var(--raised);
|
||||||
|
}
|
||||||
|
.dock__trim .i {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
color: var(--ink-faint);
|
||||||
|
}
|
||||||
|
.dock__pages {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
.dock__page {
|
||||||
position: relative;
|
position: relative;
|
||||||
aspect-ratio: 5 / 8;
|
aspect-ratio: 5 / 8;
|
||||||
background: #fcfbf7;
|
background: #fcfbf7;
|
||||||
@@ -219,26 +411,18 @@
|
|||||||
padding: 18px 16px 22px;
|
padding: 18px 16px 22px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
color: #2a2622;
|
color: #2a2622;
|
||||||
|
flex: none;
|
||||||
}
|
}
|
||||||
.dock__pdf .rh {
|
.dock__page .pch {
|
||||||
display: block;
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: 4.4px;
|
|
||||||
letter-spacing: 0.16em;
|
|
||||||
color: #b3ab9c;
|
|
||||||
text-align: center;
|
|
||||||
margin-bottom: 14px;
|
|
||||||
}
|
|
||||||
.dock__pdf .pch {
|
|
||||||
font-family: var(--font-ui);
|
font-family: var(--font-ui);
|
||||||
font-size: 4px;
|
font-size: 4px;
|
||||||
letter-spacing: 0.22em;
|
letter-spacing: 0.22em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
color: #9b9484;
|
color: #9b9484;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
margin: 0 0 4px;
|
margin: 6px 0 4px;
|
||||||
}
|
}
|
||||||
.dock__pdf .pdt {
|
.dock__page .pdt {
|
||||||
font-family: var(--font-book);
|
font-family: var(--font-book);
|
||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
font-weight: 560;
|
font-weight: 560;
|
||||||
@@ -246,19 +430,21 @@
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
margin: 0 0 11px;
|
margin: 0 0 11px;
|
||||||
}
|
}
|
||||||
.dock__pdf .pp {
|
.dock__page .pp {
|
||||||
font-family: var(--font-book);
|
font-family: var(--font-book);
|
||||||
font-size: 5.6px;
|
font-size: 5.6px;
|
||||||
line-height: 1.62;
|
line-height: 1.62;
|
||||||
text-align: justify;
|
text-align: justify;
|
||||||
|
hyphens: auto;
|
||||||
|
-webkit-hyphens: auto;
|
||||||
text-indent: 1.3em;
|
text-indent: 1.3em;
|
||||||
margin: 0 0 0.5em;
|
margin: 0 0 0.5em;
|
||||||
color: #2a2622;
|
color: #2a2622;
|
||||||
}
|
}
|
||||||
.dock__pdf .pp--first {
|
.dock__page .pp--first {
|
||||||
text-indent: 0;
|
text-indent: 0;
|
||||||
}
|
}
|
||||||
.dock__pdf .pg {
|
.dock__page .pg {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: 9px;
|
bottom: 9px;
|
||||||
left: 0;
|
left: 0;
|
||||||
@@ -268,10 +454,13 @@
|
|||||||
font-size: 5px;
|
font-size: 5px;
|
||||||
color: #b3ab9c;
|
color: #b3ab9c;
|
||||||
}
|
}
|
||||||
|
.dock__page--peek {
|
||||||
|
padding-top: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 720px) {
|
@media (max-width: 720px) {
|
||||||
.win__body {
|
.win__body {
|
||||||
grid-template-columns: 1fr 168px;
|
grid-template-columns: 1fr 180px;
|
||||||
}
|
}
|
||||||
.side {
|
.side {
|
||||||
display: none;
|
display: none;
|
||||||
|
|||||||
@@ -38,12 +38,6 @@ import OfferBadge from "./OfferBadge.astro";
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
margin-top: 32px;
|
margin-top: 32px;
|
||||||
}
|
}
|
||||||
.cta__btns :global(.dl) {
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
.cta__btns :global(.dl__os) {
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
.cta__offer {
|
.cta__offer {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ const { large = false } = Astro.props;
|
|||||||
</a>
|
</a>
|
||||||
|
|
||||||
<div class="dl__os">
|
<div class="dl__os">
|
||||||
<span class="dl__also">Also for</span>
|
|
||||||
{
|
{
|
||||||
platforms.map((p) => (
|
platforms.map((p) => (
|
||||||
<a
|
<a
|
||||||
@@ -32,7 +31,7 @@ const { large = false } = Astro.props;
|
|||||||
rel="noopener"
|
rel="noopener"
|
||||||
download
|
download
|
||||||
aria-label={`Download for ${p.label}`}
|
aria-label={`Download for ${p.label}`}
|
||||||
title={p.label}
|
title={`Download for ${p.label}`}
|
||||||
>
|
>
|
||||||
<img class="dl__logo" src={`/logos/${p.logo}`} alt={p.label} />
|
<img class="dl__logo" src={`/logos/${p.logo}`} alt={p.label} />
|
||||||
</a>
|
</a>
|
||||||
@@ -45,38 +44,36 @@ const { large = false } = Astro.props;
|
|||||||
.dl {
|
.dl {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 14px;
|
align-items: center;
|
||||||
align-items: flex-start;
|
gap: 16px;
|
||||||
}
|
}
|
||||||
.dl__os {
|
.dl__os {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
justify-content: center;
|
||||||
flex-wrap: wrap;
|
gap: 12px;
|
||||||
}
|
|
||||||
.dl__also {
|
|
||||||
font-size: 0.82rem;
|
|
||||||
color: var(--ink-faint);
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
}
|
||||||
.dl__chip {
|
.dl__chip {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
width: 40px;
|
width: 44px;
|
||||||
height: 40px;
|
height: 44px;
|
||||||
background: #ffffff;
|
background: #ffffff;
|
||||||
border: 1px solid var(--line-strong);
|
border: 1px solid var(--line-strong);
|
||||||
border-radius: var(--r-md);
|
border-radius: var(--r-md);
|
||||||
transition: box-shadow 0.18s var(--ease), transform 0.18s var(--ease);
|
box-shadow: var(--shadow-page);
|
||||||
|
transition:
|
||||||
|
box-shadow 0.18s var(--ease),
|
||||||
|
transform 0.18s var(--ease);
|
||||||
}
|
}
|
||||||
.dl__chip:hover {
|
.dl__chip:hover {
|
||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
box-shadow: var(--shadow-pop);
|
box-shadow: var(--shadow-pop);
|
||||||
}
|
}
|
||||||
.dl__logo {
|
.dl__logo {
|
||||||
width: 20px;
|
width: 22px;
|
||||||
height: 20px;
|
height: 22px;
|
||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -36,6 +36,18 @@ const { repo, releasesUrl } = site;
|
|||||||
if (label) label.textContent = "Download for " + LABELS[os];
|
if (label) label.textContent = "Download for " + LABELS[os];
|
||||||
});
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll(".dl__alt [data-download-os]").forEach((el) => {
|
||||||
|
if (el.dataset.downloadOs === os) el.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll("[data-osrow]").forEach((row) => {
|
||||||
|
const primary = row.querySelector(`[data-download-os="${os}"]`);
|
||||||
|
if (primary) {
|
||||||
|
primary.classList.add("is-detected");
|
||||||
|
row.prepend(primary);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`https://api.github.com/repos/${repo}/releases/latest`, {
|
const res = await fetch(`https://api.github.com/repos/${repo}/releases/latest`, {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
import AppWindow from "./AppWindow.astro";
|
import AppWindow from "./AppWindow.astro";
|
||||||
import DownloadButtons from "./DownloadButtons.astro";
|
|
||||||
import OfferBadge from "./OfferBadge.astro";
|
import OfferBadge from "./OfferBadge.astro";
|
||||||
|
import { site, platforms } from "../data/site";
|
||||||
---
|
---
|
||||||
|
|
||||||
<section class="hero">
|
<section class="hero">
|
||||||
@@ -11,63 +11,126 @@ import OfferBadge from "./OfferBadge.astro";
|
|||||||
<span class="book-accent">Own</span> every word.
|
<span class="book-accent">Own</span> every word.
|
||||||
</h1>
|
</h1>
|
||||||
<p class="lede hero__lede">
|
<p class="lede hero__lede">
|
||||||
Margin is a calm, offline writing studio for authors. Write your book, format it
|
A calm, offline studio to write, format, and publish your book to every store and to
|
||||||
beautifully, and send it to every store and to print, all from your own machine. No
|
print. No subscription, no account. Buy once, own it forever.
|
||||||
subscription, no account. Buy once, own it forever.
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="hero__cta">
|
|
||||||
<DownloadButtons large />
|
|
||||||
</div>
|
|
||||||
<div class="hero__offer">
|
|
||||||
<OfferBadge />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="container hero__art">
|
<div class="container hero__art">
|
||||||
<AppWindow />
|
<AppWindow />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="container hero__get" id="download">
|
||||||
|
<div class="hero__buttons" data-osrow>
|
||||||
|
{
|
||||||
|
platforms.map((p) => (
|
||||||
|
<a
|
||||||
|
class="osbtn"
|
||||||
|
href={site.releasesUrl}
|
||||||
|
data-download-os={p.id}
|
||||||
|
rel="noopener"
|
||||||
|
download
|
||||||
|
>
|
||||||
|
<img class="osbtn__icon" src={`/logos/${p.logo}`} alt="" />
|
||||||
|
<span>Download for {p.label}</span>
|
||||||
|
</a>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<p class="hero__meta">
|
||||||
|
<span data-version>v0.1.2</span> · Works offline · No account
|
||||||
|
</p>
|
||||||
|
<div class="hero__offer">
|
||||||
|
<OfferBadge />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.hero {
|
.hero {
|
||||||
padding-top: clamp(48px, 7vw, 92px);
|
padding-top: clamp(36px, 4.5vw, 60px);
|
||||||
padding-bottom: clamp(40px, 6vw, 72px);
|
padding-bottom: clamp(48px, 6vw, 80px);
|
||||||
background:
|
background:
|
||||||
radial-gradient(120% 90% at 50% -10%, var(--paper) 0%, transparent 60%),
|
radial-gradient(120% 90% at 50% -10%, var(--paper) 0%, transparent 60%),
|
||||||
var(--shell);
|
var(--shell);
|
||||||
}
|
}
|
||||||
.hero__head {
|
.hero__head {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
max-width: 760px;
|
max-width: 720px;
|
||||||
}
|
}
|
||||||
h1 {
|
h1 {
|
||||||
font-size: clamp(2.6rem, 1.6rem + 4.4vw, 4.5rem);
|
font-size: clamp(2.4rem, 1.6rem + 3.4vw, 3.9rem);
|
||||||
letter-spacing: -0.02em;
|
letter-spacing: -0.02em;
|
||||||
margin-bottom: 22px;
|
margin-bottom: 18px;
|
||||||
}
|
}
|
||||||
.hero__lede {
|
.hero__lede {
|
||||||
margin-inline: auto;
|
margin-inline: auto;
|
||||||
max-width: 56ch;
|
max-width: 50ch;
|
||||||
}
|
}
|
||||||
.hero__cta {
|
.hero__art {
|
||||||
|
margin-top: clamp(32px, 4vw, 48px);
|
||||||
|
max-width: 1000px;
|
||||||
|
}
|
||||||
|
.hero__get {
|
||||||
|
margin-top: clamp(24px, 3vw, 32px);
|
||||||
|
scroll-margin-top: 96px;
|
||||||
|
}
|
||||||
|
.hero__buttons {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
margin-top: 34px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
.hero__cta :global(.dl) {
|
.osbtn {
|
||||||
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 0.82em 1.35em;
|
||||||
|
background: #ffffff;
|
||||||
|
border: 1px solid var(--line-strong);
|
||||||
|
border-radius: 999px;
|
||||||
|
color: #23201b;
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
box-shadow: var(--shadow-page);
|
||||||
|
transition:
|
||||||
|
transform 0.18s var(--ease),
|
||||||
|
box-shadow 0.18s var(--ease),
|
||||||
|
border-color 0.18s var(--ease);
|
||||||
}
|
}
|
||||||
.hero__cta :global(.dl__os) {
|
.osbtn:hover {
|
||||||
justify-content: center;
|
transform: translateY(-1px);
|
||||||
|
box-shadow: var(--shadow-pop);
|
||||||
|
}
|
||||||
|
.osbtn.is-detected {
|
||||||
|
border-color: var(--ink);
|
||||||
|
}
|
||||||
|
.osbtn__icon {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
flex: none;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
.hero__meta {
|
||||||
|
margin: 16px 0 0;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--ink-faint);
|
||||||
|
text-align: center;
|
||||||
}
|
}
|
||||||
.hero__offer {
|
.hero__offer {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
margin-top: 30px;
|
margin-top: 22px;
|
||||||
|
}
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.hero__buttons {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
.osbtn {
|
||||||
|
justify-content: center;
|
||||||
}
|
}
|
||||||
.hero__art {
|
|
||||||
margin-top: clamp(44px, 6vw, 72px);
|
|
||||||
max-width: 1000px;
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -19,6 +19,12 @@ import Logo from "./Logo.astro";
|
|||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div class="nav__actions">
|
<div class="nav__actions">
|
||||||
|
<a class="btn nav__cta" href="#download">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true">
|
||||||
|
<path d="M12 3v12m0 0 4-4m-4 4-4-4M5 21h14" stroke-linecap="round" stroke-linejoin="round"></path>
|
||||||
|
</svg>
|
||||||
|
Download
|
||||||
|
</a>
|
||||||
<button class="iconlink" id="theme-toggle" aria-label="Toggle theme" type="button">
|
<button class="iconlink" id="theme-toggle" aria-label="Toggle theme" type="button">
|
||||||
<svg class="i-sun" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" aria-hidden="true">
|
<svg class="i-sun" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" aria-hidden="true">
|
||||||
<circle cx="12" cy="12" r="4"></circle>
|
<circle cx="12" cy="12" r="4"></circle>
|
||||||
@@ -89,6 +95,14 @@ import Logo from "./Logo.astro";
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
.nav__cta {
|
||||||
|
padding: 0.5em 1.05em;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.nav__cta svg {
|
||||||
|
width: 1em;
|
||||||
|
height: 1em;
|
||||||
|
}
|
||||||
.iconlink {
|
.iconlink {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ const { cap, left } = site.launch;
|
|||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background: color-mix(in srgb, var(--good) 12%, var(--paper));
|
background: color-mix(in srgb, var(--good) 12%, var(--paper));
|
||||||
border: 1px solid color-mix(in srgb, var(--good) 32%, transparent);
|
border: 1px solid color-mix(in srgb, var(--good) 32%, transparent);
|
||||||
box-shadow: var(--shadow-page);
|
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
|
|||||||
@@ -182,12 +182,6 @@ const included = [
|
|||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
.price-card__cta :global(.dl) {
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
.price-card__cta :global(.dl__os) {
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
.price-card__note {
|
.price-card__note {
|
||||||
margin-top: 26px;
|
margin-top: 26px;
|
||||||
font-size: 0.84rem;
|
font-size: 0.84rem;
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ export const site = {
|
|||||||
tagline: "Write your book. Own every word.",
|
tagline: "Write your book. Own every word.",
|
||||||
description:
|
description:
|
||||||
"Margin is a calm, offline writing studio for authors. Write your book and send it to print and to every ebook store, all from your own machine. Buy once, own it forever.",
|
"Margin is a calm, offline writing studio for authors. Write your book and send it to print and to every ebook store, all from your own machine. Buy once, own it forever.",
|
||||||
domain: "https://margin.studio",
|
domain: "https://margin.73ai.org",
|
||||||
|
|
||||||
// The regular one-time price, shown struck through during the launch offer.
|
// The regular one-time price, shown struck through during the launch offer.
|
||||||
price: "$10",
|
price: "$10",
|
||||||
@@ -16,7 +16,7 @@ export const site = {
|
|||||||
left: 12,
|
left: 12,
|
||||||
},
|
},
|
||||||
|
|
||||||
supportEmail: "[email protected]",
|
supportEmail: "[email protected]",
|
||||||
|
|
||||||
// Repo that hosts the release files. Used only to resolve the right download
|
// Repo that hosts the release files. Used only to resolve the right download
|
||||||
// per OS at runtime. It is never shown to the reader.
|
// per OS at runtime. It is never shown to the reader.
|
||||||
|
|||||||
Reference in new issue
Block a user