├── .env.example ├── .eslintrc.json ├── .github ├── CODEOWNERS ├── dependabot.yml └── workflows │ └── ci.yml ├── .gitignore ├── .nvmrc ├── .prettierignore ├── .storybook ├── main.cjs ├── preview-head.html └── preview.tsx ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── docs ├── keyboard-shortcuts.md ├── markdown-syntax.md ├── metadata.md ├── query-language.md ├── status.md └── templates.md ├── index.html ├── netlify └── edge-functions │ ├── cors-proxy.ts │ ├── file-proxy.ts │ ├── git-lfs-file.ts │ ├── github-auth.ts │ └── share.ts ├── package-lock.json ├── package.json ├── patches └── decode-named-character-reference+1.0.2.patch ├── postcss.config.cjs ├── public ├── _redirects ├── apple-touch-icon-512.png ├── favicon-development.svg ├── favicon-production.svg ├── fonts │ ├── iAWriterMonoV-Italic.woff2 │ ├── iAWriterMonoV.woff2 │ ├── iAWriterQuattroV-Italic.woff2 │ └── iAWriterQuattroV.woff2 ├── icon-1024-old.png ├── icon-1024.png ├── robots.txt └── sounds │ ├── notification-off.mp3 │ └── notification.mp3 ├── scripts └── benchmark.ts ├── src ├── codemirror-extensions │ ├── ellipsis.ts │ ├── frontmatter.ts │ ├── heading.ts │ ├── indented-line-wrap.ts │ ├── paste.ts │ ├── spellcheck.ts │ └── wikilink.ts ├── components │ ├── app-header.tsx │ ├── app-layout.tsx │ ├── assistant-activity-indicator.stories.tsx │ ├── assistant-activity-indicator.tsx │ ├── audio-visualizer.tsx │ ├── button.stories.tsx │ ├── button.tsx │ ├── calendar.tsx │ ├── cheatsheet-dialog.tsx │ ├── checkbox.stories.tsx │ ├── checkbox.tsx │ ├── command-menu.tsx │ ├── copy-button.tsx │ ├── days-of-week.tsx │ ├── details.stories.tsx │ ├── details.tsx │ ├── dev-bar.tsx │ ├── dialog.stories.tsx │ ├── dialog.tsx │ ├── dice.stories.tsx │ ├── dice.tsx │ ├── dropdown-menu.stories.tsx │ ├── dropdown-menu.tsx │ ├── emoji-favicon.tsx │ ├── file-input-button.tsx │ ├── file-preview.tsx │ ├── form-control.stories.tsx │ ├── form-control.tsx │ ├── github-auth.tsx │ ├── github-avatar.tsx │ ├── icon-button.stories.tsx │ ├── icon-button.tsx │ ├── icons.stories.tsx │ ├── icons.tsx │ ├── insert-template.tsx │ ├── keys.stories.tsx │ ├── keys.tsx │ ├── link-highlight-provider.tsx │ ├── lumen-logo.tsx │ ├── markdown.stories.tsx │ ├── markdown.tsx │ ├── nav-bar.tsx │ ├── nav-items.tsx │ ├── new-note-button.tsx │ ├── note-editor.tsx │ ├── note-favicon.stories.tsx │ ├── note-favicon.tsx │ ├── note-list.tsx │ ├── note-preview-card.tsx │ ├── note-preview.tsx │ ├── openai-key-input.tsx │ ├── openai-key-input.typegen.ts │ ├── pill-button.stories.tsx │ ├── pill-button.tsx │ ├── radio-group.stories.tsx │ ├── radio-group.tsx │ ├── repo-form.tsx │ ├── search-input.tsx │ ├── segmented-control.stories.tsx │ ├── segmented-control.tsx │ ├── share-dialog.tsx │ ├── sidebar.tsx │ ├── sign-in-banner.tsx │ ├── switch.stories.tsx │ ├── switch.tsx │ ├── sync-status.tsx │ ├── syntax-highlighter.tsx │ ├── tag-link.tsx │ ├── text-input.stories.tsx │ ├── text-input.tsx │ ├── toast.stories.tsx │ ├── toast.tsx │ ├── tooltip.tsx │ ├── voice-conversation.prompt.md │ ├── voice-conversation.tsx │ ├── voice-conversation.typegen.ts │ └── website-favicon.tsx ├── global-state.ts ├── global-state.typegen.ts ├── hooks │ ├── attach-file.ts │ ├── editor-settings.ts │ ├── is-scrolled.ts │ ├── mouse-position.ts │ ├── note.ts │ ├── search.ts │ ├── tag.ts │ ├── theme-color.ts │ └── value-ref.ts ├── index.tsx ├── remark-plugins │ ├── embed.test.ts │ ├── embed.ts │ ├── tag.test.ts │ ├── tag.ts │ ├── wikilink.test.ts │ └── wikilink.ts ├── routeTree.gen.ts ├── routes │ ├── __root.tsx │ ├── _appRoot.file.tsx │ ├── _appRoot.index.tsx │ ├── _appRoot.notes.index.tsx │ ├── _appRoot.notes_.$.tsx │ ├── _appRoot.settings.tsx │ ├── _appRoot.tags.index.tsx │ ├── _appRoot.tags_.$.tsx │ ├── _appRoot.tsx │ └── share.$gistId.tsx ├── schema.ts ├── styles │ ├── cmdk.css │ ├── codemirror.css │ ├── fonts.css │ ├── index.css │ ├── markdown.css │ ├── prism.css │ ├── radix-colors.css │ └── variables.css ├── utils │ ├── cx.ts │ ├── date.test.ts │ ├── date.ts │ ├── emoji.ts │ ├── frontmatter.test.ts │ ├── frontmatter.ts │ ├── fs.ts │ ├── gist.ts │ ├── git-lfs.ts │ ├── git.ts │ ├── open-new-window.ts │ ├── parse-note.ts │ ├── pluralize.ts │ ├── remove-parent-tags.ts │ ├── remove-template-frontmatter.test.ts │ ├── remove-template-frontmatter.ts │ ├── sample-markdown-files.ts │ ├── sounds.ts │ ├── strip-wikilinks.test.ts │ ├── strip-wikilinks.ts │ ├── timer.ts │ ├── transform-upload-urls.test.ts │ ├── transform-upload-urls.ts │ ├── update-tag.test.ts │ ├── update-tag.ts │ └── validate-openai-key.ts └── vite-env.d.ts ├── tailwind.config.cjs ├── tsconfig.json ├── tsconfig.node.json └── vite.config.ts /.env.example: -------------------------------------------------------------------------------- 1 | VITE_GITHUB_PAT="YOUR_TOKEN_HERE" -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "es2021": true, 5 | "node": true 6 | }, 7 | "extends": [ 8 | "eslint:recommended", 9 | "plugin:react/recommended", 10 | "plugin:react-hooks/recommended", 11 | "plugin:jsx-a11y/recommended", 12 | "plugin:@typescript-eslint/recommended", 13 | "plugin:storybook/recommended" 14 | ], 15 | "ignorePatterns": ["**/*.typegen.ts"], 16 | "overrides": [], 17 | "parser": "@typescript-eslint/parser", 18 | "parserOptions": { 19 | "ecmaVersion": "latest", 20 | "sourceType": "module" 21 | }, 22 | "settings": { 23 | "react": { 24 | "version": "detect" 25 | } 26 | }, 27 | "plugins": ["react", "react-hooks", "jsx-a11y", "@typescript-eslint"], 28 | "rules": { 29 | "react/react-in-jsx-scope": "off", 30 | "react/display-name": "off", 31 | "react/prop-types": "off", 32 | "react/no-unescaped-entities": "off", 33 | "react/jsx-no-constructed-context-values": "error", 34 | "@typescript-eslint/ban-ts-comment": "off", 35 | "@typescript-eslint/no-empty-function": "off", 36 | "@typescript-eslint/no-unused-vars": [ 37 | "error", 38 | { 39 | "args": "none", 40 | "ignoreRestSiblings": true, 41 | "destructuredArrayIgnorePattern": "^_" 42 | } 43 | ] 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | # These owners will be the default owners for everything in 2 | # the repo. Unless a later match takes precedence, 3 | # they will be requested for 4 | # review when someone opens a pull request. 5 | * @colebemis 6 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file 2 | 3 | version: 2 4 | updates: 5 | - package-ecosystem: "npm" 6 | directory: "/" 7 | schedule: 8 | interval: "weekly" 9 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: [push] 3 | jobs: 4 | lint: 5 | runs-on: ubuntu-latest 6 | steps: 7 | - uses: actions/checkout@v3 8 | - uses: actions/setup-node@v3 9 | with: 10 | node-version: 18.x 11 | cache: "npm" 12 | - run: npm ci 13 | - run: npm run lint 14 | test: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@v3 18 | - uses: actions/setup-node@v3 19 | with: 20 | node-version: 18.x 21 | cache: "npm" 22 | - run: npm ci 23 | - run: npm run test 24 | test-storybook: 25 | runs-on: ubuntu-latest 26 | steps: 27 | - uses: actions/checkout@v3 28 | - uses: actions/setup-node@v3 29 | with: 30 | node-version: 18.x 31 | cache: "npm" 32 | - run: npm ci 33 | - run: npm run test:storybook:ci 34 | build: 35 | runs-on: ubuntu-latest 36 | steps: 37 | - uses: actions/checkout@v3 38 | - uses: actions/setup-node@v3 39 | with: 40 | node-version: 18.x 41 | cache: "npm" 42 | - run: npm ci 43 | - run: npm run build 44 | benchmark: 45 | runs-on: ubuntu-latest 46 | steps: 47 | - uses: actions/checkout@v3 48 | - uses: actions/setup-node@v3 49 | with: 50 | node-version: 18.x 51 | cache: "npm" 52 | - run: npm ci 53 | - run: npx vite-node scripts/benchmark.ts >> $GITHUB_STEP_SUMMARY 54 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | dev-dist 14 | *.local 15 | coverage 16 | storybook-static 17 | stats.html 18 | 19 | # Editor directories and files 20 | .vscode/* 21 | !.vscode/extensions.json 22 | .idea 23 | .DS_Store 24 | *.suo 25 | *.ntvs* 26 | *.njsproj 27 | *.sln 28 | *.sw? 29 | 30 | # Local Netlify folder 31 | .netlify 32 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 19 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | dist 2 | node_modules 3 | *.typegen.ts 4 | -------------------------------------------------------------------------------- /.storybook/main.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | stories: ["../src/**/*.mdx", "../src/**/*.stories.@(js|jsx|ts|tsx)"], 3 | addons: [ 4 | { 5 | name: "@storybook/addon-essentials", 6 | options: { 7 | backgrounds: false, 8 | }, 9 | }, 10 | "@storybook/addon-links", 11 | "@storybook/addon-interactions", 12 | ], 13 | framework: { 14 | name: "@storybook/react-vite", 15 | options: {}, 16 | }, 17 | docs: { 18 | autodocs: true, 19 | }, 20 | } 21 | -------------------------------------------------------------------------------- /.storybook/preview-head.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.storybook/preview.tsx: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import * as Tooltip from "@radix-ui/react-tooltip" 3 | import "../src/styles/index.css" 4 | 5 | export const parameters = { 6 | actions: { argTypesRegex: "^on[A-Z].*" }, 7 | controls: { 8 | matchers: { 9 | color: /(background|color)$/i, 10 | date: /Date$/, 11 | }, 12 | }, 13 | } 14 | 15 | export const decorators = [ 16 | (Story) => ( 17 | 18 | 19 | 20 | ), 21 | ] 22 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | ## Local development 4 | 5 | 1. Clone the repository using your preferred method: 6 | 7 | ```shell 8 | # HTTPS 9 | git clone https://github.com/lumen-notes/lumen.git 10 | 11 | # SSH 12 | git clone git@github.com:lumen-notes/lumen.git 13 | 14 | # GitHub CLI 15 | gh repo clone lumen-notes/lumen 16 | ``` 17 | 18 | 1. Generate a GitHub [personal access token (classic)](https://github.com/settings/tokens/new) with `repo`, `gist`, and `user:email` scopes, or a [fine-grained personal access token](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens#creating-a-fine-grained-personal-access-token) scoped to a specific repository with the additional read-only email scope and gist, then add it to a `.env.local` file in the root directory: 19 | 20 | ```shell 21 | VITE_GITHUB_PAT= 22 | ``` 23 | 24 | 1. Install the dependencies: 25 | 26 | ```shell 27 | npm install 28 | ``` 29 | 30 | 1. Start the development server: 31 | 32 | ```shell 33 | npm run dev:netlify 34 | ``` 35 | 36 | 1. Open the app at http://localhost:8888 37 | 38 | 39 | ## Architecture 40 | 41 | ### GitHub sync 42 | 43 | ```mermaid 44 | graph 45 | subgraph local[Local machine] 46 | subgraph app.uselumen.com 47 | state-machine([state machine]) 48 | isomorphic-git[isomorphic-git] 49 | lightning-fs[lightning-fs] 50 | end 51 | 52 | local-storage[(localStorage)] 53 | indexeddb[(IndexedDB)] 54 | end 55 | 56 | subgraph edge[Netlify Edge Functions] 57 | cors-proxy(["/cors-proxy"]) 58 | end 59 | 60 | github.com([github.com]) 61 | 62 | state-machine <--> isomorphic-git 63 | state-machine <--> lightning-fs 64 | state-machine <--> local-storage 65 | isomorphic-git <--> lightning-fs 66 | isomorphic-git <--> cors-proxy 67 | lightning-fs <--> indexeddb 68 | cors-proxy <--> github.com 69 | ``` 70 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Lumen 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Lumen 2 | 3 | A simple note-taking app for capturing and organizing your thoughts 4 | 5 | [uselumen.com](https://uselumen.com) 6 | 7 | > [!WARNING] 8 | > Work in progress. Expect breaking changes. Follow [@uselumen.com](https://bsky.app/profile/uselumen.com) on Bluesky for updates. 9 | -------------------------------------------------------------------------------- /docs/keyboard-shortcuts.md: -------------------------------------------------------------------------------- 1 | # Keyboard shortcuts 2 | 3 | | Action | Shortcut | 4 | | --------------------- | ------------------------- | 5 | | Toggle command menu | K | 6 | | Create new note | I | 7 | 8 | **With focus inside a note card...** 9 | 10 | | Action | Shortcut | 11 | | ---------------------------------- | -------------------------------------- | 12 | | Switch between viewing and editing | E | 13 | | Save and finish editing | | 14 | | Save and continue editing | S | 15 | | Copy note markdown | C | 16 | | Copy note ID | C | 17 | | Delete note | | 18 | | Open note action menu | . | 19 | 20 | -------------------------------------------------------------------------------- /docs/markdown-syntax.md: -------------------------------------------------------------------------------- 1 | # Markdown syntax 2 | 3 | Lumen supports [GitHub Flavored Markdown](https://github.github.com/gfm/) with the following syntax extensions: 4 | 5 | ## Wikilinks 6 | 7 | Link to another note using its ID. 8 | 9 | ``` 10 | [[|]] 11 | ``` 12 | 13 | | Example | Rendered HTML | 14 | | :-------------------------------- | :------------------------------------------ | 15 | | `[[1652342106359\|Randie Bemis]]` | `Randie Bemis` | 16 | 17 | ### Dates 18 | 19 | You can also use wikilink syntax to reference a date. 20 | 21 | ``` 22 | [[YYYY-MM-DD]] 23 | ``` 24 | 25 | | Example | Rendered HTML | 26 | | :--------------- | :-------------------------------------------- | 27 | | `[[2021-07-11]]` | `Sun, Jul 11, 2021` | 28 | 29 | > [!TIP] 30 | > Lumen uses [Chrono](https://github.com/wanasit/chrono) to convert natural language dates into ISO format (YYYY-MM-DD). Try typing `[[yesterday]]` or `[[next monday]]` in a note editor to see it in action. 31 | 32 | ## Embeds 33 | 34 | Embed the contents of another note using its ID. 35 | 36 | ``` 37 | ![[]] 38 | ``` 39 | 40 | | Example | Rendered HTML | 41 | | :------------------- | :----------------------------- | 42 | | `![[1652342106359]]` | Contents of note 1652342106359 | 43 | 44 | ## Tags 45 | 46 | Link to all other notes with the same tag. 47 | 48 | ``` 49 | # 50 | ``` 51 | 52 | > [!NOTE] 53 | > Tag names must start with a letter and can contain letters, numbers, hyphens, underscores, and forward slashes. 54 | 55 | | Example | Rendered HTML | 56 | | :-------- | :----------------------------------- | 57 | | `#recipe` | `#recipe` | 58 | -------------------------------------------------------------------------------- /docs/metadata.md: -------------------------------------------------------------------------------- 1 | # Metadata 2 | 3 | You can include metadata, in the form of key-value pairs ([YAML](https://yaml.org/)), at the top of any note, enclosed within `---` delimiters. We refer to this as your note's "frontmatter". 4 | 5 | ## Example 6 | 7 | In the following note, we've included two pieces of metadata in the frontmatter: the book's ISBN and whether or not we've read it. 8 | 9 | ``` 10 | --- 11 | isbn: 978-1542866507 12 | read: true 13 | --- 14 | 15 | # How to Take Smart Notes 16 | 17 | ... 18 | ``` 19 | 20 | ## Recognized keys 21 | 22 | Frontmatter can contain any valid YAML key-value pairs. However, there are a few keys that Lumen recognizes and uses to enhance the user interface: 23 | 24 | | Key | Description | Enhancements | 25 | | :---------- | :--------------------------------- | :-------------------------------------------------------------------------------------------------------------------------------------- | 26 | | `phone` | Phone number | Adds a phone link. | 27 | | `email` | Email address | Adds an email link. | 28 | | `address` | Physical address | Adds a link to Google Maps. | 29 | | `birthday` | Birthday (`YYYY-MM-DD` or `MM-DD`) | Displays time until the next birthday. | 30 | | `github` | GitHub username | Adds a link to the GitHub profile. | 31 | | `twitter` | Twitter username | Adds a link to the Twitter profile. | 32 | | `bluesky` | Bluesky username | Adds a link to the Bluesky profile. | 33 | | `youtube` | YouTube username | Adds a link to the YouTube channel. | 34 | | `instagram` | Instagram username | Adds a link to the Instagram profile. | 35 | | `isbn` | Book ISBN-10 or ISBN-13 | Adds an image of the book cover and an [Open Library](https://openlibrary.org/) link to the top of the note. | 36 | | `template` | Template name | Turn the note into a template with the given name. For more details on templates, see [Templates](/docs/templates.md). | 37 | | `tags` | List of tag names | Adds the given tags to the note. This is an alternative to using [`#tag` syntax](/docs/markdown-syntax.md#note-links) in the note body. | 38 | -------------------------------------------------------------------------------- /docs/query-language.md: -------------------------------------------------------------------------------- 1 | # Query language 2 | 3 | Search your notes with Lumen's [GitHub-style](https://docs.github.com/en/search-github/searching-on-github/searching-issues-and-pull-requests) query language. Here's how it works: 4 | 5 | - A search query can contain any combination of qualifiers, which are key-value pairs separated by spaces. For example, `tag:log date:2021-07-11` matches notes with the `log` tag AND the date `2021-07-11`. 6 | - To exclude notes matching a qualifier, prefix the qualifier with a hyphen. For example, `-tag:log` matches notes that do not have the `log` tag. 7 | - To include multiple values in a qualifier, separate the values with commas. For example, `tag:article,book` matches notes with either the `article` OR `book` tag. 8 | - Qualifiers can also be used to filter notes based on numerical ranges. To do this, use one of the following operators before the qualifier value: `>`, `<`, `>=`, `<=`. For example, `backlinks:>10` matches notes with more than 10 backlinks; `date:>=2021-01-01` matches notes with a date on or after `2021-01-01`. 9 | - Text outside of qualifiers is used to fuzzy search the note's title and body. For example, `tag:recipe cookie` matches notes with the `recipe` tag that also contain the word "cookie" in the title or body. 10 | - To search for a value that contains spaces, wrap the value in quotes. For example, `genre:"science fiction"` matches notes with `genre: science fiction` in their [frontmatter](/docs/metadata.md). 11 | 12 | ## Qualifiers 13 | 14 | | Key | Example | 15 | | :---------- | :----------------------------------------------------------------------------------------------------------------------------------------- | 16 | | `id` | `id:1652342106359` matches the note with ID `1652342106359`. | 17 | | `tag` | `tag:recipe` matches notes with the `recipe` tag. | 18 | | `tags` | `tags:>1` matches notes with more than one tag. | 19 | | `date` | `date:2021-07-11` matches notes with the date `2021-07-11`. | 20 | | `dates` | `dates:>1` matches notes with more than one date. | 21 | | `link` | `link:1652342106359` matches notes that link to the note with ID `1652342106359`. | 22 | | `links` | `links:>1` matches notes with more than one link. | 23 | | `backlink` | `backlink:1652342106359` matches notes that are linked to by the note with ID `1652342106359`. | 24 | | `backlinks` | `backlinks:>1` matches notes with more than one backlink. | 25 | | `tasks` | `tasks:>0` matches notes with at least one open task. | 26 | | `no` | `no:tag` matches notes without a tag. `no` can be used with any qualifier key or frontmatter key. | 27 | | `has` | `has:tag` matches notes with one or more tag. `has` can be used with any qualifier key or frontmatter key. `has` and `-no` are equivalent. | 28 | 29 | Unrecognized qualifier keys are assumed to be [frontmatter](/docs/metadata.md) keys. For example, `read:true` matches notes with `read: true` in their frontmatter. 30 | -------------------------------------------------------------------------------- /docs/status.md: -------------------------------------------------------------------------------- 1 | # Status 2 | 3 | | URL | Status | 4 | | :------------------------------------------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------- | 5 | | [app.uselumen.com](https://app.uselumen.com) | [![Netlify Status](https://api.netlify.com/api/v1/badges/9e55f1c2-783d-4abb-9fa2-edc59f8aa0c3/deploy-status)](https://app.netlify.com/sites/lumen-notes/deploys) | 6 | | [storybook.uselumen.com](https://storybook.uselumen.com) | [![Netlify Status](https://api.netlify.com/api/v1/badges/acd80077-43c2-4292-8721-6f77e633a896/deploy-status)](https://app.netlify.com/sites/lumen-storybook/deploys) | 7 | -------------------------------------------------------------------------------- /docs/templates.md: -------------------------------------------------------------------------------- 1 | # Templates 2 | 3 | Any note can be turned into a template by adding a `template` property to the note's [frontmatter](/docs/metadata.md) with details about the template. 4 | 5 | Here's an example "Book" template: 6 | 7 | ``` 8 | --- 9 | template: 10 | name: Book 11 | author: 12 | isbn: 13 | recommended_by: 14 | --- 15 | 16 | # 17 | 18 | #book 19 | ``` 20 | 21 | To use this template, type `/` in any note editor. A list of available templates will appear. Select the "Book" template and press `Enter` to insert the contents of the template into your note. 22 | 23 | ## EJS 24 | 25 | Templates are rendered using [EJS](https://ejs.co/), a simple templating language that lets you embed JavaScript in your templates. 26 | 27 | Here's how you could use EJS to include the current date in our Book template: 28 | 29 | ``` 30 | --- 31 | template: 32 | name: Book 33 | author: 34 | isbn: 35 | recommended_by: 36 | date_saved: <%= date %> 37 | --- 38 | 39 | # 40 | 41 | #book 42 | ``` 43 | 44 | When you use this template, the `<%= date %>` placeholder will be replaced with the current date in `YYYY-MM-DD` format. 45 | 46 | ### Global variables 47 | 48 | The following global variables are available in all templates: 49 | 50 | | Name | Type | Description | 51 | | :----- | :------- | :--------------------------------------- | 52 | | `date` | `string` | The current date in `YYYY-MM-DD` format. | 53 | 54 | ## Inputs 55 | 56 | You can specify inputs for your template with `template.inputs`. Each input is an object with the following properties: 57 | 58 | | Name | Type | Required | Description | 59 | | :------------ | :--------- | :------- | ------------------------------------------------------------------------------------------------------------------------------------------ | 60 | | `type` | `'string'` | Yes | The type of the input. Currently, only `'string'` is supported. | 61 | | `required` | `boolean` | No | Whether the input is required. If `true`, the user will not be able to insert the template until they have provided a value for the input. | 62 | | `default` | `string` | No | The default value for the input. | 63 | | `description` | `string` | No | A description of the input. | 64 | 65 | These inputs are added to the variables available in the template. For example, here's how you might add an `author` input to your Book template: 66 | 67 | ``` 68 | --- 69 | template: 70 | name: Book 71 | inputs: 72 | author: 73 | type: string 74 | author: <%= author %> 75 | isbn: 76 | recommended_by: 77 | date_saved: <%= date %> 78 | --- 79 | 80 | # 81 | 82 | #book 83 | ``` 84 | 85 | When you use this template, you'll be prompted to provide a value for the `author` variable. 86 | 87 | ## Cursor position 88 | 89 | You can specify where the cursor should be placed after the template is inserted by adding a `{cursor}` placeholder to the template. For example: 90 | 91 | ``` 92 | --- 93 | template: 94 | name: Book 95 | inputs: 96 | author: 97 | type: string 98 | author: <%= author %> 99 | isbn: 100 | recommended_by: 101 | date_saved: <%= date %> 102 | --- 103 | 104 | # {cursor} 105 | 106 | #book 107 | ``` 108 | 109 | > **Note**: Only one `{cursor}` placeholder is supported per template. 110 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 9 | 10 | 11 | 12 | 13 |
14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /netlify/edge-functions/cors-proxy.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | import type { Config } from "https://edge.netlify.com" 4 | 5 | // Reference: https://github.com/isomorphic-git/cors-proxy 6 | 7 | const ALLOW_HEADERS = [ 8 | "accept-encoding", 9 | "accept-language", 10 | "accept", 11 | "access-control-allow-origin", 12 | "authorization", 13 | "cache-control", 14 | "connection", 15 | "content-length", 16 | "content-type", 17 | "dnt", 18 | "git-protocol", 19 | "pragma", 20 | "range", 21 | "referer", 22 | "user-agent", 23 | "x-authorization", 24 | "x-http-method-override", 25 | "x-requested-with", 26 | ] 27 | 28 | const EXPOSE_HEADERS = [ 29 | "accept-ranges", 30 | "age", 31 | "cache-control", 32 | "content-length", 33 | "content-language", 34 | "content-type", 35 | "date", 36 | "etag", 37 | "expires", 38 | "last-modified", 39 | "location", 40 | "pragma", 41 | "server", 42 | "transfer-encoding", 43 | "vary", 44 | "x-github-request-id", 45 | "x-redirected-url", 46 | ] 47 | 48 | export default async (request: Request) => { 49 | // The request URL will look like: "https://.../cors-proxy/example.com/..." 50 | // We want to strip off the "https://.../cors-proxy/" part of the URL 51 | // and proxy the request to the remaining URL. 52 | const url = request.url.replace(/^.*\/cors-proxy\//, "https://") 53 | 54 | // Filter request headers 55 | const requestHeaders = new Headers() 56 | for (const [key, value] of request.headers.entries()) { 57 | if (ALLOW_HEADERS.includes(key.toLowerCase())) { 58 | requestHeaders.set(key, value) 59 | } 60 | } 61 | 62 | // GitHub requests behave differently if the user-agent starts with "git/" 63 | requestHeaders.set("user-agent", "git/lumen/cors-proxy") 64 | 65 | const response = await fetch(url, { 66 | method: request.method, 67 | headers: requestHeaders, 68 | body: request.body, 69 | }) 70 | 71 | // Filter response headers 72 | const responseHeaders = new Headers() 73 | for (const [key, value] of response.headers.entries()) { 74 | if (EXPOSE_HEADERS.includes(key.toLowerCase())) { 75 | responseHeaders.set(key, value) 76 | } 77 | } 78 | 79 | return new Response(response.body, { 80 | status: response.status, 81 | statusText: response.statusText, 82 | headers: responseHeaders, 83 | }) 84 | } 85 | 86 | export const config: Config = { 87 | path: "/cors-proxy/*", 88 | } 89 | -------------------------------------------------------------------------------- /netlify/edge-functions/file-proxy.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | import type { Config } from "https://edge.netlify.com" 4 | 5 | /** 6 | * This edge function proxies a file from a given URL and returns it directly. 7 | * The URL should be provided as a query parameter, e.g. /file-proxy?url=https://example.com/image.jpg 8 | */ 9 | export default async (request: Request) => { 10 | try { 11 | const url = new URL(request.url) 12 | const fileUrl = url.searchParams.get("url") 13 | 14 | if (!fileUrl) { 15 | return new Response("Missing 'url' query parameter", { status: 400 }) 16 | } 17 | 18 | // Fetch the file 19 | const response = await fetch(fileUrl) 20 | 21 | if (!response.ok) { 22 | return new Response(`Failed to fetch file: ${response.statusText}`, { 23 | status: response.status, 24 | }) 25 | } 26 | 27 | // Get the content type from the original response 28 | const contentType = response.headers.get("content-type") || "application/octet-stream" 29 | 30 | // Return the file directly with appropriate headers 31 | return new Response(response.body, { 32 | headers: { 33 | "Content-Type": contentType, 34 | "Cache-Control": "public, max-age=3600", 35 | "Access-Control-Allow-Origin": "*", 36 | }, 37 | }) 38 | } catch (error) { 39 | console.error(error) 40 | return new Response(`Error: ${error.message}`, { status: 500 }) 41 | } 42 | } 43 | 44 | export const config: Config = { 45 | path: "/file-proxy", 46 | } 47 | -------------------------------------------------------------------------------- /netlify/edge-functions/github-auth.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | import type { Config } from "https://edge.netlify.com" 4 | 5 | // Reference: https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/authorizing-oauth-apps 6 | 7 | export default async (request: Request) => { 8 | try { 9 | const url = new URL(request.url) 10 | const code = url.searchParams.get("code") 11 | const state = url.searchParams.get("state") 12 | 13 | const response = await fetch("https://github.com/login/oauth/access_token", { 14 | method: "POST", 15 | headers: { 16 | "Content-Type": "application/json", 17 | Accept: "application/json", 18 | }, 19 | body: JSON.stringify({ 20 | client_id: Deno.env.get("VITE_GITHUB_CLIENT_ID"), 21 | client_secret: Deno.env.get("GITHUB_CLIENT_SECRET"), 22 | code, 23 | }), 24 | }) 25 | 26 | const { error, access_token: token } = await response.json() 27 | 28 | if (error) { 29 | throw new Error(error) 30 | } 31 | 32 | const { login, name, email } = await getUser(token) 33 | 34 | const redirectUrl = new URL(state || "https://uselumen.com") 35 | redirectUrl.searchParams.set("user_token", token) 36 | redirectUrl.searchParams.set("user_login", login) 37 | redirectUrl.searchParams.set("user_name", name) 38 | redirectUrl.searchParams.set("user_email", email) 39 | 40 | return Response.redirect(`${redirectUrl}`) 41 | } catch (error) { 42 | return new Response(`Error: ${error.message}`, { status: 500 }) 43 | } 44 | } 45 | 46 | async function getUser(token: string) { 47 | const userResponse = await fetch("https://api.github.com/user", { 48 | headers: { 49 | Authorization: `Bearer ${token}`, 50 | }, 51 | }) 52 | 53 | const { error, login, name } = await userResponse.json() 54 | 55 | if (error) { 56 | throw new Error(error) 57 | } 58 | 59 | const emailResponse = await fetch("https://api.github.com/user/emails", { 60 | headers: { 61 | Authorization: `Bearer ${token}`, 62 | }, 63 | }) 64 | 65 | if (emailResponse.status === 401) { 66 | throw new Error("Invalid token") 67 | } 68 | 69 | if (!emailResponse.ok) { 70 | throw new Error("Error getting user's emails") 71 | } 72 | 73 | const emails = (await emailResponse.json()) as Array<{ 74 | email: string 75 | primary: boolean 76 | visibility: string 77 | }> 78 | const primaryEmail = emails.find((email) => email.visibility !== "private") 79 | 80 | if (!primaryEmail) { 81 | throw new Error( 82 | "No public email found. Check your email settings in https://github.com/settings/emails", 83 | ) 84 | } 85 | 86 | return { login, name, email: primaryEmail.email } 87 | } 88 | 89 | export const config: Config = { 90 | path: "/github-auth", 91 | } 92 | -------------------------------------------------------------------------------- /patches/decode-named-character-reference+1.0.2.patch: -------------------------------------------------------------------------------- 1 | diff --git a/node_modules/decode-named-character-reference/index.dom.js b/node_modules/decode-named-character-reference/index.dom.js 2 | index 5d0abe2..ffad265 100644 3 | --- a/node_modules/decode-named-character-reference/index.dom.js 4 | +++ b/node_modules/decode-named-character-reference/index.dom.js 5 | @@ -2,13 +2,12 @@ 6 | 7 | /* eslint-env browser */ 8 | 9 | -const element = document.createElement('i') 10 | - 11 | /** 12 | * @param {string} value 13 | * @returns {string|false} 14 | */ 15 | export function decodeNamedCharacterReference(value) { 16 | + const element = document.createElement('i') 17 | const characterReference = '&' + value + ';' 18 | element.innerHTML = characterReference 19 | const char = element.textContent 20 | -------------------------------------------------------------------------------- /postcss.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /public/_redirects: -------------------------------------------------------------------------------- 1 | /stats /stats.html 200 2 | /* /index.html 200 3 | -------------------------------------------------------------------------------- /public/apple-touch-icon-512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lumen-notes/lumen/1711291cb2ab79d1bbb325d8e7f0f9b4f6cb3650/public/apple-touch-icon-512.png -------------------------------------------------------------------------------- /public/favicon-development.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /public/favicon-production.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /public/fonts/iAWriterMonoV-Italic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lumen-notes/lumen/1711291cb2ab79d1bbb325d8e7f0f9b4f6cb3650/public/fonts/iAWriterMonoV-Italic.woff2 -------------------------------------------------------------------------------- /public/fonts/iAWriterMonoV.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lumen-notes/lumen/1711291cb2ab79d1bbb325d8e7f0f9b4f6cb3650/public/fonts/iAWriterMonoV.woff2 -------------------------------------------------------------------------------- /public/fonts/iAWriterQuattroV-Italic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lumen-notes/lumen/1711291cb2ab79d1bbb325d8e7f0f9b4f6cb3650/public/fonts/iAWriterQuattroV-Italic.woff2 -------------------------------------------------------------------------------- /public/fonts/iAWriterQuattroV.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lumen-notes/lumen/1711291cb2ab79d1bbb325d8e7f0f9b4f6cb3650/public/fonts/iAWriterQuattroV.woff2 -------------------------------------------------------------------------------- /public/icon-1024-old.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lumen-notes/lumen/1711291cb2ab79d1bbb325d8e7f0f9b4f6cb3650/public/icon-1024-old.png -------------------------------------------------------------------------------- /public/icon-1024.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lumen-notes/lumen/1711291cb2ab79d1bbb325d8e7f0f9b4f6cb3650/public/icon-1024.png -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Disallow: / 3 | -------------------------------------------------------------------------------- /public/sounds/notification-off.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lumen-notes/lumen/1711291cb2ab79d1bbb325d8e7f0f9b4f6cb3650/public/sounds/notification-off.mp3 -------------------------------------------------------------------------------- /public/sounds/notification.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lumen-notes/lumen/1711291cb2ab79d1bbb325d8e7f0f9b4f6cb3650/public/sounds/notification.mp3 -------------------------------------------------------------------------------- /scripts/benchmark.ts: -------------------------------------------------------------------------------- 1 | import Benchmark from "benchmark" 2 | import { fromMarkdown } from "mdast-util-from-markdown" 3 | import { wikilink, wikilinkFromMarkdown } from "../src/remark-plugins/wikilink" 4 | import { tag, tagFromMarkdown } from "../src/remark-plugins/tag" 5 | 6 | const markdown = ` 7 | # Heading 1 8 | ## Heading 2 9 | ### Heading 3 10 | #### Heading 4 11 | ##### Heading 5 12 | ###### Heading 6 13 | 14 | --- 15 | 16 | Paragraph with **bold** and *italic* and [link](https://example.com). 17 | 18 | - List item 1 19 | - List item 2 20 | - List item 3 21 | 22 | 1. List item 1 23 | 2. List item 2 24 | 3. List item 3 25 | 26 | > Blockquote 27 | 28 | \`\`\`js 29 | const foo = "bar" 30 | \`\`\` 31 | 32 | \`inline code\` 33 | 34 | ![image](https://example.com/image.png) 35 | 36 | | Table | Header | 37 | | ----- | ------ | 38 | | Cell | Cell | 39 | 40 | [[123456789|Note link]] 41 | 42 | [[1998-7-11]] 43 | 44 | #tag 45 | ` 46 | 47 | const suite = new Benchmark.Suite("Markdown parsing") 48 | 49 | suite.add("Without syntax extensions", () => { 50 | fromMarkdown(markdown) 51 | }) 52 | 53 | suite.add("With tag syntax", () => { 54 | fromMarkdown(markdown, { 55 | extensions: [tag()], 56 | mdastExtensions: [tagFromMarkdown()], 57 | }) 58 | }) 59 | 60 | suite.add("With wikilink syntax", () => { 61 | fromMarkdown(markdown, { 62 | extensions: [wikilink()], 63 | mdastExtensions: [wikilinkFromMarkdown()], 64 | }) 65 | }) 66 | 67 | suite.add("With all syntax extensions", () => { 68 | fromMarkdown(markdown, { 69 | extensions: [wikilink(), tag()], 70 | mdastExtensions: [wikilinkFromMarkdown(), tagFromMarkdown()], 71 | }) 72 | }) 73 | 74 | suite.on("complete", function () { 75 | const fastest: Benchmark = this.filter("fastest")[0] 76 | 77 | console.log(`## ${this.name}`) 78 | 79 | // Create markdown table 80 | console.log("| Test case | Ops/sec | Margin of error | Comparison |") 81 | console.log("| :-------- | :------ | :-------------- | :--------- |") 82 | 83 | this.forEach((benchmark: Benchmark) => { 84 | const isFastest = benchmark === fastest 85 | console.log( 86 | `| ${benchmark.name} | ${Benchmark.formatNumber( 87 | Math.round(benchmark.hz), 88 | )} ops/sec | ±${benchmark.stats.rme.toFixed(2)}% | ${ 89 | isFastest ? "**Fastest**" : `${percentDecrease(fastest.hz, benchmark.hz)}% slower` 90 | } |`, 91 | ) 92 | }) 93 | }) 94 | 95 | /** Calculate the percent decrease between two numbers */ 96 | function percentDecrease(a: number, b: number): number { 97 | return round(((a - b) / a) * 100, 2) 98 | } 99 | 100 | /** Round to the nearest given number of decimal places */ 101 | function round(value: number, decimals: number): number { 102 | return Number(Math.round(Number(value + "e" + decimals)) + "e-" + decimals) 103 | } 104 | 105 | suite.run() 106 | -------------------------------------------------------------------------------- /src/codemirror-extensions/ellipsis.ts: -------------------------------------------------------------------------------- 1 | import { EditorView } from "@codemirror/view" 2 | 3 | /** 4 | * Replaces three consecutive dots with a single ellipsis character. 5 | */ 6 | export function ellipsisExtension() { 7 | return EditorView.inputHandler.of((view, from, to, text) => { 8 | // When typing a single '.' check if the previous two characters are '..' 9 | if ( 10 | text === "." && 11 | from >= 2 && 12 | view.state.sliceDoc(from - 2, from) === ".." 13 | ) { 14 | view.dispatch({ 15 | changes: { from: from - 2, to, insert: "…" }, 16 | selection: { anchor: from - 1 }, 17 | }) 18 | return true 19 | } 20 | 21 | // When pasting or inserting three dots at once 22 | if (text === "...") { 23 | view.dispatch({ 24 | changes: { from, to, insert: "…" }, 25 | selection: { anchor: from + 1 }, 26 | }) 27 | return true 28 | } 29 | 30 | return false 31 | }) 32 | } 33 | -------------------------------------------------------------------------------- /src/codemirror-extensions/frontmatter.ts: -------------------------------------------------------------------------------- 1 | import { EditorView } from "@codemirror/view" 2 | 3 | export function frontmatterExtension() { 4 | return EditorView.inputHandler.of((view: EditorView, from: number, to: number, text: string) => { 5 | // If you're inserting a `-` at index 2 and all previous characters are also `-`, 6 | // insert a matching `---` below the line 7 | if ( 8 | (text === "-" && from === 2 && view.state.sliceDoc(0, 2) === "--") || 9 | // Sometimes the mobile Safari replaces `--` with `—` so we need to handle that case too 10 | (text === "-" && from === 1 && view.state.sliceDoc(0, 1) === "—") 11 | ) { 12 | view.dispatch({ 13 | changes: { 14 | from: 0, 15 | to, 16 | insert: "---\n\n---", 17 | }, 18 | selection: { 19 | anchor: 4, 20 | }, 21 | }) 22 | 23 | return true 24 | } 25 | 26 | return false 27 | }) 28 | } 29 | -------------------------------------------------------------------------------- /src/codemirror-extensions/heading.ts: -------------------------------------------------------------------------------- 1 | import { EditorState, Extension, Line, Range, StateField, Transaction } from "@codemirror/state" 2 | import { Decoration, DecorationSet, EditorView } from "@codemirror/view" 3 | 4 | const headingField = StateField.define({ 5 | create(state) { 6 | return createDecorations(state) 7 | }, 8 | update(decorations, transaction) { 9 | if (transaction.docChanged) { 10 | return updateDecorations(decorations, transaction) 11 | } 12 | return decorations 13 | }, 14 | provide: (f) => EditorView.decorations.from(f), 15 | }) 16 | 17 | function createDecorations(state: EditorState) { 18 | const decorations: Range[] = [] 19 | 20 | for (let i = 1; i <= state.doc.lines; i++) { 21 | const line = state.doc.line(i) 22 | const lineDecoration = getLineDecoration(line) 23 | 24 | if (lineDecoration) { 25 | decorations.push(lineDecoration.range(line.from)) 26 | } 27 | } 28 | 29 | return Decoration.set(decorations) 30 | } 31 | 32 | /** 33 | * Updates the decorations for markdown headings when the document changes. 34 | * This method is more efficient than recreating all decorations for several reasons: 35 | * 1. It only processes the changed ranges of the document, not all lines. 36 | * 2. It reuses existing decorations that weren't affected by the changes. 37 | * 3. It's optimized for changes, using the Transaction object to map positions and identify changed ranges. 38 | * 4. It reduces overall processing, especially for large documents with small changes. 39 | */ 40 | function updateDecorations(oldDecorations: DecorationSet, tr: Transaction): DecorationSet { 41 | const decorations: Range[] = [] 42 | 43 | // Iterate through existing decorations and update their positions 44 | oldDecorations.between(0, tr.newDoc.length, (from, to, decoration) => { 45 | const newFrom = tr.changes.mapPos(from) 46 | const newTo = tr.changes.mapPos(to) 47 | if (tr.changes.touchesRange(from, to)) { 48 | // If the range was affected by the changes, recalculate the decoration 49 | const line = tr.newDoc.lineAt(newFrom) 50 | const newDecoration = getLineDecoration(line) 51 | if (newDecoration) { 52 | decorations.push(newDecoration.range(newFrom, newTo)) 53 | } 54 | } else { 55 | // If the range wasn't affected, keep the existing decoration 56 | decorations.push(decoration.range(newFrom, newTo)) 57 | } 58 | }) 59 | 60 | // Process newly changed ranges 61 | tr.changes.iterChangedRanges((fromA, toA, fromB, toB) => { 62 | let posB = fromB 63 | while (posB <= toB) { 64 | // For each line in the changed range, check if it needs a decoration 65 | const line = tr.newDoc.lineAt(posB) 66 | const decoration = getLineDecoration(line) 67 | if (decoration) { 68 | decorations.push(decoration.range(line.from)) 69 | } 70 | posB = line.to + 1 71 | } 72 | }) 73 | 74 | // Sort decorations by 'from' position 75 | decorations.sort((a, b) => a.from - b.from) 76 | 77 | // Return the sorted decorations as a DecorationSet 78 | return Decoration.set(decorations) 79 | } 80 | 81 | /** Returns a line decoration for markdown headings. */ 82 | function getLineDecoration(line: Line) { 83 | // Match markdown heading syntax: between 1-6 hash symbols (#) at start of line, followed by a space 84 | // Examples: "# Heading 1", "## Heading 2", "### Heading 3", etc. 85 | const headingMatch = line.text.match(/^(#{1,6})\s/) 86 | 87 | if (headingMatch) { 88 | const [_, hashes] = headingMatch 89 | const level = hashes.length 90 | 91 | let fontSize = "" 92 | if (level === 1) { 93 | fontSize = "var(--font-size-xl)" 94 | } else if (level === 2) { 95 | fontSize = "var(--font-size-lg)" 96 | } 97 | 98 | return Decoration.line({ 99 | attributes: { 100 | style: `font-weight: var(--font-weight-bold);${fontSize ? ` font-size: ${fontSize};` : ""}`, 101 | }, 102 | }) 103 | } 104 | 105 | return null 106 | } 107 | 108 | export function headingExtension(): Extension { 109 | return headingField 110 | } 111 | -------------------------------------------------------------------------------- /src/codemirror-extensions/paste.ts: -------------------------------------------------------------------------------- 1 | import { EditorView } from "@codemirror/view" 2 | import { useAttachFile } from "../hooks/attach-file" 3 | import { isValidUnixTimestamp } from "../utils/date" 4 | 5 | export function pasteExtension({ 6 | attachFile, 7 | onPaste, 8 | }: { 9 | attachFile: ReturnType 10 | onPaste?: (event: ClipboardEvent, view: EditorView) => void 11 | }) { 12 | return EditorView.domEventHandlers({ 13 | paste: (event, view) => { 14 | const clipboardText = event.clipboardData?.getData("text/plain") ?? "" 15 | 16 | // If the clipboard text is a URL or a Unix timestamp (likely a note ID), 17 | // make the selected text a link to that URL or note 18 | const isUrl = /^https?:\/\//.test(clipboardText) 19 | const isUnixTimestamp = isValidUnixTimestamp(clipboardText) 20 | 21 | if (isUrl || isUnixTimestamp) { 22 | // Get the selected text 23 | const { selection } = view.state 24 | const { from = 0, to = 0 } = selection.ranges[selection.mainIndex] ?? {} 25 | const selectedText = view?.state.doc.sliceString(from, to) ?? "" 26 | 27 | if (selectedText) { 28 | const markdown = isUnixTimestamp 29 | ? `[[${clipboardText}|${selectedText}]]` 30 | : `[${selectedText}](${clipboardText})` 31 | 32 | view.dispatch({ 33 | changes: { 34 | from, 35 | to, 36 | insert: markdown, 37 | }, 38 | selection: { 39 | anchor: from + markdown.length, 40 | }, 41 | }) 42 | 43 | event.preventDefault() 44 | } 45 | } 46 | 47 | // If the clipboard contains a file, upload it 48 | const [file] = Array.from(event.clipboardData?.files ?? []) 49 | 50 | if (file) { 51 | attachFile(file, view) 52 | event.preventDefault() 53 | } 54 | 55 | onPaste?.(event, view) 56 | }, 57 | }) 58 | } 59 | -------------------------------------------------------------------------------- /src/codemirror-extensions/spellcheck.ts: -------------------------------------------------------------------------------- 1 | import { EditorView } from "@codemirror/view" 2 | 3 | export function spellcheckExtension() { 4 | return EditorView.contentAttributes.of({ spellcheck: "true" }) 5 | } 6 | -------------------------------------------------------------------------------- /src/codemirror-extensions/wikilink.ts: -------------------------------------------------------------------------------- 1 | import { EditorState, Extension, Range, StateField } from "@codemirror/state" 2 | import { Decoration, DecorationSet, EditorView, WidgetType } from "@codemirror/view" 3 | 4 | function createWikilinkField(navigate: (id: string) => void) { 5 | return StateField.define({ 6 | create(state) { 7 | return createDecorations(state, navigate) 8 | }, 9 | update(decorations, tr) { 10 | // Update decorations if the document has changed or the selection has changed 11 | if (tr.docChanged || tr.selection) { 12 | return createDecorations(tr.state, navigate) 13 | } 14 | return decorations 15 | }, 16 | provide: (f) => EditorView.decorations.from(f), 17 | }) 18 | } 19 | 20 | const wikilinkRegex = /\[\[([^\]]+?)(?:\|([^\]]+))?\]\]/g 21 | 22 | function createDecorations(state: EditorState, navigate: (id: string) => void) { 23 | const decorations: Range[] = [] 24 | const { from, to } = state.selection.main 25 | 26 | for (let i = 1; i <= state.doc.lines; i++) { 27 | const line = state.doc.line(i) 28 | let match: RegExpExecArray | null 29 | 30 | while ((match = wikilinkRegex.exec(line.text)) !== null) { 31 | const startPos = line.from + match.index 32 | const endPos = startPos + match[0].length 33 | const [_fullMatch, id, text] = match 34 | 35 | // Only apply decoration if cursor is not within the wikilink 36 | if (from < startPos || to > endPos) { 37 | decorations.push( 38 | Decoration.replace({ 39 | widget: new WikilinkWidget(id, text, navigate, startPos, endPos), 40 | }).range(startPos, endPos), 41 | ) 42 | } 43 | } 44 | } 45 | 46 | return Decoration.set(decorations) 47 | } 48 | 49 | class WikilinkWidget extends WidgetType { 50 | private span: HTMLSpanElement | null = null 51 | private clickHandler: ((event: MouseEvent) => void) | null = null 52 | 53 | constructor( 54 | private id: string, 55 | private text: string, 56 | private navigate: (id: string) => void, 57 | private startPos: number, 58 | private endPos: number, 59 | ) { 60 | super() 61 | } 62 | 63 | toDOM(view: EditorView) { 64 | this.span = document.createElement("span") 65 | this.span.textContent = this.text || this.id 66 | this.span.className = "cm-wikilink" 67 | 68 | this.clickHandler = (event: MouseEvent) => { 69 | event.preventDefault() 70 | if (event.ctrlKey || event.metaKey) { 71 | this.navigate(this.id) 72 | } else { 73 | view.dispatch({ 74 | selection: { anchor: this.startPos, head: this.endPos }, 75 | scrollIntoView: true, 76 | }) 77 | } 78 | } 79 | 80 | this.span.addEventListener("click", this.clickHandler) 81 | return this.span 82 | } 83 | 84 | destroy() { 85 | if (this.span && this.clickHandler) { 86 | this.span.removeEventListener("click", this.clickHandler) 87 | this.span = null 88 | this.clickHandler = null 89 | } 90 | } 91 | } 92 | 93 | export function wikilinkExtension(navigate: (id: string) => void): Extension { 94 | return createWikilinkField(navigate) 95 | } 96 | -------------------------------------------------------------------------------- /src/components/app-header.tsx: -------------------------------------------------------------------------------- 1 | import { useNavigate, useRouter } from "@tanstack/react-router" 2 | import { useAtom } from "jotai" 3 | import { useHotkeys } from "react-hotkeys-hook" 4 | import { sidebarAtom } from "../global-state" 5 | import { cx } from "../utils/cx" 6 | import { IconButton } from "./icon-button" 7 | import { ArrowLeftIcon16, ArrowRightIcon16, SidebarIcon16 } from "./icons" 8 | import { NewNoteButton } from "./new-note-button" 9 | 10 | export type AppHeaderProps = { 11 | title: React.ReactNode 12 | icon?: React.ReactNode 13 | className?: string 14 | actions?: React.ReactNode 15 | } 16 | 17 | export function AppHeader({ title, icon, className, actions }: AppHeaderProps) { 18 | const router = useRouter() 19 | const navigate = useNavigate() 20 | const [sidebar, setSidebar] = useAtom(sidebarAtom) 21 | 22 | useHotkeys( 23 | "mod+shift+o", 24 | () => { 25 | navigate({ 26 | to: "/notes/$", 27 | params: { _splat: `${Date.now()}` }, 28 | search: { 29 | mode: "write", 30 | query: undefined, 31 | view: "grid", 32 | }, 33 | }) 34 | }, 35 | { 36 | preventDefault: true, 37 | enableOnFormTags: true, 38 | enableOnContentEditable: true, 39 | }, 40 | ) 41 | 42 | return ( 43 |
44 |
45 |
46 | {sidebar === "collapsed" ? ( 47 | <> 48 | setSidebar("expanded")} 52 | > 53 | 54 | 55 | 56 |
57 | 58 | ) : null} 59 | router.history.back()} 65 | className="group" 66 | > 67 | 68 | 69 | router.history.forward()} 74 | > 75 | 76 | 77 |
78 |
79 | {icon ? ( 80 |
{icon}
81 | ) : null} 82 |
{title}
83 |
84 |
85 | {actions ?
{actions}
: null} 86 |
87 |
88 |
89 | ) 90 | } 91 | -------------------------------------------------------------------------------- /src/components/assistant-activity-indicator.stories.tsx: -------------------------------------------------------------------------------- 1 | import { StoryObj } from "@storybook/react" 2 | import { AssistantActivityIndicator } from "./assistant-activity-indicator" 3 | 4 | export default { 5 | title: "AssistantActivityIndicator", 6 | component: AssistantActivityIndicator, 7 | parameters: { 8 | layout: "centered", 9 | }, 10 | argTypes: { 11 | state: { 12 | control: { 13 | type: "select", 14 | }, 15 | options: ["idle", "thinking", "speaking"], 16 | }, 17 | }, 18 | } 19 | 20 | type Story = StoryObj 21 | 22 | export const Thinking: Story = { 23 | render: (args) => { 24 | return ( 25 | 26 |
27 | 28 | ) 29 | }, 30 | args: { 31 | state: "thinking", 32 | }, 33 | } 34 | 35 | export const Speaking: Story = { 36 | render: (args) => { 37 | return ( 38 | 39 |
40 | 41 | ) 42 | }, 43 | args: { 44 | state: "speaking", 45 | }, 46 | } 47 | -------------------------------------------------------------------------------- /src/components/audio-visualizer.tsx: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | 3 | // Visualization settings 4 | const BAR_COUNT = 4 // Number of bars in the visualization 5 | const MIN_BAR_HEIGHT = 3 // Minimum height of each bar in pixels 6 | const MAX_BAR_HEIGHT = 16 // Maximum height of each bar in pixels 7 | const BAR_WIDTH = 3 // Width of each bar in pixels 8 | 9 | // Audio analysis settings 10 | const FFT_SIZE = 256 // Size of the FFT (Fast Fourier Transform) for frequency analysis 11 | const SMOOTHING_TIME_CONSTANT = 0.8 // Smoothing factor for the visualization (0-1) 12 | const DEFAULT_SAMPLE_RATE = 44100 // Default audio sample rate in Hz 13 | 14 | // Frequency ranges for each bar (in Hz) 15 | const FREQUENCY_RANGES = Array.from({ length: BAR_COUNT }, (_, i) => { 16 | const minFreq = 0 17 | const maxFreq = 4000 18 | const step = (maxFreq - minFreq) / BAR_COUNT 19 | const low = minFreq + step * i 20 | const high = minFreq + step * (i + 1) 21 | return [low, high] // Each bar gets an equal frequency range slice 22 | }) 23 | 24 | interface AudioVisualizerProps { 25 | stream: MediaStream 26 | } 27 | 28 | export function AudioVisualizer({ stream }: AudioVisualizerProps) { 29 | const [levels, setLevels] = React.useState(Array(BAR_COUNT).fill(0)) 30 | 31 | React.useEffect(() => { 32 | let audioContext: AudioContext | null = null 33 | let analyser: AnalyserNode | null = null 34 | let source: MediaStreamAudioSourceNode | null = null 35 | let animationFrameId: number 36 | 37 | async function setupAudio() { 38 | try { 39 | // Initialize audio context and analyzer 40 | audioContext = new AudioContext() 41 | analyser = audioContext.createAnalyser() 42 | analyser.fftSize = FFT_SIZE 43 | analyser.smoothingTimeConstant = SMOOTHING_TIME_CONSTANT 44 | 45 | // Connect provided stream to analyzer 46 | source = audioContext.createMediaStreamSource(stream) 47 | source.connect(analyser) 48 | updateLevels(analyser) 49 | } catch (error) { 50 | console.error(error) 51 | } 52 | } 53 | 54 | function updateLevels(analyser: AnalyserNode) { 55 | // Get frequency data from the analyzer 56 | const dataArray = new Uint8Array(analyser.frequencyBinCount) 57 | analyser.getByteFrequencyData(dataArray) 58 | 59 | // Calculate levels for each frequency range 60 | const levels = FREQUENCY_RANGES.map(([low, high]) => { 61 | // Convert frequency ranges to array indices 62 | const lowIndex = Math.floor( 63 | (low / (audioContext?.sampleRate || DEFAULT_SAMPLE_RATE)) * analyser.frequencyBinCount, 64 | ) 65 | const highIndex = Math.floor( 66 | (high / (audioContext?.sampleRate || DEFAULT_SAMPLE_RATE)) * analyser.frequencyBinCount, 67 | ) 68 | 69 | // Calculate average for this frequency range 70 | const rangeData = dataArray.slice(lowIndex, highIndex) 71 | const average = rangeData.reduce((a, b) => a + b, 0) / rangeData.length 72 | 73 | // Normalize 74 | return Math.min(1, average / 255) 75 | }) 76 | 77 | setLevels(levels) 78 | animationFrameId = requestAnimationFrame(() => updateLevels(analyser)) 79 | } 80 | 81 | setupAudio() 82 | 83 | // Cleanup function 84 | return () => { 85 | if (animationFrameId) { 86 | cancelAnimationFrame(animationFrameId) 87 | } 88 | if (source) { 89 | source.disconnect() 90 | } 91 | if (audioContext) { 92 | audioContext.close() 93 | } 94 | } 95 | }, [stream]) 96 | 97 | return ( 98 |
99 | {levels.map((level, index) => ( 100 |
108 | ))} 109 |
110 | ) 111 | } 112 | -------------------------------------------------------------------------------- /src/components/button.stories.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "./button" 2 | 3 | export default { 4 | title: "Button", 5 | component: Button, 6 | parameters: { 7 | layout: "centered", 8 | }, 9 | } 10 | 11 | export const Primary = { 12 | args: { 13 | children: "Button", 14 | variant: "primary", 15 | size: "medium", 16 | }, 17 | } 18 | 19 | export const Secondary = { 20 | args: { 21 | children: "Button", 22 | variant: "secondary", 23 | size: "medium", 24 | }, 25 | } 26 | 27 | export const WithKeyboardShortcut = { 28 | args: { 29 | children: "Save", 30 | shortcut: ["⌘", "⏎"], 31 | size: "medium", 32 | }, 33 | } 34 | 35 | export const Disabled = { 36 | args: { 37 | children: "Button", 38 | disabled: true, 39 | size: "medium", 40 | }, 41 | } 42 | 43 | export const Small = { 44 | args: { 45 | children: "Button", 46 | variant: "secondary", 47 | size: "small", 48 | }, 49 | } 50 | -------------------------------------------------------------------------------- /src/components/button.tsx: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import { Keys } from "./keys" 3 | import { cx } from "../utils/cx" 4 | 5 | export type ButtonProps = React.ComponentPropsWithoutRef<"button"> & { 6 | variant?: "secondary" | "primary" 7 | size?: "small" | "medium" 8 | shortcut?: string[] 9 | } 10 | 11 | export const Button = React.forwardRef( 12 | ({ variant = "secondary", size = "medium", shortcut, className, children, ...props }, ref) => { 13 | return ( 14 | 36 | ) 37 | }, 38 | ) 39 | -------------------------------------------------------------------------------- /src/components/checkbox.stories.tsx: -------------------------------------------------------------------------------- 1 | import { Checkbox } from "./checkbox" 2 | 3 | export default { 4 | title: "Checkbox", 5 | component: Checkbox, 6 | parameters: { 7 | layout: "centered", 8 | }, 9 | } 10 | 11 | export const Default = { 12 | args: { 13 | disabled: false, 14 | }, 15 | } 16 | 17 | export const Checked = { 18 | args: { 19 | checked: true, 20 | disabled: false, 21 | }, 22 | } 23 | 24 | export const Disabled = { 25 | args: { 26 | checked: true, 27 | disabled: true, 28 | }, 29 | } 30 | -------------------------------------------------------------------------------- /src/components/checkbox.tsx: -------------------------------------------------------------------------------- 1 | import * as CheckboxPrimitive from "@radix-ui/react-checkbox" 2 | import React from "react" 3 | import { cx } from "../utils/cx" 4 | import { CheckIcon8 } from "./icons" 5 | 6 | type CheckboxProps = CheckboxPrimitive.CheckboxProps 7 | 8 | export const Checkbox = React.forwardRef< 9 | React.ElementRef, 10 | CheckboxProps 11 | >(({ className, ...props }, ref) => ( 12 | 20 | 21 | 22 | 23 | 24 | )) 25 | 26 | Checkbox.displayName = CheckboxPrimitive.Root.displayName 27 | -------------------------------------------------------------------------------- /src/components/copy-button.tsx: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import { IconButton } from "./icon-button" 3 | import { CheckIcon16, CopyIcon16 } from "./icons" 4 | import copy from "copy-to-clipboard" 5 | 6 | export function CopyButton({ className, text }: { text: string; className?: string }) { 7 | const [copied, setCopied] = React.useState(false) 8 | const timeoutRef = React.useRef(null) 9 | 10 | return ( 11 | { 16 | copy(text) 17 | setCopied(true) 18 | 19 | if (timeoutRef.current) { 20 | window.clearTimeout(timeoutRef.current) 21 | } 22 | 23 | timeoutRef.current = window.setTimeout(() => setCopied(false), 1000) 24 | }} 25 | > 26 | {copied ? : } 27 | 28 | ) 29 | } 30 | -------------------------------------------------------------------------------- /src/components/days-of-week.tsx: -------------------------------------------------------------------------------- 1 | import { Link } from "@tanstack/react-router" 2 | import { addDays, eachDayOfInterval, parseISO } from "date-fns" 3 | import { useMemo } from "react" 4 | import { useNoteById } from "../hooks/note" 5 | import { DAY_NAMES, toDateString } from "../utils/date" 6 | import { NotePreviewCard } from "./note-preview-card" 7 | 8 | export function DaysOfWeek({ week }: { week: string }) { 9 | const daysOfWeek = useMemo(() => { 10 | const startOfWeek = parseISO(week) 11 | const endOfWeek = addDays(startOfWeek, 6) 12 | return eachDayOfInterval({ start: startOfWeek, end: endOfWeek }).map(toDateString) 13 | }, [week]) 14 | 15 | return ( 16 |
17 | {daysOfWeek.map((day) => ( 18 | 19 | ))} 20 |
21 | ) 22 | } 23 | 24 | function Day({ date }: { date: string }) { 25 | const note = useNoteById(date) 26 | const dayName = DAY_NAMES[new Date(date).getUTCDay()] 27 | 28 | if (!note) { 29 | // Placeholder 30 | return ( 31 | 41 | {dayName} 42 | 43 | ) 44 | } 45 | 46 | return 47 | } 48 | -------------------------------------------------------------------------------- /src/components/details.stories.tsx: -------------------------------------------------------------------------------- 1 | import { StoryObj } from "@storybook/react" 2 | import { Details } from "./details" 3 | 4 | export default { 5 | title: "Details", 6 | component: Details, 7 | argTypes: { 8 | defaultOpen: { 9 | control: "boolean", 10 | }, 11 | }, 12 | } 13 | 14 | export const Default: StoryObj<{ defaultOpen: boolean }> = { 15 | render: (args) => ( 16 |
17 | Details 18 |
Peekaboo!
19 |
20 | ), 21 | args: { 22 | defaultOpen: true, 23 | }, 24 | } 25 | -------------------------------------------------------------------------------- /src/components/details.tsx: -------------------------------------------------------------------------------- 1 | import { cx } from "../utils/cx" 2 | import { TriangleRightIcon12 } from "./icons" 3 | 4 | function Root({ 5 | children, 6 | className, 7 | defaultOpen = true, 8 | }: { 9 | children: React.ReactNode 10 | className?: string 11 | defaultOpen?: boolean 12 | }) { 13 | return ( 14 |
15 | {children} 16 |
17 | ) 18 | } 19 | 20 | function Summary({ children, className }: { children: React.ReactNode; className?: string }) { 21 | return ( 22 | 28 | 29 | 30 | {children} 31 | 32 | 33 | ) 34 | } 35 | 36 | export const Details = Object.assign(Root, { Summary }) 37 | -------------------------------------------------------------------------------- /src/components/dev-bar.tsx: -------------------------------------------------------------------------------- 1 | import { useAtomValue } from "jotai" 2 | import React from "react" 3 | import { useHotkeys } from "react-hotkeys-hook" 4 | import { globalStateMachineAtom } from "../global-state" 5 | 6 | /** 7 | * Shows the current state of the global state machine for debugging purposes 8 | */ 9 | export function DevBar() { 10 | const state = useAtomValue(globalStateMachineAtom) 11 | 12 | // Toggle dev bar with ctrl+` 13 | const [isEnabled, setIsEnabled] = React.useState(false) 14 | useHotkeys("ctrl+`", () => setIsEnabled((prev) => !prev), { 15 | enabled: import.meta.env.DEV, 16 | preventDefault: true, 17 | enableOnFormTags: true, 18 | enableOnContentEditable: true, 19 | }) 20 | 21 | if (!isEnabled) return null 22 | 23 | return ( 24 |
25 |
26 | {formatState(state.value)} 27 | · 28 | 29 |
30 |
31 | ) 32 | } 33 | 34 | function formatState(state: Record | string): string { 35 | if (typeof state === "string") { 36 | return state 37 | } 38 | 39 | const entries = Object.entries(state) 40 | 41 | if (entries.length === 0) { 42 | return "" 43 | } 44 | 45 | if (entries.length === 1) { 46 | const [key, value] = entries[0] 47 | return `${key}.${formatState(value as Record | string)}` 48 | } 49 | 50 | return `[${entries 51 | .map(([key, value]) => `${key}.${formatState(value as Record | string)}`) 52 | .join("|")}]` 53 | } 54 | 55 | function CurrentBreakpoint() { 56 | return ( 57 | 58 | xs 59 | sm 60 | md 61 | lg 62 | xl 63 | 2xl 64 | 65 | ) 66 | } 67 | -------------------------------------------------------------------------------- /src/components/dialog.stories.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "./button" 2 | import { Dialog } from "./dialog" 3 | import { IconButton } from "./icon-button" 4 | import { ShareIcon16 } from "./icons" 5 | 6 | export default { 7 | title: "Dialog", 8 | component: Dialog, 9 | parameters: { 10 | layout: "centered", 11 | }, 12 | } 13 | 14 | export const Default = { 15 | render: () => { 16 | return ( 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 |
25 | 26 | 27 | Anyone with the link will be able to view this note. 28 | 29 |
30 |
31 |
32 | ) 33 | }, 34 | } 35 | -------------------------------------------------------------------------------- /src/components/dialog.tsx: -------------------------------------------------------------------------------- 1 | import * as RadixDialog from "@radix-ui/react-dialog" 2 | import { cx } from "../utils/cx" 3 | import React from "react" 4 | import { IconButton } from "./icon-button" 5 | import { XIcon16 } from "./icons" 6 | 7 | const Root = RadixDialog.Root 8 | 9 | const Trigger = RadixDialog.Trigger 10 | 11 | const Close = RadixDialog.Close 12 | 13 | type DialogContentProps = RadixDialog.DialogContentProps & { 14 | title: React.ReactNode 15 | } 16 | 17 | const Content = React.forwardRef( 18 | ({ title, className, children, ...props }, ref) => { 19 | return ( 20 | 21 | 28 |
29 | {title} 30 | 31 | 36 | 37 | 38 | 39 |
40 |
{children}
41 |
42 |
43 | ) 44 | }, 45 | ) 46 | 47 | export const Dialog = Object.assign(Root, { 48 | Trigger, 49 | Close, 50 | Content, 51 | }) 52 | -------------------------------------------------------------------------------- /src/components/dice.stories.tsx: -------------------------------------------------------------------------------- 1 | import { Dice } from "./dice" 2 | 3 | export default { 4 | title: "Dice", 5 | component: Dice, 6 | parameters: { 7 | layout: "centered", 8 | }, 9 | argTypes: { 10 | number: { 11 | options: [1, 2, 3, 4, 5, 6], 12 | control: { type: "radio" }, 13 | }, 14 | angle: { 15 | control: { 16 | type: "range", 17 | min: 0, 18 | max: 360, 19 | }, 20 | }, 21 | }, 22 | } 23 | 24 | export const Default = { 25 | args: { 26 | number: 3, 27 | angle: 0, 28 | }, 29 | } 30 | -------------------------------------------------------------------------------- /src/components/dice.tsx: -------------------------------------------------------------------------------- 1 | import { cx } from "../utils/cx" 2 | 3 | export function Dice({ 4 | number, 5 | angle = 0, 6 | className, 7 | }: { 8 | number: number 9 | angle?: number 10 | className?: string 11 | }) { 12 | return ( 13 |
28 | 34 | {number === 1 && } 35 | {number === 2 && ( 36 | <> 37 | 38 | 39 | 40 | )} 41 | {number === 3 && ( 42 | <> 43 | 44 | 45 | 46 | 47 | )} 48 | {number === 4 && ( 49 | <> 50 | 51 | 52 | 53 | 54 | 55 | )} 56 | {number === 5 && ( 57 | <> 58 | 59 | 60 | 61 | 62 | 63 | 64 | )} 65 | {number === 6 && ( 66 | <> 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | )} 75 | 76 |
77 | ) 78 | } 79 | -------------------------------------------------------------------------------- /src/components/dropdown-menu.stories.tsx: -------------------------------------------------------------------------------- 1 | import { DropdownMenu } from "./dropdown-menu" 2 | import { IconButton } from "./icon-button" 3 | import { EditIcon16, ExternalLinkIcon16, MoreIcon16, TrashIcon16 } from "./icons" 4 | 5 | export default { 6 | title: "DropdownMenu", 7 | component: DropdownMenu, 8 | parameters: { 9 | layout: "centered", 10 | }, 11 | } 12 | 13 | export const Default = { 14 | render: () => { 15 | return ( 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | } shortcut={["E"]}> 24 | Edit 25 | 26 | } href="#"> 27 | Open in GitHub 28 | 29 | 30 | } shortcut={["⌘", "⌫"]} disabled> 31 | Delete 32 | 33 | 34 | 35 | ) 36 | }, 37 | } 38 | -------------------------------------------------------------------------------- /src/components/emoji-favicon.tsx: -------------------------------------------------------------------------------- 1 | export function EmojiFavicon({ emoji }: { emoji: string }) { 2 | return ( 3 | 4 | 5 | {emoji} 6 | 7 | 8 | ) 9 | } 10 | -------------------------------------------------------------------------------- /src/components/file-input-button.tsx: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import { Slot } from "@radix-ui/react-slot" 3 | 4 | type FileInputButtonProps = React.PropsWithChildren<{ 5 | asChild?: boolean 6 | multiple?: boolean 7 | onChange?: (files: FileList | null) => void 8 | }> 9 | 10 | export function FileInputButton({ 11 | asChild = false, 12 | multiple = false, 13 | onChange, 14 | children, 15 | }: FileInputButtonProps) { 16 | const fileInputRef = React.useRef(null) 17 | const Component = asChild ? Slot : "button" 18 | return ( 19 | <> 20 | { 26 | onChange?.(event.target.files) 27 | }} 28 | /> 29 | { 31 | fileInputRef.current?.click() 32 | event.preventDefault() 33 | }} 34 | > 35 | {children} 36 | 37 | 38 | ) 39 | } 40 | -------------------------------------------------------------------------------- /src/components/file-preview.tsx: -------------------------------------------------------------------------------- 1 | import { useAtomValue } from "jotai" 2 | import React from "react" 3 | import { useNetworkState } from "react-use" 4 | import { ErrorIcon16, LoadingIcon16, OfflineIcon16 } from "../components/icons" 5 | import { githubRepoAtom, githubUserAtom } from "../global-state" 6 | import { getFileUrl, readFile } from "../utils/fs" 7 | import { REPO_DIR } from "../utils/git" 8 | 9 | export const fileCache = new Map() 10 | 11 | type FilePreviewProps = { 12 | path: string 13 | alt?: string 14 | width?: number | string 15 | height?: number | string 16 | } 17 | 18 | export function FilePreview({ path, alt = "", width, height }: FilePreviewProps) { 19 | const githubUser = useAtomValue(githubUserAtom) 20 | const githubRepo = useAtomValue(githubRepoAtom) 21 | const cachedFile = fileCache.get(path) 22 | const [file, setFile] = React.useState(cachedFile?.file ?? null) 23 | const [url, setUrl] = React.useState(cachedFile?.url ?? "") 24 | const [isLoading, setIsLoading] = React.useState(!cachedFile) 25 | const { online } = useNetworkState() 26 | 27 | React.useEffect(() => { 28 | // If file is already cached, don't fetch it again 29 | if (file) return 30 | 31 | async function loadFile() { 32 | if (!githubUser || !githubRepo) return 33 | 34 | try { 35 | setIsLoading(true) 36 | 37 | const file = await readFile(`${REPO_DIR}${path}`) 38 | const url = await getFileUrl({ file, path, githubUser, githubRepo }) 39 | 40 | setFile(file) 41 | setUrl(url) 42 | 43 | // Cache the file and its URL 44 | fileCache.set(path, { file, url }) 45 | } catch (error) { 46 | console.error(error) 47 | } finally { 48 | setIsLoading(false) 49 | } 50 | } 51 | 52 | loadFile() 53 | }, [file, githubUser, githubRepo, path]) 54 | 55 | if (!file) { 56 | return isLoading ? ( 57 |
58 | 59 | Loading… 60 |
61 | ) : !online ? ( 62 |
63 | 64 | File not available 65 |
66 | ) : ( 67 |
68 | 69 | File not found 70 |
71 | ) 72 | } 73 | 74 | // Image 75 | if (file.type.startsWith("image/")) { 76 | return {alt} 77 | } 78 | 79 | // Video 80 | if (file.type.startsWith("video/")) { 81 | return ( 82 | // eslint-disable-next-line jsx-a11y/media-has-caption 83 | 86 | ) 87 | } 88 | 89 | // Audio 90 | if (file.type.startsWith("audio/")) { 91 | // eslint-disable-next-line jsx-a11y/media-has-caption 92 | return