mirror of
https://github.com/jackyzha0/quartz.git
synced 2025-05-18 14:34:23 +02:00
Compare commits
5 Commits
c5109a18bf
...
e05df44a0b
Author | SHA1 | Date | |
---|---|---|---|
![]() |
e05df44a0b | ||
![]() |
91189dfd2f | ||
![]() |
142a08a6f8 | ||
![]() |
10063e1247 | ||
![]() |
d595a9e6b7 |
@ -24,6 +24,9 @@ This part of the configuration concerns anything that can affect the whole site.
|
||||
- `pageTitleSuffix`: a string added to the end of the page title. This only applies to the browser tab title, not the title shown at the top of the page.
|
||||
- `enableSPA`: whether to enable [[SPA Routing]] on your site.
|
||||
- `enablePopovers`: whether to enable [[popover previews]] on your site.
|
||||
- `passProtected`: what to use [[Password Protected]] on your site.
|
||||
- `enabled`: whether to enable password protected
|
||||
- `iteration`: iteration of key derivation, default is `2e6`
|
||||
- `analytics`: what to use for analytics on your site. Values can be
|
||||
- `null`: don't use analytics;
|
||||
- `{ provider: 'google', tagId: '<your-google-tag>' }`: use Google Analytics;
|
||||
|
32
docs/features/Password Protected.md
Normal file
32
docs/features/Password Protected.md
Normal file
@ -0,0 +1,32 @@
|
||||
---
|
||||
title: Password Protected
|
||||
---
|
||||
|
||||
Some notes may be sensitive, i.e. non-public personal projects, contacts, meeting notes and such. It would be really useful to be able to protect some pages or group of pages so they don't appear to everyone, while still allowing them to be published.
|
||||
|
||||
By adding a password to your note's frontmatter, you can create an extra layer of security, ensuring that only authorized individuals can access your content. Whether you're safeguarding personal journals, project plans, this feature provides the peace of mind you need.
|
||||
|
||||
## How it works
|
||||
|
||||
Simply add a password field to your note's frontmatter and set your desired password. When you try to view the note, you'll be prompted to enter the password. If the password is correct, the note will be unlocked. Once unlocked, you can access the note until you clear your browser cookies.
|
||||
|
||||
### Security techniques
|
||||
|
||||
- Key Derivation: Utilizes PBKDF2 for generating secure encryption keys.
|
||||
- Unique Salt for Each Encryption: A unique salt is generated every time the encrypt method is used, enhancing security.
|
||||
- Encryption/Decryption: Implements AES-GCM for robust data encryption and decryption.
|
||||
- Encoding/Decoding: Use base64 to convert non-textual encrypted data in HTML
|
||||
|
||||
### Disclaimer
|
||||
|
||||
- Use it at your own risk
|
||||
- You need to choose a strong password and share it only to trusted users
|
||||
- You need to secure your notes and Quartz repository in private mode on Github/Gitlab/Bitbucket... or use your own Git server
|
||||
- You can use other WAF tools to enhance security, based on URL of notes that Quartz build for you, e.g. Cloudflare WAF, AWS WAF, Google Cloud Armor...
|
||||
|
||||
## Configuration
|
||||
|
||||
- Enable password protected notes: set the `passwordProtected.enabled` field in `quartz.config.ts` to be `true`.
|
||||
- Change iteration count of key derivation: set the `passwordProtected.iteration` filed in `quartz.config.ts` to any bigger than 2e6.
|
||||
- Style: `quartz/components/styles/passwordProtected.scss`
|
||||
- Script: `quartz/components/scripts/decrypt.inline.ts`
|
7
package-lock.json
generated
7
package-lock.json
generated
@ -55,6 +55,7 @@
|
||||
"remark-parse": "^11.0.0",
|
||||
"remark-rehype": "^11.1.1",
|
||||
"remark-smartypants": "^3.0.2",
|
||||
"rfc4648": "^1.5.3",
|
||||
"rfdc": "^1.4.1",
|
||||
"rimraf": "^6.0.1",
|
||||
"satori": "^0.12.1",
|
||||
@ -6128,6 +6129,12 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/rfc4648": {
|
||||
"version": "1.5.3",
|
||||
"resolved": "https://registry.npmjs.org/rfc4648/-/rfc4648-1.5.3.tgz",
|
||||
"integrity": "sha512-MjOWxM065+WswwnmNONOT+bD1nXzY9Km6u3kzvnx8F8/HXGZdz3T6e6vZJ8Q/RIMUSp/nxqjH3GwvJDy8ijeQQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/rfdc": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz",
|
||||
|
@ -81,6 +81,7 @@
|
||||
"remark-parse": "^11.0.0",
|
||||
"remark-rehype": "^11.1.1",
|
||||
"remark-smartypants": "^3.0.2",
|
||||
"rfc4648": "^1.5.3",
|
||||
"rfdc": "^1.4.1",
|
||||
"rimraf": "^6.0.1",
|
||||
"satori": "^0.12.1",
|
||||
|
@ -20,6 +20,10 @@ const config: QuartzConfig = {
|
||||
ignorePatterns: ["private", "templates", ".obsidian"],
|
||||
defaultDateType: "created",
|
||||
generateSocialImages: false,
|
||||
passProtected: {
|
||||
enabled: false,
|
||||
iteration: 2e6,
|
||||
},
|
||||
theme: {
|
||||
fontOrigin: "googleFonts",
|
||||
cdnCaching: true,
|
||||
|
@ -27,7 +27,7 @@ export const defaultContentPageLayout: PageLayout = {
|
||||
Component.MobileOnly(Component.Spacer()),
|
||||
Component.Search(),
|
||||
Component.Darkmode(),
|
||||
Component.DesktopOnly(Component.Explorer()),
|
||||
Component.Explorer(),
|
||||
],
|
||||
right: [
|
||||
Component.Graph(),
|
||||
@ -44,7 +44,7 @@ export const defaultListPageLayout: PageLayout = {
|
||||
Component.MobileOnly(Component.Spacer()),
|
||||
Component.Search(),
|
||||
Component.Darkmode(),
|
||||
Component.DesktopOnly(Component.Explorer()),
|
||||
Component.Explorer(),
|
||||
],
|
||||
right: [],
|
||||
}
|
||||
|
@ -44,6 +44,13 @@ export type Analytics =
|
||||
projectId?: string
|
||||
}
|
||||
|
||||
export type PassProtected = {
|
||||
/** Whether to enable password protected page rendering */
|
||||
enabled: boolean
|
||||
/** Iteration of derived key to encrypt page */
|
||||
iteration: number
|
||||
}
|
||||
|
||||
export interface GlobalConfiguration {
|
||||
pageTitle: string
|
||||
pageTitleSuffix?: string
|
||||
@ -57,6 +64,8 @@ export interface GlobalConfiguration {
|
||||
ignorePatterns: string[]
|
||||
/** Whether to use created, modified, or published as the default type of date */
|
||||
defaultDateType: ValidDateType
|
||||
/** Password protected page rendering */
|
||||
passProtected: PassProtected
|
||||
/** Base URL to use for CNAME files, sitemaps, and RSS feeds that require an absolute URL.
|
||||
* Quartz will avoid using this as much as possible and use relative URLs most of the time
|
||||
*/
|
||||
|
@ -250,7 +250,7 @@ export async function handleBuild(argv) {
|
||||
|
||||
// remove default exports that we manually inserted
|
||||
text = text.replace("export default", "")
|
||||
text = text.replace("export", "")
|
||||
text = text.replace("export ", "")
|
||||
|
||||
const sourcefile = path.relative(path.resolve("."), args.path)
|
||||
const resolveDir = path.dirname(sourcefile)
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types"
|
||||
import explorerStyle from "./styles/explorer.scss"
|
||||
import style from "./styles/explorer.scss"
|
||||
|
||||
// @ts-ignore
|
||||
import script from "./scripts/explorer.inline"
|
||||
@ -83,18 +83,46 @@ export default ((userOpts?: Partial<Options>) => {
|
||||
lastBuildId = ctx.buildId
|
||||
constructFileTree(allFiles)
|
||||
}
|
||||
|
||||
return (
|
||||
<div class={classNames(displayClass, "explorer")}>
|
||||
<button
|
||||
type="button"
|
||||
id="explorer"
|
||||
id="mobile-explorer"
|
||||
class="collapsed hide-until-loaded"
|
||||
data-behavior={opts.folderClickBehavior}
|
||||
data-collapsed={opts.folderDefaultState}
|
||||
data-savestate={opts.useSavedState}
|
||||
data-tree={jsonTree}
|
||||
data-mobile={true}
|
||||
aria-controls="explorer-content"
|
||||
aria-expanded={opts.folderDefaultState === "open"}
|
||||
aria-expanded={false}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="lucide lucide-menu"
|
||||
>
|
||||
<line x1="4" x2="20" y1="12" y2="12" />
|
||||
<line x1="4" x2="20" y1="6" y2="6" />
|
||||
<line x1="4" x2="20" y1="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
id="desktop-explorer"
|
||||
class="title-button"
|
||||
data-behavior={opts.folderClickBehavior}
|
||||
data-collapsed={opts.folderDefaultState}
|
||||
data-savestate={opts.useSavedState}
|
||||
data-tree={jsonTree}
|
||||
data-mobile={false}
|
||||
aria-controls="explorer-content"
|
||||
aria-expanded={true}
|
||||
>
|
||||
<h2>{opts.title ?? i18n(cfg.locale).components.explorer.title}</h2>
|
||||
<svg
|
||||
@ -122,7 +150,7 @@ export default ((userOpts?: Partial<Options>) => {
|
||||
)
|
||||
}
|
||||
|
||||
Explorer.css = explorerStyle
|
||||
Explorer.css = style
|
||||
Explorer.afterDOMLoaded = script
|
||||
return Explorer
|
||||
}) satisfies QuartzComponentConstructor
|
||||
|
41
quartz/components/pages/EncryptedContent.tsx
Normal file
41
quartz/components/pages/EncryptedContent.tsx
Normal file
@ -0,0 +1,41 @@
|
||||
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "../types"
|
||||
import { i18n } from "../../i18n"
|
||||
|
||||
const EncryptedContent: QuartzComponent = ({ encryptedContent, cfg }: QuartzComponentProps) => {
|
||||
return (
|
||||
<>
|
||||
<div id="lock">
|
||||
<div
|
||||
id="msg"
|
||||
data-wrong={i18n(cfg.locale).pages.encryptedContent.wrongPassword}
|
||||
data-modern={i18n(cfg.locale).pages.encryptedContent.modernBrowser}
|
||||
data-empty={i18n(cfg.locale).pages.encryptedContent.noPayload}
|
||||
>
|
||||
{i18n(cfg.locale).pages.encryptedContent.enterPassword}
|
||||
</div>
|
||||
<div id="load">
|
||||
<p class="spinner"></p>
|
||||
<p id="load-text" data-decrypt={i18n(cfg.locale).pages.encryptedContent.decrypting}>
|
||||
{i18n(cfg.locale).pages.encryptedContent.loading}
|
||||
</p>
|
||||
</div>
|
||||
<form class="hidden">
|
||||
<input
|
||||
type="password"
|
||||
class="pwd"
|
||||
name="pwd"
|
||||
aria-label={i18n(cfg.locale).pages.encryptedContent.password}
|
||||
autofocus
|
||||
/>
|
||||
<input type="submit" value={i18n(cfg.locale).pages.encryptedContent.submit} />
|
||||
</form>
|
||||
<pre class="hidden" data-i={cfg.passProtected?.iteration}>
|
||||
{encryptedContent}
|
||||
</pre>
|
||||
</div>
|
||||
<article id="content"></article>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default (() => EncryptedContent) satisfies QuartzComponentConstructor
|
@ -2,7 +2,9 @@ import { render } from "preact-render-to-string"
|
||||
import { QuartzComponent, QuartzComponentProps } from "./types"
|
||||
import HeaderConstructor from "./Header"
|
||||
import BodyConstructor from "./Body"
|
||||
import EncryptedContent from "./pages/EncryptedContent"
|
||||
import { JSResourceToScriptElement, StaticResources } from "../util/resources"
|
||||
import { getEncryptedPayload } from "../util/encrypt"
|
||||
import { clone, FullSlug, RelativeURL, joinSegments, normalizeHastElement } from "../util/path"
|
||||
import { visit } from "unist-util-visit"
|
||||
import { Root, Element, ElementContent } from "hast"
|
||||
@ -77,13 +79,13 @@ export function pageResources(
|
||||
return resources
|
||||
}
|
||||
|
||||
export function renderPage(
|
||||
export async function renderPage(
|
||||
cfg: GlobalConfiguration,
|
||||
slug: FullSlug,
|
||||
componentData: QuartzComponentProps,
|
||||
components: RenderComponents,
|
||||
pageResources: StaticResources,
|
||||
): string {
|
||||
): Promise<string> {
|
||||
// make a deep copy of the tree so we don't remove the transclusion references
|
||||
// for the file cached in contentMap in build.ts
|
||||
const root = clone(componentData.tree) as Root
|
||||
@ -219,6 +221,7 @@ export function renderPage(
|
||||
} = components
|
||||
const Header = HeaderConstructor()
|
||||
const Body = BodyConstructor()
|
||||
const Encrypted = EncryptedContent()
|
||||
|
||||
const LeftComponent = (
|
||||
<div class="left sidebar">
|
||||
@ -237,6 +240,16 @@ export function renderPage(
|
||||
)
|
||||
|
||||
const lang = componentData.fileData.frontmatter?.lang ?? cfg.locale?.split("-")[0] ?? "en"
|
||||
|
||||
let content = <Content {...componentData} />
|
||||
if (cfg.passProtected?.enabled && componentData.fileData.frontmatter?.password) {
|
||||
componentData.encryptedContent = await getEncryptedPayload(
|
||||
render(content),
|
||||
componentData.fileData.frontmatter.password.toString(),
|
||||
cfg.passProtected?.iteration,
|
||||
)
|
||||
content = <Encrypted {...componentData} />
|
||||
}
|
||||
const doc = (
|
||||
<html lang={lang}>
|
||||
<Head {...componentData} />
|
||||
@ -257,7 +270,7 @@ export function renderPage(
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<Content {...componentData} />
|
||||
{content}
|
||||
<hr />
|
||||
<div class="page-footer">
|
||||
{afterBody.map((BodyComponent) => (
|
||||
|
168
quartz/components/scripts/decrypt.inline.ts
Normal file
168
quartz/components/scripts/decrypt.inline.ts
Normal file
@ -0,0 +1,168 @@
|
||||
import { base64 } from "rfc4648"
|
||||
|
||||
// @ts-ignore:next-line
|
||||
function find<T>(selector: string): T {
|
||||
const element = document.querySelector(selector) as T
|
||||
if (element) return element
|
||||
}
|
||||
|
||||
let salt: Uint8Array, iv: Uint8Array, ciphertext: Uint8Array, iterations: number
|
||||
const subtle =
|
||||
window.crypto?.subtle ||
|
||||
(window.crypto as unknown as { webkitSubtle: Crypto["subtle"] })?.webkitSubtle
|
||||
|
||||
let pl: HTMLPreElement,
|
||||
form: HTMLFormElement,
|
||||
pwd: HTMLInputElement,
|
||||
load: HTMLDivElement,
|
||||
loadText: HTMLElement,
|
||||
lock: HTMLDivElement,
|
||||
msg: HTMLParagraphElement,
|
||||
article: HTMLElement
|
||||
|
||||
async function decryptHTML() {
|
||||
pl = find<HTMLPreElement>("pre[data-i]")
|
||||
form = find<HTMLFormElement>("form")
|
||||
pwd = find<HTMLInputElement>(".pwd")
|
||||
load = find<HTMLDivElement>("#load")
|
||||
loadText = find<HTMLElement>("#load-text")
|
||||
lock = find<HTMLDivElement>("#lock")
|
||||
msg = find<HTMLParagraphElement>("#msg")
|
||||
article = find<HTMLElement>("#content")
|
||||
|
||||
if (!pl || !form || !pwd) {
|
||||
return
|
||||
}
|
||||
pwd.value = ""
|
||||
if (!subtle) {
|
||||
pwd.disabled = true
|
||||
error("modern")
|
||||
return
|
||||
}
|
||||
|
||||
show(lock)
|
||||
if (!pl.innerHTML) {
|
||||
pwd.disabled = true
|
||||
error("empty")
|
||||
return
|
||||
}
|
||||
|
||||
form.addEventListener("submit", async (event) => {
|
||||
event.preventDefault()
|
||||
await decrypt()
|
||||
})
|
||||
|
||||
iterations = Number(pl.dataset.i)
|
||||
const bytes = base64.parse(pl.innerHTML)
|
||||
salt = bytes.slice(0, 32)
|
||||
iv = bytes.slice(32, 32 + 16)
|
||||
ciphertext = bytes.slice(32 + 16)
|
||||
|
||||
if (location.hash) {
|
||||
const parts = location.href.split("#")
|
||||
pwd.value = parts[1]
|
||||
history.replaceState(null, "", parts[0])
|
||||
}
|
||||
|
||||
if (sessionStorage[document.body.dataset.slug!] || pwd.value) {
|
||||
await decrypt()
|
||||
} else {
|
||||
hide(load)
|
||||
show(form)
|
||||
pwd.focus()
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("nav", decryptHTML)
|
||||
|
||||
function show(element: Element) {
|
||||
element.classList.remove("hidden")
|
||||
}
|
||||
|
||||
function hide(element: Element) {
|
||||
element.classList.add("hidden")
|
||||
}
|
||||
|
||||
function error(code: string) {
|
||||
msg.innerText = msg.getAttribute("data-" + code) || ""
|
||||
}
|
||||
|
||||
async function sleep(milliseconds: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, milliseconds))
|
||||
}
|
||||
|
||||
async function decrypt() {
|
||||
loadText.innerText = loadText.getAttribute("data-decrypt") || ""
|
||||
show(load)
|
||||
hide(form)
|
||||
await sleep(60)
|
||||
|
||||
try {
|
||||
const decrypted = await decryptFile({ salt, iv, ciphertext, iterations }, pwd.value)
|
||||
|
||||
article.innerHTML = decrypted
|
||||
hide(lock)
|
||||
} catch (e) {
|
||||
hide(load)
|
||||
show(form)
|
||||
|
||||
if (sessionStorage[document.body.dataset.slug!]) {
|
||||
sessionStorage.removeItem(document.body.dataset.slug!)
|
||||
} else {
|
||||
error("wrong")
|
||||
}
|
||||
|
||||
pwd.value = ""
|
||||
pwd.focus()
|
||||
}
|
||||
}
|
||||
|
||||
async function deriveKey(
|
||||
salt: Uint8Array,
|
||||
password: string,
|
||||
iterations: number,
|
||||
): Promise<CryptoKey> {
|
||||
const encoder = new TextEncoder()
|
||||
const baseKey = await subtle.importKey("raw", encoder.encode(password), "PBKDF2", false, [
|
||||
"deriveKey",
|
||||
])
|
||||
return await subtle.deriveKey(
|
||||
{ name: "PBKDF2", salt, iterations, hash: "SHA-256" },
|
||||
baseKey,
|
||||
{ name: "AES-GCM", length: 256 },
|
||||
true,
|
||||
["decrypt"],
|
||||
)
|
||||
}
|
||||
|
||||
async function importKey(key: JsonWebKey) {
|
||||
return subtle.importKey("jwk", key, "AES-GCM", true, ["decrypt"])
|
||||
}
|
||||
|
||||
async function decryptFile(
|
||||
{
|
||||
salt,
|
||||
iv,
|
||||
ciphertext,
|
||||
iterations,
|
||||
}: {
|
||||
salt: Uint8Array
|
||||
iv: Uint8Array
|
||||
ciphertext: Uint8Array
|
||||
iterations: number
|
||||
},
|
||||
password: string,
|
||||
) {
|
||||
const decoder = new TextDecoder()
|
||||
|
||||
const key = sessionStorage[document.body.dataset.slug!]
|
||||
? await importKey(JSON.parse(sessionStorage[document.body.dataset.slug!]))
|
||||
: await deriveKey(salt, password, iterations)
|
||||
|
||||
const data = new Uint8Array(await subtle.decrypt({ name: "AES-GCM", iv }, key, ciphertext))
|
||||
if (!data) throw "Malformed data"
|
||||
|
||||
sessionStorage[document.body.dataset.slug!] = JSON.stringify(await subtle.exportKey("jwk", key))
|
||||
|
||||
return decoder.decode(data)
|
||||
}
|
@ -1,7 +1,9 @@
|
||||
import { FolderState } from "../ExplorerNode"
|
||||
|
||||
// Current state of folders
|
||||
type MaybeHTMLElement = HTMLElement | undefined
|
||||
let currentExplorerState: FolderState[]
|
||||
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
// If last element is observed, remove gradient of "overflow" class so element is visible
|
||||
const explorerUl = document.getElementById("explorer-ul")
|
||||
@ -16,23 +18,43 @@ const observer = new IntersectionObserver((entries) => {
|
||||
})
|
||||
|
||||
function toggleExplorer(this: HTMLElement) {
|
||||
// Toggle collapsed state of entire explorer
|
||||
this.classList.toggle("collapsed")
|
||||
|
||||
// Toggle collapsed aria state of entire explorer
|
||||
this.setAttribute(
|
||||
"aria-expanded",
|
||||
this.getAttribute("aria-expanded") === "true" ? "false" : "true",
|
||||
)
|
||||
const content = this.nextElementSibling as MaybeHTMLElement
|
||||
if (!content) return
|
||||
|
||||
const content = (
|
||||
this.nextElementSibling?.nextElementSibling
|
||||
? this.nextElementSibling.nextElementSibling
|
||||
: this.nextElementSibling
|
||||
) as MaybeHTMLElement
|
||||
if (!content) return
|
||||
content.classList.toggle("collapsed")
|
||||
content.classList.toggle("explorer-viewmode")
|
||||
|
||||
// Prevent scroll under
|
||||
if (document.querySelector("#mobile-explorer")) {
|
||||
// Disable scrolling on the page when the explorer is opened on mobile
|
||||
const bodySelector = document.querySelector("#quartz-body")
|
||||
if (bodySelector) bodySelector.classList.toggle("lock-scroll")
|
||||
}
|
||||
}
|
||||
|
||||
function toggleFolder(evt: MouseEvent) {
|
||||
evt.stopPropagation()
|
||||
|
||||
// Element that was clicked
|
||||
const target = evt.target as MaybeHTMLElement
|
||||
if (!target) return
|
||||
|
||||
// Check if target was svg icon or button
|
||||
const isSvg = target.nodeName === "svg"
|
||||
|
||||
// corresponding <ul> element relative to clicked button/folder
|
||||
const childFolderContainer = (
|
||||
isSvg
|
||||
? target.parentElement?.nextSibling
|
||||
@ -42,10 +64,14 @@ function toggleFolder(evt: MouseEvent) {
|
||||
isSvg ? target.nextElementSibling : target.parentElement
|
||||
) as MaybeHTMLElement
|
||||
if (!(childFolderContainer && currentFolderParent)) return
|
||||
|
||||
// <li> element of folder (stores folder-path dataset)
|
||||
childFolderContainer.classList.toggle("open")
|
||||
|
||||
// Collapse folder container
|
||||
const isCollapsed = childFolderContainer.classList.contains("open")
|
||||
setFolderState(childFolderContainer, !isCollapsed)
|
||||
|
||||
// Save folder state to localStorage
|
||||
const fullFolderPath = currentFolderParent.dataset.folderpath as string
|
||||
toggleCollapsedByPath(currentExplorerState, fullFolderPath)
|
||||
const stringifiedFileTree = JSON.stringify(currentExplorerState)
|
||||
@ -53,57 +79,106 @@ function toggleFolder(evt: MouseEvent) {
|
||||
}
|
||||
|
||||
function setupExplorer() {
|
||||
const explorer = document.getElementById("explorer")
|
||||
if (!explorer) return
|
||||
// Set click handler for collapsing entire explorer
|
||||
const allExplorers = document.querySelectorAll(".explorer > button") as NodeListOf<HTMLElement>
|
||||
|
||||
if (explorer.dataset.behavior === "collapse") {
|
||||
for (const explorer of allExplorers) {
|
||||
// Get folder state from local storage
|
||||
const storageTree = localStorage.getItem("fileTree")
|
||||
|
||||
// Convert to bool
|
||||
const useSavedFolderState = explorer?.dataset.savestate === "true"
|
||||
|
||||
if (explorer) {
|
||||
// Get config
|
||||
const collapseBehavior = explorer.dataset.behavior
|
||||
|
||||
// Add click handlers for all folders (click handler on folder "label")
|
||||
if (collapseBehavior === "collapse") {
|
||||
for (const item of document.getElementsByClassName(
|
||||
"folder-button",
|
||||
) as HTMLCollectionOf<HTMLElement>) {
|
||||
window.addCleanup(() => explorer.removeEventListener("click", toggleExplorer))
|
||||
item.addEventListener("click", toggleFolder)
|
||||
}
|
||||
}
|
||||
|
||||
// Add click handler to main explorer
|
||||
window.addCleanup(() => explorer.removeEventListener("click", toggleExplorer))
|
||||
explorer.addEventListener("click", toggleExplorer)
|
||||
}
|
||||
|
||||
// Set up click handlers for each folder (click handler on folder "icon")
|
||||
for (const item of document.getElementsByClassName(
|
||||
"folder-button",
|
||||
"folder-icon",
|
||||
) as HTMLCollectionOf<HTMLElement>) {
|
||||
item.addEventListener("click", toggleFolder)
|
||||
window.addCleanup(() => item.removeEventListener("click", toggleFolder))
|
||||
}
|
||||
|
||||
// Get folder state from local storage
|
||||
const oldExplorerState: FolderState[] =
|
||||
storageTree && useSavedFolderState ? JSON.parse(storageTree) : []
|
||||
const oldIndex = new Map(oldExplorerState.map((entry) => [entry.path, entry.collapsed]))
|
||||
const newExplorerState: FolderState[] = explorer.dataset.tree
|
||||
? JSON.parse(explorer.dataset.tree)
|
||||
: []
|
||||
currentExplorerState = []
|
||||
|
||||
for (const { path, collapsed } of newExplorerState) {
|
||||
currentExplorerState.push({
|
||||
path,
|
||||
collapsed: oldIndex.get(path) ?? collapsed,
|
||||
})
|
||||
}
|
||||
|
||||
currentExplorerState.map((folderState) => {
|
||||
const folderLi = document.querySelector(
|
||||
`[data-folderpath='${folderState.path.replace("'", "-")}']`,
|
||||
) as MaybeHTMLElement
|
||||
const folderUl = folderLi?.parentElement?.nextElementSibling as MaybeHTMLElement
|
||||
if (folderUl) {
|
||||
setFolderState(folderUl, folderState.collapsed)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
explorer.addEventListener("click", toggleExplorer)
|
||||
window.addCleanup(() => explorer.removeEventListener("click", toggleExplorer))
|
||||
function toggleExplorerFolders() {
|
||||
const currentFile = (document.querySelector("body")?.getAttribute("data-slug") ?? "").replace(
|
||||
/\/index$/g,
|
||||
"",
|
||||
)
|
||||
const allFolders = document.querySelectorAll(".folder-outer")
|
||||
|
||||
// Set up click handlers for each folder (click handler on folder "icon")
|
||||
for (const item of document.getElementsByClassName(
|
||||
"folder-icon",
|
||||
) as HTMLCollectionOf<HTMLElement>) {
|
||||
item.addEventListener("click", toggleFolder)
|
||||
window.addCleanup(() => item.removeEventListener("click", toggleFolder))
|
||||
}
|
||||
|
||||
// Get folder state from local storage
|
||||
const storageTree = localStorage.getItem("fileTree")
|
||||
const useSavedFolderState = explorer?.dataset.savestate === "true"
|
||||
const oldExplorerState: FolderState[] =
|
||||
storageTree && useSavedFolderState ? JSON.parse(storageTree) : []
|
||||
const oldIndex = new Map(oldExplorerState.map((entry) => [entry.path, entry.collapsed]))
|
||||
const newExplorerState: FolderState[] = explorer.dataset.tree
|
||||
? JSON.parse(explorer.dataset.tree)
|
||||
: []
|
||||
currentExplorerState = []
|
||||
for (const { path, collapsed } of newExplorerState) {
|
||||
currentExplorerState.push({ path, collapsed: oldIndex.get(path) ?? collapsed })
|
||||
}
|
||||
|
||||
currentExplorerState.map((folderState) => {
|
||||
const folderLi = document.querySelector(
|
||||
`[data-folderpath='${folderState.path}']`,
|
||||
) as MaybeHTMLElement
|
||||
const folderUl = folderLi?.parentElement?.nextElementSibling as MaybeHTMLElement
|
||||
allFolders.forEach((element) => {
|
||||
const folderUl = Array.from(element.children).find((child) =>
|
||||
child.matches("ul[data-folderul]"),
|
||||
)
|
||||
if (folderUl) {
|
||||
setFolderState(folderUl, folderState.collapsed)
|
||||
if (currentFile.includes(folderUl.getAttribute("data-folderul") ?? "")) {
|
||||
if (!element.classList.contains("open")) {
|
||||
element.classList.add("open")
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
window.addEventListener("resize", setupExplorer)
|
||||
|
||||
document.addEventListener("nav", () => {
|
||||
const explorer = document.querySelector("#mobile-explorer")
|
||||
if (explorer) {
|
||||
explorer.classList.add("collapsed")
|
||||
const content = explorer.nextElementSibling?.nextElementSibling as HTMLElement
|
||||
if (content) {
|
||||
content.classList.add("collapsed")
|
||||
content.classList.toggle("explorer-viewmode")
|
||||
}
|
||||
}
|
||||
setupExplorer()
|
||||
|
||||
observer.disconnect()
|
||||
|
||||
// select pseudo element at end of list
|
||||
@ -111,6 +186,12 @@ document.addEventListener("nav", () => {
|
||||
if (lastItem) {
|
||||
observer.observe(lastItem)
|
||||
}
|
||||
|
||||
// Hide explorer on mobile until it is requested
|
||||
const hiddenUntilDoneLoading = document.querySelector("#mobile-explorer")
|
||||
hiddenUntilDoneLoading?.classList.remove("hide-until-loaded")
|
||||
|
||||
toggleExplorerFolders()
|
||||
})
|
||||
|
||||
/**
|
||||
|
@ -1,14 +1,70 @@
|
||||
@use "../../styles/variables.scss" as *;
|
||||
|
||||
@media all and ($mobile) {
|
||||
.page > #quartz-body {
|
||||
// Shift page position when toggling Explorer on mobile.
|
||||
& > :not(.sidebar.left:has(.explorer)) {
|
||||
transform: translateX(0);
|
||||
transition: transform 300ms ease-in-out;
|
||||
}
|
||||
&.lock-scroll > :not(.sidebar.left:has(.explorer)) {
|
||||
transform: translateX(100dvw);
|
||||
transition: transform 300ms ease-in-out;
|
||||
}
|
||||
|
||||
// Sticky top bar (stays in place when scrolling down on mobile).
|
||||
.sidebar.left:has(.explorer) {
|
||||
box-sizing: border-box;
|
||||
position: sticky;
|
||||
background-color: var(--light);
|
||||
}
|
||||
|
||||
// Hide Explorer on mobile until done loading.
|
||||
// Prevents ugly animation on page load.
|
||||
.hide-until-loaded ~ #explorer-content {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.explorer {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
flex-direction: column;
|
||||
overflow-y: hidden;
|
||||
|
||||
@media all and ($mobile) {
|
||||
order: -1;
|
||||
height: initial;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
button#mobile-explorer {
|
||||
display: none;
|
||||
}
|
||||
|
||||
button#desktop-explorer {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
@media all and ($mobile) {
|
||||
button#mobile-explorer {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
button#desktop-explorer {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
&.desktop-only {
|
||||
@media all and not ($mobile) {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
|
||||
/*&:after {
|
||||
pointer-events: none;
|
||||
content: "";
|
||||
@ -23,7 +79,8 @@
|
||||
}*/
|
||||
}
|
||||
|
||||
button#explorer {
|
||||
button#mobile-explorer,
|
||||
button#desktop-explorer {
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
text-align: left;
|
||||
@ -68,19 +125,19 @@ button#explorer {
|
||||
list-style: none;
|
||||
overflow: hidden;
|
||||
overflow-y: auto;
|
||||
max-height: 100%;
|
||||
max-height: 0px;
|
||||
transition:
|
||||
max-height 0.35s ease,
|
||||
visibility 0s linear 0s;
|
||||
visibility 0s linear 0.35s;
|
||||
margin-top: 0.5rem;
|
||||
visibility: visible;
|
||||
visibility: hidden;
|
||||
|
||||
&.collapsed {
|
||||
max-height: 0;
|
||||
max-height: 100%;
|
||||
transition:
|
||||
max-height 0.35s ease,
|
||||
visibility 0s linear 0.35s;
|
||||
visibility: hidden;
|
||||
visibility 0s linear 0s;
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
& ul {
|
||||
@ -91,12 +148,14 @@ button#explorer {
|
||||
max-height 0.35s ease,
|
||||
transform 0.35s ease,
|
||||
opacity 0.2s ease;
|
||||
|
||||
& li > a {
|
||||
color: var(--dark);
|
||||
opacity: 0.75;
|
||||
pointer-events: all;
|
||||
}
|
||||
}
|
||||
|
||||
> #explorer-ul {
|
||||
max-height: none;
|
||||
}
|
||||
@ -179,3 +238,80 @@ li:has(> .folder-outer:not(.open)) > .folder-container > svg {
|
||||
// remove default margin from li
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.explorer {
|
||||
@media all and ($mobile) {
|
||||
#explorer-content {
|
||||
box-sizing: border-box;
|
||||
overscroll-behavior: none;
|
||||
z-index: 100;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
background-color: var(--light);
|
||||
max-width: 100dvw;
|
||||
left: -100dvw;
|
||||
width: 100%;
|
||||
transition: transform 300ms ease-in-out;
|
||||
overflow: hidden;
|
||||
padding: $topSpacing 2rem 2rem;
|
||||
height: 100dvh;
|
||||
max-height: 100dvh;
|
||||
margin-top: 0;
|
||||
visibility: hidden;
|
||||
|
||||
&:not(.collapsed) {
|
||||
transform: translateX(100dvw);
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
ul.overflow {
|
||||
max-height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
&.collapsed {
|
||||
transform: translateX(0);
|
||||
visibility: visible;
|
||||
}
|
||||
}
|
||||
|
||||
#mobile-explorer {
|
||||
margin: 5px;
|
||||
z-index: 101;
|
||||
|
||||
&:not(.collapsed) .lucide-menu {
|
||||
transform: rotate(-90deg);
|
||||
transition: transform 200ms ease-in-out;
|
||||
}
|
||||
|
||||
.lucide-menu {
|
||||
stroke: var(--darkgray);
|
||||
transition: transform 200ms ease;
|
||||
|
||||
&:hover {
|
||||
stroke: var(--dark);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.no-scroll {
|
||||
opacity: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
html:has(.no-scroll) {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@media all and not ($mobile) {
|
||||
.no-scroll {
|
||||
opacity: 1 !important;
|
||||
overflow: auto !important;
|
||||
}
|
||||
|
||||
html:has(.no-scroll) {
|
||||
overflow: auto !important;
|
||||
}
|
||||
}
|
||||
|
30
quartz/components/styles/passProtected.scss
Normal file
30
quartz/components/styles/passProtected.scss
Normal file
@ -0,0 +1,30 @@
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.pwd {
|
||||
box-sizing: border-box;
|
||||
font-family: var(--bodyFont);
|
||||
color: var(--dark);
|
||||
border: 1px solid var(--lightgray);
|
||||
padding: 0.5em 1em;
|
||||
background: var(--light);
|
||||
border-radius: 7px;
|
||||
width: 70%;
|
||||
margin-bottom: 2em;
|
||||
margin-right: 2em;
|
||||
}
|
||||
|
||||
.pwd + input {
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-size: 15px;
|
||||
line-height: 1;
|
||||
color: var(--light);
|
||||
padding: 7px 15px;
|
||||
background-color: var(--dark);
|
||||
margin-top: 1px;
|
||||
border-radius: 7px;
|
||||
position: relative;
|
||||
padding: 0.6em 1em;
|
||||
}
|
@ -22,6 +22,7 @@ export type QuartzComponent = ComponentType<QuartzComponentProps> & {
|
||||
css?: string
|
||||
beforeDOMLoaded?: string
|
||||
afterDOMLoaded?: string
|
||||
encryptedContent?: string
|
||||
}
|
||||
|
||||
export type QuartzComponentConstructor<Options extends object | undefined = undefined> = (
|
||||
|
@ -85,5 +85,15 @@ export default {
|
||||
showingFirst: ({ count }) => `إظهار أول ${count} أوسمة.`,
|
||||
totalTags: ({ count }) => `يوجد ${count} أوسمة.`,
|
||||
},
|
||||
encryptedContent: {
|
||||
loading: " التحميل...",
|
||||
password: "كلمة المرور",
|
||||
submit: "إرسال",
|
||||
enterPassword: "الصفحة مقفلة بشكل افتراضي. يرجى إدخال كلمة المرور لفتح القفل:",
|
||||
modernBrowser: ".استخدام متصفح حديث.",
|
||||
wrongPassword: "خاطئة. يرجى إعادة إدخال كلمة المرور لفتح القفل:",
|
||||
noPayload: "حمولة مشفرة.",
|
||||
decrypting: "جاري فك التشفير...",
|
||||
},
|
||||
},
|
||||
} as const satisfies Translation
|
||||
|
@ -80,5 +80,16 @@ export default {
|
||||
showingFirst: ({ count }) => `Mostrant les primeres ${count} etiquetes.`,
|
||||
totalTags: ({ count }) => `S'han trobat ${count} etiquetes en total.`,
|
||||
},
|
||||
encryptedContent: {
|
||||
loading: "Carregant...",
|
||||
password: "Contrasenya",
|
||||
submit: "Enviar",
|
||||
enterPassword:
|
||||
"Aquesta pàgina està bloquejada per defecte. Introduïu la contrasenya per desbloquejarla:",
|
||||
modernBrowser: "Utilitzeu un navegador modern.",
|
||||
wrongPassword: "Contrasenya incorrecta. Introduïu de nou la contrasenya per desbloquejar:",
|
||||
noPayload: "No hi ha càrrega útil xifrada.",
|
||||
decrypting: "Desxifrant...",
|
||||
},
|
||||
},
|
||||
} as const satisfies Translation
|
||||
|
@ -80,5 +80,15 @@ export default {
|
||||
showingFirst: ({ count }) => `Zobrazují se první ${count} tagy.`,
|
||||
totalTags: ({ count }) => `Nalezeno celkem ${count} tagů.`,
|
||||
},
|
||||
encryptedContent: {
|
||||
loading: "Načítání...",
|
||||
password: "Heslo",
|
||||
submit: "Odeslat",
|
||||
enterPassword: "Tato stránka je standardně uzamčena. Zadejte heslo pro odemknutí:",
|
||||
modernBrowser: "Použijte moderní prohlížeč.",
|
||||
wrongPassword: "Nesprávné heslo. Zadejte heslo znovu pro odemknutí:",
|
||||
noPayload: "Není žádné šifrované užitečné zatížení.",
|
||||
decrypting: "Dekódování...",
|
||||
},
|
||||
},
|
||||
} as const satisfies Translation
|
||||
|
@ -80,5 +80,17 @@ export default {
|
||||
showingFirst: ({ count }) => `Die ersten ${count} Tags werden angezeigt.`,
|
||||
totalTags: ({ count }) => `${count} Tags insgesamt.`,
|
||||
},
|
||||
encryptedContent: {
|
||||
loading: "Wird geladen...",
|
||||
password: "Passwort",
|
||||
submit: "Senden",
|
||||
enterPassword:
|
||||
"Diese Seite ist standardmäßig gesperrt. Bitte geben Sie das Passwort ein, um sie zu entsperren:",
|
||||
modernBrowser: "Bitte verwenden Sie einen modernen Browser.",
|
||||
wrongPassword:
|
||||
"Falsches Passwort. Bitte geben Sie das Passwort erneut ein, um zu entsperren:",
|
||||
noPayload: "Keine verschlüsselte Nutzlast.",
|
||||
decrypting: "Entschlüsseln...",
|
||||
},
|
||||
},
|
||||
} as const satisfies Translation
|
||||
|
@ -80,5 +80,15 @@ export interface Translation {
|
||||
showingFirst: (variables: { count: number }) => string
|
||||
totalTags: (variables: { count: number }) => string
|
||||
}
|
||||
encryptedContent: {
|
||||
loading: string
|
||||
password: string
|
||||
submit: string
|
||||
enterPassword: string
|
||||
modernBrowser: string
|
||||
wrongPassword: string
|
||||
noPayload: string
|
||||
decrypting: string
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -80,5 +80,15 @@ export default {
|
||||
showingFirst: ({ count }) => `Showing first ${count} tags.`,
|
||||
totalTags: ({ count }) => `Found ${count} total tags.`,
|
||||
},
|
||||
encryptedContent: {
|
||||
loading: "Loading...",
|
||||
password: "Password",
|
||||
submit: "Submit",
|
||||
enterPassword: "This page is locked by default. Please enter passsword to unlock:",
|
||||
modernBrowser: "Please use a modern browser.",
|
||||
wrongPassword: "Wrong password. Please re-enter passsword to unlock:",
|
||||
noPayload: "No encrypted payload.",
|
||||
decrypting: "Decrypting...",
|
||||
},
|
||||
},
|
||||
} as const satisfies Translation
|
||||
|
@ -80,5 +80,15 @@ export default {
|
||||
showingFirst: ({ count }) => `Showing first ${count} tags.`,
|
||||
totalTags: ({ count }) => `Found ${count} total tags.`,
|
||||
},
|
||||
encryptedContent: {
|
||||
loading: "Loading...",
|
||||
password: "Password",
|
||||
submit: "Submit",
|
||||
enterPassword: "This page is locked by default. Please enter passsword to unlock:",
|
||||
modernBrowser: "Please use a modern browser.",
|
||||
wrongPassword: "Wrong password. Please re-enter passsword to unlock:",
|
||||
noPayload: "No encrypted payload.",
|
||||
decrypting: "Decrypting...",
|
||||
},
|
||||
},
|
||||
} as const satisfies Translation
|
||||
|
@ -80,5 +80,16 @@ export default {
|
||||
showingFirst: ({ count }) => `Mostrando las primeras ${count} etiquetas.`,
|
||||
totalTags: ({ count }) => `Se han encontrado ${count} etiquetas en total.`,
|
||||
},
|
||||
encryptedContent: {
|
||||
loading: "Cargando...",
|
||||
password: "Contraseña",
|
||||
submit: "Enviar",
|
||||
enterPassword:
|
||||
"Esta página está bloqueada por defecto. Introduzca la contraseña para desbloquearla:",
|
||||
modernBrowser: "Utilice un navegador moderno.",
|
||||
wrongPassword: "Contraseña incorrecta. Vuelva a introducir la contraseña para desbloquear:",
|
||||
noPayload: "No hay ninguna carga útil cifrada.",
|
||||
decrypting: "Descifrando...",
|
||||
},
|
||||
},
|
||||
} as const satisfies Translation
|
||||
|
@ -80,5 +80,16 @@ export default {
|
||||
showingFirst: ({ count }) => `در حال نمایش ${count} برچسب.`,
|
||||
totalTags: ({ count }) => `${count} برچسب یافت شد.`,
|
||||
},
|
||||
encryptedContent: {
|
||||
loading: "در حال بارگذاری...",
|
||||
password: "رمز عبور",
|
||||
submit: "ارسال کردن",
|
||||
enterPassword:
|
||||
"این صفحه به طور پیش فرض قفل شده است. لطفا رمز عبور را برای باز کردن قفل وارد کنید:",
|
||||
modernBrowser: "لطفا از یک مرورگر مدرن استفاده کنید.",
|
||||
wrongPassword: "رمز عبور اشتباه است. لطفا رمز عبور را دوباره وارد کنید تا قفل باز شود:",
|
||||
noPayload: "هیچ محموله رمزگذاری شده ای وجود ندارد.",
|
||||
decrypting: "در حال رمزگشایی...",
|
||||
},
|
||||
},
|
||||
} as const satisfies Translation
|
||||
|
@ -80,5 +80,17 @@ export default {
|
||||
showingFirst: ({ count }) => `Affichage des premières ${count} étiquettes.`,
|
||||
totalTags: ({ count }) => `Trouvé ${count} étiquettes au total.`,
|
||||
},
|
||||
encryptedContent: {
|
||||
loading: "Chargement en cours...",
|
||||
password: "Mot de passe",
|
||||
submit: "Soumettre",
|
||||
enterPassword:
|
||||
"Cette page est verrouillée par défaut. Veuillez entrer le mot de passe pour déverrouiller :",
|
||||
modernBrowser: "Veuillez utiliser un navigateur moderne.",
|
||||
wrongPassword:
|
||||
"Mot de passe incorrect. Veuillez saisir à nouveau le mot de passe pour déverrouiller :",
|
||||
noPayload: "Aucune charge utile cryptée.",
|
||||
decrypting: "Décryptage en cours...",
|
||||
},
|
||||
},
|
||||
} as const satisfies Translation
|
||||
|
@ -78,5 +78,16 @@ export default {
|
||||
showingFirst: ({ count }) => `Első ${count} címke megjelenítve.`,
|
||||
totalTags: ({ count }) => `Összesen ${count} címke található.`,
|
||||
},
|
||||
encryptedContent: {
|
||||
loading: "Betöltés...",
|
||||
password: "Jelszó",
|
||||
submit: "Küldés",
|
||||
enterPassword:
|
||||
"Ez az oldal alapértelmezés szerint zárolva van. Kérjük, adja meg a jelszót a feloldáshoz:",
|
||||
modernBrowser: "Kérjük, használjon modern böngészőt.",
|
||||
wrongPassword: "Helytelen jelszó. Kérjük, adja meg újra a jelszót a feloldáshoz:",
|
||||
noPayload: "Nincs titkosított hasznos teher.",
|
||||
decrypting: "Dekódolás...",
|
||||
},
|
||||
},
|
||||
} as const satisfies Translation
|
||||
|
@ -80,5 +80,16 @@ export default {
|
||||
showingFirst: ({ count }) => `Prime ${count} etichette.`,
|
||||
totalTags: ({ count }) => `Trovate ${count} etichette totali.`,
|
||||
},
|
||||
encryptedContent: {
|
||||
loading: "Caricamento in corso...",
|
||||
password: "Password",
|
||||
submit: "Invia",
|
||||
enterPassword:
|
||||
"Questa pagina è bloccata per impostazione predefinita. Inserisci la password per sbloccare:",
|
||||
modernBrowser: "Si prega di utilizzare un browser moderno.",
|
||||
wrongPassword: "Password errato. Si prega di reinserire la password per sbloccare:",
|
||||
noPayload: "Nessun payload crittografato.",
|
||||
decrypting: "Decifrazione in corso...",
|
||||
},
|
||||
},
|
||||
} as const satisfies Translation
|
||||
|
@ -78,5 +78,17 @@ export default {
|
||||
showingFirst: ({ count }) => `のうち最初の${count}件を表示しています`,
|
||||
totalTags: ({ count }) => `全${count}個のタグを表示中`,
|
||||
},
|
||||
encryptedContent: {
|
||||
loading: "読み込み中...",
|
||||
password: "パスワード",
|
||||
submit: "送信",
|
||||
enterPassword:
|
||||
"このページはデフォルトでロックされています。ロックを解除するにはパスワードを入力してください:",
|
||||
modernBrowser: "最新のブラウザを使用してください。",
|
||||
wrongPassword:
|
||||
"パスワードが間違っています。ロックを解除するにはパスワードを再度入力してください:",
|
||||
noPayload: "暗号化されたペイロードはありません。",
|
||||
decrypting: "解読中...",
|
||||
},
|
||||
},
|
||||
} as const satisfies Translation
|
||||
|
@ -78,5 +78,15 @@ export default {
|
||||
showingFirst: ({ count }) => `처음 ${count}개의 태그`,
|
||||
totalTags: ({ count }) => `총 ${count}개의 태그를 찾았습니다.`,
|
||||
},
|
||||
encryptedContent: {
|
||||
loading: "로딩 중...",
|
||||
password: "비밀번호",
|
||||
submit: "제출",
|
||||
enterPassword: "이 페이지는 기본적으로 잠겨 있습니다. 잠금을 해제하려면 암호를 입력하십시오:",
|
||||
modernBrowser: "최신 브라우저를 사용하십시오.",
|
||||
wrongPassword: "비밀번호가 잘못되었습니다. 잠금을 해제하려면 암호를 다시 입력하십시오:",
|
||||
noPayload: "암호화된 페이로드가 없습니다.",
|
||||
decrypting: "해독 중...",
|
||||
},
|
||||
},
|
||||
} as const satisfies Translation
|
||||
|
@ -100,5 +100,16 @@ export default {
|
||||
? `Rasta iš viso ${count} žymos.`
|
||||
: `Rasta iš viso ${count} žymų.`,
|
||||
},
|
||||
encryptedContent: {
|
||||
loading: "Loading...",
|
||||
password: "Slaptažodis",
|
||||
submit: "Pateikti",
|
||||
enterPassword:
|
||||
"Šis puslapis yra užrakintas pagal numatytuosius nustatymus. Norėdami atrakinti, įveskite slaptažodį:",
|
||||
modernBrowser: "Prašome naudoti modernią naršyklę.",
|
||||
wrongPassword: "Neteisingas slaptažodis. Norėdami atrakinti, iš naujo įveskite slaptažodį:",
|
||||
noPayload: "Nėra užšifruoto naudingojo krovinio.",
|
||||
decrypting: "Iššifruojama...",
|
||||
},
|
||||
},
|
||||
} as const satisfies Translation
|
||||
|
@ -82,5 +82,17 @@ export default {
|
||||
count === 1 ? "Eerste label tonen." : `Eerste ${count} labels tonen.`,
|
||||
totalTags: ({ count }) => `${count} labels gevonden.`,
|
||||
},
|
||||
encryptedContent: {
|
||||
loading: "Bezig met laden...",
|
||||
password: "Wachtwoord",
|
||||
submit: "Verzenden",
|
||||
enterPassword:
|
||||
"Deze pagina is standaard vergrendeld. Voer alstublieft uw wachtwoord in om te ontgrendelen:",
|
||||
modernBrowser: "Gebruik alstublieft een moderne browser.",
|
||||
wrongPassword:
|
||||
"Verkeerd wachtwoord. Voer alstublieft uw wachtwoord opnieuw in om te ontgrendelen:",
|
||||
noPayload: "Geen versleutelde payload.",
|
||||
decrypting: "Ontsleutelen..",
|
||||
},
|
||||
},
|
||||
} as const satisfies Translation
|
||||
|
@ -80,5 +80,15 @@ export default {
|
||||
showingFirst: ({ count }) => `Pokazuje ${count} pierwszych znaczników.`,
|
||||
totalTags: ({ count }) => `Znalezionych wszystkich znaczników: ${count}.`,
|
||||
},
|
||||
encryptedContent: {
|
||||
loading: "Ładowanie...",
|
||||
password: "Hasło",
|
||||
submit: "Wyślij",
|
||||
enterPassword: "Ta strona jest domyślnie zablokowana. Wprowadź hasło, aby ją odblokować:",
|
||||
modernBrowser: "Użyj nowoczesnej przeglądarki.",
|
||||
wrongPassword: "Senha incorreta. Digite a senha novamente para desbloquear:",
|
||||
noPayload: "Nie ma zaszyfrowanego ładunku.",
|
||||
decrypting: "Deszyfrowanie...",
|
||||
},
|
||||
},
|
||||
} as const satisfies Translation
|
||||
|
@ -80,5 +80,15 @@ export default {
|
||||
showingFirst: ({ count }) => `Mostrando as ${count} primeiras tags.`,
|
||||
totalTags: ({ count }) => `Encontradas ${count} tags.`,
|
||||
},
|
||||
encryptedContent: {
|
||||
loading: "Carregando...",
|
||||
password: "Senha",
|
||||
submit: "Enviar",
|
||||
enterPassword: "Esta página está bloqueada por padrão. Digite a senha para desbloquear:",
|
||||
modernBrowser: "Use um navegador moderno.",
|
||||
wrongPassword: "Parolă greșită. Vă rugăm să reintroduceți parola pentru a debloca:",
|
||||
noPayload: "Não há nenhuma carga útil criptografada.",
|
||||
decrypting: "Descifrando...",
|
||||
},
|
||||
},
|
||||
} as const satisfies Translation
|
||||
|
@ -81,5 +81,16 @@ export default {
|
||||
showingFirst: ({ count }) => `Se afișează primele ${count} etichete.`,
|
||||
totalTags: ({ count }) => `Au fost găsite ${count} etichete în total.`,
|
||||
},
|
||||
encryptedContent: {
|
||||
loading: "Se încarcă...",
|
||||
password: "Parolă",
|
||||
submit: "Trimite",
|
||||
enterPassword:
|
||||
"Această pagină este blocată în mod implicit. Introduceți parola pentru a debloca:",
|
||||
modernBrowser: "Vă rugăm să utilizați un browser modern.",
|
||||
wrongPassword: "Неправильний пароль. Будь ласка, введіть пароль ще раз, щоб розблокувати:",
|
||||
noPayload: "Nu există nicio sarcină utilă criptată.",
|
||||
decrypting: "Decriptare...",
|
||||
},
|
||||
},
|
||||
} as const satisfies Translation
|
||||
|
@ -82,6 +82,17 @@ export default {
|
||||
`Показыва${getForm(count, "ется", "ются", "ются")} ${count} тег${getForm(count, "", "а", "ов")}`,
|
||||
totalTags: ({ count }) => `Всего ${count} тег${getForm(count, "", "а", "ов")}`,
|
||||
},
|
||||
encryptedContent: {
|
||||
loading: "Загрузка...",
|
||||
password: "Пароль",
|
||||
submit: "Отправить",
|
||||
enterPassword:
|
||||
"Эта страница заблокирована по умолчанию. Пожалуйста, введите пароль для разблокировки:",
|
||||
modernBrowser: "Пожалуйста, используйте современный браузер.",
|
||||
wrongPassword: "Неверный пароль. Пожалуйста, введите пароль еще раз для разблокировки:",
|
||||
noPayload: "Нет зашифрованной полезной нагрузки.",
|
||||
decrypting: "Расшифровка...",
|
||||
},
|
||||
},
|
||||
} as const satisfies Translation
|
||||
|
||||
|
@ -78,5 +78,15 @@ export default {
|
||||
showingFirst: ({ count }) => `แสดง ${count} แท็กแรก`,
|
||||
totalTags: ({ count }) => `มีทั้งหมด ${count} แท็ก`,
|
||||
},
|
||||
encryptedContent: {
|
||||
loading: "กำลังโหลด...",
|
||||
password: "รหัสผ่าน",
|
||||
submit: "ส่ง",
|
||||
enterPassword: "หน้านี้ถูกล็อคโดยค่าเริ่มต้น กรุณากรอกรหัสผ่านเพื่อปลดล็อค:",
|
||||
modernBrowser: "กรุณาใช้เบราว์เซอร์ที่ทันสมัย",
|
||||
wrongPassword: "รหัสผ่านผิด กรุณากรอกรหัสผ่านอีกครั้งเพื่อปลดล็อค:",
|
||||
noPayload: "ไม่มีเพย์โหลดที่เข้ารหัส",
|
||||
decrypting: "กำลังถอดรหัส...",
|
||||
},
|
||||
},
|
||||
} as const satisfies Translation
|
||||
|
@ -80,5 +80,16 @@ export default {
|
||||
showingFirst: ({ count }) => `İlk ${count} etiket gösteriliyor.`,
|
||||
totalTags: ({ count }) => `Toplam ${count} adet etiket bulundu.`,
|
||||
},
|
||||
encryptedContent: {
|
||||
loading: "Yükleniyor...",
|
||||
password: "Şifre",
|
||||
submit: "Gönder",
|
||||
enterPassword:
|
||||
"Bu sayfa varsayılan olarak kilitlidir. Kilidi açmak için lütfen şifreyi girin:",
|
||||
modernBrowser: "Lütfen modern bir tarayıcı kullanın.",
|
||||
wrongPassword: "Yanlış şifre. Kilidi açmak için lütfen şifreyi tekrar girin:",
|
||||
noPayload: "Şifrelenmiş yük yok.",
|
||||
decrypting: "Şifre çözülüyor...",
|
||||
},
|
||||
},
|
||||
} as const satisfies Translation
|
||||
|
@ -80,5 +80,15 @@ export default {
|
||||
showingFirst: ({ count }) => `Показ перших ${count} міток.`,
|
||||
totalTags: ({ count }) => `Всього знайдено міток: ${count}.`,
|
||||
},
|
||||
encryptedContent: {
|
||||
loading: "Завантаження...",
|
||||
password: "Пароль",
|
||||
submit: "Надіслати",
|
||||
enterPassword: "Ця сторінка за замовчуванням заблокована. Введіть пароль, щоб розблокувати:",
|
||||
modernBrowser: "Будь ласка, використовуйте сучасний браузер.",
|
||||
wrongPassword: "Неправильний пароль. Будь ласка, введіть пароль ще раз, щоб розблокувати:",
|
||||
noPayload: "Немає зашифрованого корисного навантаження.",
|
||||
decrypting: "Розшифровка...",
|
||||
},
|
||||
},
|
||||
} as const satisfies Translation
|
||||
|
@ -80,5 +80,15 @@ export default {
|
||||
showingFirst: ({ count }) => `Hiển thị trước ${count} thẻ.`,
|
||||
totalTags: ({ count }) => `Tìm thấy ${count} thẻ tổng cộng.`,
|
||||
},
|
||||
encryptedContent: {
|
||||
loading: "Đang tải...",
|
||||
password: "Mật khẩu",
|
||||
submit: "Mở khóa",
|
||||
enterPassword: "Trang này bị khóa theo mặc định. Vui lòng điền mật khẩu để mở khóa:",
|
||||
modernBrowser: "Vui lòng sử dụng trình duyệt mới nhất.",
|
||||
wrongPassword: "Sai mật khẩu. Vui lòng điền lại mật khẩu để mở khóa:",
|
||||
noPayload: "Không có nội dung được mã hóa.",
|
||||
decrypting: "Đang giải mã...",
|
||||
},
|
||||
},
|
||||
} as const satisfies Translation
|
||||
|
@ -78,5 +78,15 @@ export default {
|
||||
showingFirst: ({ count }) => `显示前${count}个标签。`,
|
||||
totalTags: ({ count }) => `总共有${count}个标签。`,
|
||||
},
|
||||
encryptedContent: {
|
||||
loading: "正在加载...",
|
||||
password: "密码",
|
||||
submit: "提交",
|
||||
enterPassword: "此页面默认锁定。请输入密码解锁:",
|
||||
modernBrowser: "请使用现代浏览器。",
|
||||
wrongPassword: "密码错误。请重新输入密码解锁:",
|
||||
noPayload: "没有加密的有效负载。",
|
||||
decrypting: "解密中...",
|
||||
},
|
||||
},
|
||||
} as const satisfies Translation
|
||||
|
@ -78,5 +78,15 @@ export default {
|
||||
showingFirst: ({ count }) => `顯示前 ${count} 個標籤。`,
|
||||
totalTags: ({ count }) => `總共有 ${count} 個標籤。`,
|
||||
},
|
||||
encryptedContent: {
|
||||
loading: "正在加載...",
|
||||
password: "密碼",
|
||||
submit: "提交",
|
||||
enterPassword: "此頁面預設鎖定。請輸入密碼解鎖:",
|
||||
modernBrowser: "請使用現代瀏覽器。",
|
||||
wrongPassword: "密碼錯誤。請重新輸入密碼解鎖:",
|
||||
noPayload: "沒有加密的有效負載。",
|
||||
decrypting: "解密中...",
|
||||
},
|
||||
},
|
||||
} as const satisfies Translation
|
||||
|
@ -58,7 +58,7 @@ export const NotFoundPage: QuartzEmitterPlugin = () => {
|
||||
return [
|
||||
await write({
|
||||
ctx,
|
||||
content: renderPage(cfg, slug, componentData, opts, externalResources),
|
||||
content: await renderPage(cfg, slug, componentData, opts, externalResources),
|
||||
slug,
|
||||
ext: ".html",
|
||||
}),
|
||||
|
@ -5,8 +5,11 @@ import { QuartzEmitterPlugin } from "../types"
|
||||
import spaRouterScript from "../../components/scripts/spa.inline"
|
||||
// @ts-ignore
|
||||
import popoverScript from "../../components/scripts/popover.inline"
|
||||
// @ts-ignore
|
||||
import decryptScript from "../../components/scripts/decrypt.inline"
|
||||
import styles from "../../styles/custom.scss"
|
||||
import popoverStyle from "../../components/styles/popover.scss"
|
||||
import passProtectedStyle from "../../components/styles/passProtected.scss"
|
||||
import { BuildCtx } from "../../util/ctx"
|
||||
import { QuartzComponent } from "../../components/types"
|
||||
import { googleFontHref, joinStyles } from "../../util/theme"
|
||||
@ -77,6 +80,11 @@ function addGlobalPageResources(ctx: BuildCtx, componentResources: ComponentReso
|
||||
componentResources.css.push(popoverStyle)
|
||||
}
|
||||
|
||||
if (cfg.passProtected?.enabled) {
|
||||
componentResources.afterDOMLoaded.push(decryptScript)
|
||||
componentResources.css.push(passProtectedStyle)
|
||||
}
|
||||
|
||||
if (cfg.analytics?.provider === "google") {
|
||||
const tagId = cfg.analytics.tagId
|
||||
componentResources.afterDOMLoaded.push(`
|
||||
|
@ -117,7 +117,7 @@ export const ContentPage: QuartzEmitterPlugin<Partial<FullPageLayout>> = (userOp
|
||||
allFiles,
|
||||
}
|
||||
|
||||
const content = renderPage(cfg, slug, componentData, opts, externalResources)
|
||||
const content = await renderPage(cfg, slug, componentData, opts, externalResources)
|
||||
const fp = await write({
|
||||
ctx,
|
||||
content,
|
||||
|
@ -118,7 +118,7 @@ export const FolderPage: QuartzEmitterPlugin<Partial<FolderPageOptions>> = (user
|
||||
allFiles,
|
||||
}
|
||||
|
||||
const content = renderPage(cfg, slug, componentData, opts, externalResources)
|
||||
const content = await renderPage(cfg, slug, componentData, opts, externalResources)
|
||||
const fp = await write({
|
||||
ctx,
|
||||
content,
|
||||
|
@ -126,7 +126,7 @@ export const TagPage: QuartzEmitterPlugin<Partial<TagPageOptions>> = (userOpts)
|
||||
allFiles,
|
||||
}
|
||||
|
||||
const content = renderPage(cfg, slug, componentData, opts, externalResources)
|
||||
const content = await renderPage(cfg, slug, componentData, opts, externalResources)
|
||||
const fp = await write({
|
||||
ctx,
|
||||
content,
|
||||
|
33
quartz/util/encrypt.ts
Normal file
33
quartz/util/encrypt.ts
Normal file
@ -0,0 +1,33 @@
|
||||
import { base64 } from "rfc4648"
|
||||
import { getRandomValues, subtle } from "crypto"
|
||||
|
||||
export async function getEncryptedPayload(
|
||||
content: string,
|
||||
password: string,
|
||||
iterations: number = 2e6,
|
||||
) {
|
||||
const encoder = new TextEncoder()
|
||||
const salt = getRandomValues(new Uint8Array(32))
|
||||
const baseKey = await subtle.importKey("raw", encoder.encode(password), "PBKDF2", false, [
|
||||
"deriveKey",
|
||||
])
|
||||
const key = await subtle.deriveKey(
|
||||
{ name: "PBKDF2", salt, iterations, hash: "SHA-256" },
|
||||
baseKey,
|
||||
{ name: "AES-GCM", length: 256 },
|
||||
false,
|
||||
["encrypt"],
|
||||
)
|
||||
|
||||
const iv = getRandomValues(new Uint8Array(16))
|
||||
const ciphertext = new Uint8Array(
|
||||
await subtle.encrypt({ name: "AES-GCM", iv }, key, encoder.encode(content)),
|
||||
)
|
||||
const totalLength = salt.length + iv.length + ciphertext.length
|
||||
const mergedData = new Uint8Array(totalLength)
|
||||
mergedData.set(salt)
|
||||
mergedData.set(iv, salt.length)
|
||||
mergedData.set(ciphertext, salt.length + iv.length)
|
||||
|
||||
return base64.stringify(mergedData)
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user