├── .DS_Store
├── .eslintrc
├── .gitignore
├── .node-version
├── README.md
├── etc
└── dev-server.js
├── jsconfig.json
├── package-lock.json
├── package.json
├── src
├── .DS_Store
├── api
│ ├── .DS_Store
│ ├── collection
│ │ ├── get.js
│ │ └── index.js
│ ├── collections
│ │ ├── get.js
│ │ └── index.js
│ ├── errors.js
│ ├── filters
│ │ ├── get.js
│ │ └── index.js
│ ├── index.js
│ ├── raindrops
│ │ ├── get.js
│ │ └── index.js
│ └── user
│ │ ├── get.js
│ │ └── index.js
├── assets
│ ├── .DS_Store
│ ├── brand
│ │ └── icon_48.svg
│ └── remixicon.symbol.svg
├── co
│ ├── .DS_Store
│ ├── badge
│ │ ├── index.jsx
│ │ └── index.module.css
│ ├── button
│ │ ├── index.jsx
│ │ ├── index.module.css
│ │ ├── select.jsx
│ │ ├── select.module.css
│ │ └── share.jsx
│ ├── collections
│ │ ├── compact
│ │ │ ├── index.jsx
│ │ │ └── single.jsx
│ │ ├── cover
│ │ │ ├── index.jsx
│ │ │ └── index.module.css
│ │ ├── hooks
│ │ │ ├── index.jsx
│ │ │ ├── useChildrens.jsx
│ │ │ ├── useParents.jsx
│ │ │ └── useRoot.jsx
│ │ └── listing
│ │ │ ├── index.jsx
│ │ │ ├── index.module.css
│ │ │ └── single
│ │ │ ├── index.jsx
│ │ │ └── index.module.css
│ ├── form
│ │ ├── checkbox
│ │ │ ├── index.jsx
│ │ │ └── index.module.css
│ │ ├── fields
│ │ │ ├── index.jsx
│ │ │ └── index.module.css
│ │ ├── form
│ │ │ ├── index.jsx
│ │ │ └── index.module.css
│ │ ├── index.jsx
│ │ ├── input
│ │ │ ├── index.jsx
│ │ │ └── index.module.css
│ │ ├── label
│ │ │ ├── index.jsx
│ │ │ └── index.module.css
│ │ ├── select
│ │ │ ├── index.jsx
│ │ │ └── index.module.css
│ │ └── textarea
│ │ │ ├── index.jsx
│ │ │ └── index.module.css
│ ├── icon
│ │ ├── .DS_Store
│ │ ├── index.jsx
│ │ └── index.module.css
│ ├── layout
│ │ ├── info
│ │ │ ├── index.jsx
│ │ │ └── index.module.css
│ │ └── toolbar
│ │ │ ├── index.jsx
│ │ │ └── index.module.css
│ ├── page
│ │ ├── content
│ │ │ ├── index.jsx
│ │ │ └── index.module.css
│ │ ├── footer
│ │ │ ├── index.jsx
│ │ │ └── index.module.css
│ │ ├── header
│ │ │ ├── index.jsx
│ │ │ └── index.module.css
│ │ ├── index.jsx
│ │ ├── index.module.css
│ │ ├── pagination
│ │ │ ├── index.jsx
│ │ │ └── index.module.css
│ │ ├── path
│ │ │ ├── index.jsx
│ │ │ └── index.module.css
│ │ ├── subheader
│ │ │ ├── index.jsx
│ │ │ └── index.module.css
│ │ └── theme.module.scss
│ ├── picture
│ │ ├── index.jsx
│ │ └── index.module.css
│ ├── popover
│ │ ├── context.jsx
│ │ ├── index.jsx
│ │ └── index.module.css
│ ├── raindrops
│ │ ├── listing
│ │ │ ├── index.jsx
│ │ │ ├── index.module.css
│ │ │ └── useInfiniteScroll.js
│ │ ├── path
│ │ │ └── index.jsx
│ │ ├── single
│ │ │ ├── add
│ │ │ │ ├── index.jsx
│ │ │ │ └── index.module.css
│ │ │ ├── cover
│ │ │ │ ├── index.jsx
│ │ │ │ └── index.module.css
│ │ │ ├── creator
│ │ │ │ └── index.jsx
│ │ │ ├── highlights
│ │ │ │ ├── highlight
│ │ │ │ │ ├── index.jsx
│ │ │ │ │ └── index.module.css
│ │ │ │ ├── index.jsx
│ │ │ │ └── index.module.css
│ │ │ ├── important
│ │ │ │ └── index.jsx
│ │ │ ├── index.jsx
│ │ │ ├── index.module.css
│ │ │ ├── path
│ │ │ │ ├── index.jsx
│ │ │ │ └── index.module.css
│ │ │ ├── tags
│ │ │ │ ├── index.jsx
│ │ │ │ └── tag
│ │ │ │ │ └── index.jsx
│ │ │ └── type
│ │ │ │ ├── index.jsx
│ │ │ │ └── index.module.css
│ │ └── sort
│ │ │ └── index.jsx
│ └── search
│ │ ├── field
│ │ ├── index.jsx
│ │ └── index.module.css
│ │ ├── hooks
│ │ ├── index.jsx
│ │ └── useFilterHref.jsx
│ │ └── tags
│ │ ├── index.jsx
│ │ ├── index.module.css
│ │ └── tag
│ │ └── index.jsx
├── config
│ ├── api.js
│ ├── links.js
│ ├── raindrops.js
│ └── vendors.js
├── modules
│ ├── .DS_Store
│ ├── async
│ │ ├── index.js
│ │ ├── pause.js
│ │ └── timeout.js
│ ├── browser
│ │ ├── copyText.js
│ │ └── index.js
│ ├── format
│ │ ├── callback
│ │ │ └── debounce.js
│ │ ├── date
│ │ │ ├── index.js
│ │ │ ├── parse.js
│ │ │ └── short.jsx
│ │ └── url
│ │ │ ├── domain.js
│ │ │ ├── index.js
│ │ │ └── query.js
│ └── router
│ │ ├── index.js
│ │ └── linkFactory.js
├── pages
│ ├── _app
│ │ ├── app.css
│ │ └── index.jsx
│ ├── _error
│ │ └── _error.page.jsx
│ ├── api
│ │ ├── oembed
│ │ │ ├── collection.js
│ │ │ ├── index.page.js
│ │ │ └── user.js
│ │ └── ogimage
│ │ │ └── index.page.js
│ ├── collection
│ │ ├── embed
│ │ │ ├── index.page.jsx
│ │ │ ├── index.page.route.js
│ │ │ ├── options.page.jsx
│ │ │ └── options.page.route.js
│ │ ├── empty-slug.page.js
│ │ ├── empty-slug.page.route.js
│ │ ├── search
│ │ │ ├── index.page.jsx
│ │ │ ├── index.page.route.js
│ │ │ ├── options.page.jsx
│ │ │ └── options.page.route.js
│ │ ├── share
│ │ │ ├── index.page.jsx
│ │ │ ├── index.page.route.js
│ │ │ ├── options.page.jsx
│ │ │ └── options.page.route.js
│ │ └── view
│ │ │ ├── index.page.jsx
│ │ │ ├── index.page.route.js
│ │ │ ├── options.page.jsx
│ │ │ └── options.page.route.js
│ ├── legacy
│ │ ├── byId
│ │ │ ├── collection.page.js
│ │ │ ├── collection.page.route.js
│ │ │ ├── user.page.js
│ │ │ └── user.page.route.js
│ │ └── obsolete
│ │ │ ├── collection-id-section.page.js
│ │ │ ├── collection-id-section.page.route.js
│ │ │ ├── collection-view-options.page.js
│ │ │ ├── collection-view-options.page.route.js
│ │ │ ├── collection-view.page.js
│ │ │ └── collection-view.page.route.js
│ └── user
│ │ ├── embed
│ │ ├── index.page.jsx
│ │ ├── index.page.route.js
│ │ ├── options.page.jsx
│ │ └── options.page.route.js
│ │ ├── home
│ │ ├── index.page.jsx
│ │ └── index.page.route.js
│ │ └── share
│ │ ├── index.page.jsx
│ │ ├── index.page.route.js
│ │ ├── options.page.jsx
│ │ └── options.page.route.js
├── public
│ ├── favicon.ico
│ └── icon_128.png
└── renderer
│ ├── _default.page.client.jsx
│ └── _default.page.server.jsx
├── vite.config.js
├── worker
├── index.js
├── ssr.js
├── static-assets.js
└── webpack.config.js
└── wrangler.toml
/.DS_Store:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/raindropio/pages/d012250750984f7559ff742dd5bcc6e297e8a9e1/.DS_Store
--------------------------------------------------------------------------------
/.eslintrc:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "eslint:recommended",
3 | "parser": "babel-eslint",
4 | "env": {
5 | "browser": true,
6 | "node": true
7 | },
8 | "plugins": [
9 | "react",
10 | "react-hooks"
11 | ],
12 | "rules": {
13 | "no-inner-declarations": "off",
14 | "no-unused-vars": 1,
15 | "no-debugger": 0,
16 | "no-console": 0,
17 | "new-cap": 0,
18 | "strict": 0,
19 | "no-underscore-dangle": 0,
20 | "no-use-before-define": 0,
21 | "eol-last": 0,
22 | "quotes": [1, "single"],
23 | "jsx-quotes": [1, "prefer-single"],
24 | "react/jsx-no-undef": 1,
25 | "react/jsx-uses-react": 1,
26 | "react/jsx-uses-vars": 1,
27 | "no-empty": ["error", {
28 | "allowEmptyCatch": true
29 | }]
30 | },
31 | "globals": {
32 | "Promise": true,
33 | "__TARGET__": true,
34 | "RAINDROP_ENVIRONMENT": true,
35 | "Set": true,
36 | "Map": true,
37 | "browser": true
38 | }
39 | }
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # node
2 | node_modules
3 |
4 | # outputs
5 | /worker/worker/
6 | /dist/
7 | .DS_Store
--------------------------------------------------------------------------------
/.node-version:
--------------------------------------------------------------------------------
1 | 16.0.0
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Raindrop.io Public Pages for Collection / User
2 |
3 | USE NODE 16 TO DEPLOY
4 |
5 | ## Implementation
6 | Build with `vite` & `vite-ssr-plugin`. Can be run on any serverless environment.
7 | But optimized for Cloudflare Worker for now.
8 |
9 | **Folder structure**
10 | - etc/dev-server Used only for local development, mimics Cloudflare Worker environment
11 | - src
12 | - pages
13 | - public Files that will be copied to production root folder
14 | - _app Like next.js
15 | - _error Like next.js
16 | - renderer Different root index files for client/server env
17 | - worker Source of Cloudflare Worker
18 | - wrangler.toml Cloudflare Worker specific
19 |
20 | ## Todo
21 | [] BaseURL
22 | [] Sentry
23 | [] Loading indicator
--------------------------------------------------------------------------------
/etc/dev-server.js:
--------------------------------------------------------------------------------
1 | const express = require('express')
2 | const { createPageRenderer } = require('vite-plugin-ssr')
3 | const vite = require('vite')
4 |
5 | const isProduction = process.env.NODE_ENV === 'production'
6 | const root = `${__dirname}/..`
7 |
8 | global.fetch = require('node-fetch')
9 |
10 | startServer()
11 |
12 | async function startServer() {
13 | const app = express()
14 |
15 | let viteDevServer
16 | if (isProduction)
17 | app.use(express.static(`${root}/dist/client`))
18 | else {
19 | viteDevServer = await vite.createServer({
20 | root,
21 | server: { middlewareMode: true },
22 | })
23 |
24 | app.use(viteDevServer.middlewares)
25 | }
26 |
27 | const renderPage = createPageRenderer({ viteDevServer, isProduction, root })
28 | app.get('*', async (req, res, next) => {
29 | const { httpResponse, statusCode, headers={}, redirect, json, proxy } = await renderPage({
30 | url: req.originalUrl
31 | })
32 |
33 | //remove caching headers for dev
34 | if (!isProduction)
35 | for(const i of Object.keys(headers))
36 | if (i.toLowerCase() == 'cache-control')
37 | delete headers[i]
38 |
39 | if (redirect) {
40 | return res.redirect(statusCode||302, redirect)
41 | } else if (proxy) {
42 | const r = await fetch(proxy)
43 | r.body.pipe(res)
44 | return res
45 | .status(r.status)
46 | .type(r.headers.get('content-type'))
47 | } else if (json)
48 | return res
49 | .status(statusCode || 200)
50 | .set(headers)
51 | .json(json)
52 | else if (!httpResponse)
53 | return next()
54 |
55 | res
56 | .status(statusCode || httpResponse.statusCode)
57 | .type(httpResponse.contentType)
58 | .set(headers)
59 | .send(httpResponse.body)
60 | })
61 |
62 | const port = 80
63 | app.listen(port)
64 | console.log(`Server running at http://localhost:${port}`)
65 | }
66 |
--------------------------------------------------------------------------------
/jsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "baseUrl": ".",
4 | "paths": {
5 | "~*": ["src/*"]
6 | }
7 | },
8 | "include": ["src/**/*"]
9 | }
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "pages",
3 | "version": "0.0.0",
4 | "imports": {
5 | "~*": "./src/*.js"
6 | },
7 | "scripts": {
8 | "local": "node ./etc/dev-server",
9 | "preview": "npm run build:vite && sudo wrangler dev --port 80",
10 | "deploy:prod": "npm run build:vite && DEBUG=wrangler:* wrangler publish --env production --verbose",
11 | "build:vite": "vite build && vite build --ssr"
12 | },
13 | "dependencies": {
14 | "@cloudflare/kv-asset-handler": "0.2.x",
15 | "@cloudflare/wrangler": "^1.19.5",
16 | "@vitejs/plugin-react": "1.1.x",
17 | "color-convert": "^2.0.1",
18 | "date-fns": "2.x",
19 | "express": "4.x",
20 | "lodash-es": "4.x",
21 | "markdown-to-jsx": "^7.2.0",
22 | "modern-normalize": "1.1.x",
23 | "react": "17.x",
24 | "react-dom": "17.x",
25 | "react-helmet": "6.x",
26 | "react-portal": "^4.2.1",
27 | "vite": "2.6.x",
28 | "vite-plugin-ssr": "0.3.31",
29 | "vite-plugin-svgr": "0.6.x"
30 | },
31 | "devDependencies": {
32 | "eslint": "7.x",
33 | "eslint-plugin-react": "7.x",
34 | "eslint-plugin-react-hooks": "4.x",
35 | "node-fetch": "2.x",
36 | "sass": "1.43.x"
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/src/.DS_Store:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/raindropio/pages/d012250750984f7559ff742dd5bcc6e297e8a9e1/src/.DS_Store
--------------------------------------------------------------------------------
/src/api/.DS_Store:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/raindropio/pages/d012250750984f7559ff742dd5bcc6e297e8a9e1/src/api/.DS_Store
--------------------------------------------------------------------------------
/src/api/collection/get.js:
--------------------------------------------------------------------------------
1 | import { API_ENDPOINT } from '~config/api'
2 | import { FetchError } from '../errors'
3 |
4 | export async function get(id) {
5 | if (typeof id == 'undefined')
6 | throw new FetchError(404)
7 |
8 | const res = await fetch(`${API_ENDPOINT}/collection/${id}`)
9 | if (!res.ok)
10 | throw new FetchError(res.status, res.statusText)
11 |
12 | const { result, item } = await res.json()
13 |
14 | if (!result)
15 | return null
16 |
17 | item.slug = item.slug || 'a'
18 |
19 | return item
20 | }
--------------------------------------------------------------------------------
/src/api/collection/index.js:
--------------------------------------------------------------------------------
1 | export * from './get'
2 |
--------------------------------------------------------------------------------
/src/api/collections/get.js:
--------------------------------------------------------------------------------
1 | import { API_ENDPOINT } from '~config/api'
2 | import { FetchError } from '../errors'
3 |
4 | export async function getByUserId(userId, options={}) {
5 | const params = new URLSearchParams(options)
6 |
7 | const res = await fetch(`${API_ENDPOINT}/collections/${String(userId)}?${params.toString()}`)
8 | if (!res.ok)
9 | throw new FetchError(res.status, res.statusText)
10 |
11 | const { result, items } = await res.json()
12 |
13 | if (!result)
14 | return []
15 |
16 | return items
17 | .map(item=>({
18 | ...item,
19 | slug: item.slug || 'a'
20 | }))
21 | }
22 |
23 | export async function getByUserName(user_name, options={}) {
24 | const params = new URLSearchParams(options)
25 |
26 | const res = await fetch(`${API_ENDPOINT}/collections/username/${String(user_name)}?${params.toString()}`)
27 | if (!res.ok)
28 | throw new FetchError(res.status, res.statusText)
29 |
30 | const { result, items } = await res.json()
31 |
32 | if (!result)
33 | return []
34 |
35 | return items
36 | .map(item=>({
37 | ...item,
38 | slug: item.slug || 'a'
39 | }))
40 | }
--------------------------------------------------------------------------------
/src/api/collections/index.js:
--------------------------------------------------------------------------------
1 | export * from './get'
--------------------------------------------------------------------------------
/src/api/errors.js:
--------------------------------------------------------------------------------
1 | export class FetchError extends Error {
2 | constructor(status, message, ...params) {
3 | super(...params)
4 |
5 | if (Error.captureStackTrace)
6 | Error.captureStackTrace(this, FetchError)
7 |
8 | this.name = 'FetchError'
9 | this.status = status
10 | this.message = message || status
11 | this.date = new Date()
12 | }
13 | }
--------------------------------------------------------------------------------
/src/api/filters/get.js:
--------------------------------------------------------------------------------
1 | import { API_ENDPOINT } from '~config/api'
2 | import { FetchError } from '../errors'
3 | import { optionsToQueryString } from '~api/raindrops/get'
4 |
5 | export async function get(id, _options) {
6 | const { sort, perpage, ...options } = _options
7 | const res = await fetch(`${API_ENDPOINT}/filters/${id}?${optionsToQueryString(options)}`)
8 | if (!res.ok)
9 | throw new FetchError(res.status, res.statusText)
10 |
11 | const { result, ...items } = await res.json()
12 |
13 | if (!result)
14 | return []
15 |
16 | return items
17 | }
--------------------------------------------------------------------------------
/src/api/filters/index.js:
--------------------------------------------------------------------------------
1 | export * from './get'
--------------------------------------------------------------------------------
/src/api/index.js:
--------------------------------------------------------------------------------
1 | import * as collection from './collection'
2 | import * as collections from './collections'
3 | import * as filters from './filters'
4 | import * as raindrops from './raindrops'
5 | import * as user from './user'
6 |
7 | export default {
8 | collection,
9 | collections,
10 | filters,
11 | raindrops,
12 | user
13 | }
--------------------------------------------------------------------------------
/src/api/raindrops/get.js:
--------------------------------------------------------------------------------
1 | import { API_ENDPOINT } from '~config/api'
2 | import { FetchError } from '../errors'
3 |
4 | export function optionsToQueryString(options={}) {
5 | const params = new URLSearchParams(options)
6 | params.set('version', 2)
7 |
8 | //nested
9 | if (params.get('nested') === 'false')
10 | params.delete('nested')
11 | else
12 | params.set('nested', true)
13 |
14 | return params.toString()
15 | }
16 |
17 | export async function get(id, options={}) {
18 | const res = await fetch(`${API_ENDPOINT}/raindrops/${id}?${optionsToQueryString(options)}`)
19 | if (!res.ok)
20 | throw new FetchError(res.status, res.statusText)
21 |
22 | const { result, items, count=0 } = await res.json()
23 |
24 | if (!result)
25 | return {
26 | items: [],
27 | count: 0
28 | }
29 |
30 | return {
31 | items,
32 | count
33 | }
34 | }
--------------------------------------------------------------------------------
/src/api/raindrops/index.js:
--------------------------------------------------------------------------------
1 | export * from './get'
--------------------------------------------------------------------------------
/src/api/user/get.js:
--------------------------------------------------------------------------------
1 | import { API_ENDPOINT } from '~config/api'
2 | import { FetchError } from '../errors'
3 |
4 | //id or name
5 | export async function getById(id) {
6 | const res = await fetch(`${API_ENDPOINT}/user/${String(id)}`)
7 | if (!res.ok)
8 | throw new FetchError(res.status, res.statusText)
9 |
10 | const { result, user } = await res.json()
11 |
12 | if (!result)
13 | return null
14 |
15 | return user
16 | }
17 |
18 | export async function getByName(name) {
19 | const res = await fetch(`${API_ENDPOINT}/user/name/${String(name)}`)
20 | if (!res.ok)
21 | throw new FetchError(res.status, res.statusText)
22 |
23 | const { result, user } = await res.json()
24 |
25 | if (!result)
26 | return null
27 |
28 | return user
29 | }
--------------------------------------------------------------------------------
/src/api/user/index.js:
--------------------------------------------------------------------------------
1 | export * from './get'
--------------------------------------------------------------------------------
/src/assets/.DS_Store:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/raindropio/pages/d012250750984f7559ff742dd5bcc6e297e8a9e1/src/assets/.DS_Store
--------------------------------------------------------------------------------
/src/assets/brand/icon_48.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/co/.DS_Store:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/raindropio/pages/d012250750984f7559ff742dd5bcc6e297e8a9e1/src/co/.DS_Store
--------------------------------------------------------------------------------
/src/co/badge/index.jsx:
--------------------------------------------------------------------------------
1 | import s from './index.module.css'
2 |
3 | export default function Badge({ className='', variant, ...etc }) {
4 | return (
5 |
9 | )
10 | }
--------------------------------------------------------------------------------
/src/co/badge/index.module.css:
--------------------------------------------------------------------------------
1 | .badge {
2 | display: inline-block;
3 | font-size: var(--font-size-micro);
4 | font-weight: 600;
5 | color: var(--background-color-regular);
6 |
7 | height: 1.3em;
8 | line-height: 1.3em;
9 | padding: 0 var(--padding-small);
10 | border-radius: var(--border-radius-regular);
11 | }
12 |
13 | .badge[data-variant='disabled'] {
14 | background: var(--text-color-secondary);
15 | opacity: .5;
16 | }
--------------------------------------------------------------------------------
/src/co/button/index.jsx:
--------------------------------------------------------------------------------
1 | import s from './index.module.css'
2 | import { forwardRef } from 'react'
3 | import Icon, { Logo, Avatar } from '~co/icon'
4 |
5 | export * from './select'
6 | export * from './share'
7 |
8 | export function Base({ as='a', className='', variant, color, size, bold=false, disabled=false, inline=true, forwardedRef, ...props }) {
9 | const Component = as
10 |
11 | return (
12 |
22 | )
23 | }
24 |
25 | export default forwardRef((props, ref) => {
26 | return
27 | })
28 |
29 | export function Buttons({ className='', tight=false, ...etc }) {
30 | return (
31 |
32 | )
33 | }
--------------------------------------------------------------------------------
/src/co/button/index.module.css:
--------------------------------------------------------------------------------
1 | .button {
2 | user-select: none;
3 | appearance: none !important;
4 | background: transparent;
5 | border: 0;
6 | cursor: pointer;
7 | white-space: nowrap;
8 | flex-shrink: 0;
9 | max-width: 100%;
10 | vertical-align: middle;
11 |
12 | height: var(--this-button-size);
13 | line-height: var(--this-button-size);
14 | border-radius: var(--border-radius-large);
15 |
16 | display: inline-grid;
17 | grid-auto-flow: column;
18 | grid-gap: var(--padding-regular);
19 | align-items: center;
20 | justify-content: start;
21 |
22 | padding: 0 var(--padding-regular);
23 | transition: background .1s linear, box-shadow .1s linear, filter .1s linear;
24 | }
25 | .button[data-inline="false"] {
26 | display: grid;
27 | justify-content: center;
28 | }
29 |
30 | .button:active {
31 | filter: brightness(90%);
32 | }
33 |
34 | /* size */
35 | .button[data-size='regular'] {
36 | --this-button-size: var(--button-size-regular)
37 | }
38 |
39 | .button[data-size='small'] {
40 | --this-button-size: var(--button-size-small);
41 | font-size: var(--font-size-small);
42 | }
43 |
44 | .button[data-size='large'] {
45 | --this-button-size: var(--button-size-large);
46 | }
47 |
48 | .button[data-single-icon='true'] {
49 | contain: strict;
50 | padding: 0;
51 | justify-content: center;
52 | width: var(--this-button-size);
53 | }
54 |
55 | /* colors */
56 | .button[data-color='secondary'] {
57 | color: var(--text-color-secondary);
58 | }
59 |
60 | .button[data-color='regular'] {
61 | color: var(--text-color-regular);
62 | }
63 |
64 | .button[data-color='accent'] {
65 | color: var(--accent-color);
66 | }
67 |
68 | /* variants */
69 | .button[data-variant='regular'] {
70 | background: var(--background-color-regular);
71 | box-shadow: inset 0 0 0 var(--line-size) var(--line-color);
72 | }
73 | .button[data-variant='regular']:hover,
74 | .button[data-variant='ghost']:hover {
75 | box-shadow: inset 0 0 0 var(--line-size) var(--line-color), 0 var(--line-size) 2px rgba(0,0,0,.3);
76 | }
77 |
78 | .button[data-variant='ghost'] {
79 | background: var(--background-color-secondary);
80 | }
81 |
82 | .button[data-variant='regular']:active,
83 | .button[data-variant='ghost']:active,
84 | .button[data-variant='flat']:hover {
85 | background: var(--background-color-secondary);
86 | box-shadow: 0 0 0 2px var(--background-color-secondary);
87 | }
88 |
89 | .button[data-size='small'][data-variant='flat'] {
90 | padding-left: 0;
91 | padding-right: 0;
92 | border-radius: var(--border-radius-regular);
93 | }
94 |
95 | .button[data-variant='active'] {
96 | background: var(--accent-color);
97 | color: var(--background-color-regular);
98 | }
99 |
100 | .button[data-variant='disabled'] {
101 | pointer-events: none;
102 | background: var(--background-color-disabled);
103 | color: var(--text-color-disabled);
104 | }
105 |
106 | /* modifiers */
107 | .button[data-bold='true'] {
108 | font-weight: 600;
109 | }
110 |
111 | /* group */
112 | .buttons {
113 | --buttons-gap: calc(var(--padding-regular) + var(--padding-small));
114 |
115 | display: flex;
116 | flex-wrap: wrap;
117 | align-content: flex-start;
118 |
119 | margin-bottom: calc(var(--buttons-gap) * -1);
120 | margin-right: calc(var(--buttons-gap) * -1);
121 | }
122 |
123 | .buttons > * {
124 | margin: var(--buttons-gap);
125 | margin-top: 0;
126 | margin-left: 0;
127 | }
128 |
129 | .buttons[data-tight='true'] {
130 | --buttons-gap: var(--padding-regular)
131 | }
--------------------------------------------------------------------------------
/src/co/button/select.jsx:
--------------------------------------------------------------------------------
1 | import s from './select.module.css'
2 | import React, { useCallback } from 'react'
3 | import { Base } from './index'
4 | import Icon from '~co/icon'
5 |
6 | /*
7 | options = [ { value, label, ..anything } ]
8 | selected = value
9 | children = function({ value, label, ...}=>render) optional
10 | onChange = functioon(value)
11 | */
12 | export function Select({ className='', options=[], selected, children, onChange, ...etc }) {
13 | const active = options.find(({value})=>(value||undefined) == (selected||undefined))
14 |
15 | const onNativeChange = useCallback(e=>{
16 | e.preventDefault()
17 | onChange && onChange(e.target.value)
18 | }, [onChange])
19 |
20 | return (
21 |
25 | {!!active && (children ?
26 | children(active) :
27 | (
28 | <>
29 | {active.label}
30 |
33 | >
34 | )
35 | )}
36 |
37 |
53 |
54 | )
55 | }
--------------------------------------------------------------------------------
/src/co/button/select.module.css:
--------------------------------------------------------------------------------
1 | .select {
2 | position: relative;
3 | overflow: initial; /* fix firefox bug */
4 | border-radius: var(--border-radius-regular);
5 | }
6 | .select:focus-within {
7 | outline-style: auto;
8 | }
9 |
10 | .select select {
11 | opacity: 0;
12 | position: absolute;
13 | top: 0;
14 | right: 0;
15 | left: 0;
16 | bottom: 0;
17 | width: 100%;
18 | height: 100%;
19 | appearance: none;
20 | }
21 |
22 | .dropDownIcon {
23 | color: var(--text-color-secondary);
24 | }
--------------------------------------------------------------------------------
/src/co/button/share.jsx:
--------------------------------------------------------------------------------
1 | import { TWITTER_ID, FACEBOOK_APP_ID } from '~config/vendors'
2 | import { copyText } from '~modules/browser'
3 |
4 | import Button from './index'
5 | import Icon from '~co/icon'
6 |
7 | export function Share({ url, title }) {
8 | return [
9 | ,
19 |
20 | ,
30 |
31 | ,
41 |
42 | ,
49 |
50 | ,
57 | ]
58 | }
--------------------------------------------------------------------------------
/src/co/collections/compact/index.jsx:
--------------------------------------------------------------------------------
1 | import { Buttons } from '~co/button'
2 | import Single from './single'
3 |
4 | export default function CollectionsCompact({ items, user }) {
5 | if (!items.length)
6 | return null
7 |
8 | return (
9 |
10 | {items.map(item=>(
11 |
15 | ))}
16 |
17 | )
18 | }
--------------------------------------------------------------------------------
/src/co/collections/compact/single.jsx:
--------------------------------------------------------------------------------
1 | import Button from '~co/button'
2 | import Cover from '../cover'
3 |
4 | export default function CollectionsCompactSingle({ item, user, ...etc }) {
5 | return (
6 |
17 | )
18 | }
--------------------------------------------------------------------------------
/src/co/collections/cover/index.jsx:
--------------------------------------------------------------------------------
1 | import s from './index.module.css'
2 | import Icon, { Image } from '~co/icon'
3 |
4 | export default function CollectionCover({ className='', cover, title, size, fallback=true }) {
5 | if (!Array.isArray(cover) ||
6 | !cover.length)
7 | return fallback ? (
8 |
12 | ) : null
13 |
14 | return (
15 |
20 | )
21 | }
--------------------------------------------------------------------------------
/src/co/collections/cover/index.module.css:
--------------------------------------------------------------------------------
1 | .fallback {
2 | color: var(--text-color-secondary);
3 | text-decoration: none;
4 | }
--------------------------------------------------------------------------------
/src/co/collections/hooks/index.jsx:
--------------------------------------------------------------------------------
1 | export * from './useChildrens'
2 | export * from './useRoot'
3 | export * from './useParents'
--------------------------------------------------------------------------------
/src/co/collections/hooks/useChildrens.jsx:
--------------------------------------------------------------------------------
1 | import { useMemo } from 'react'
2 | import sortBy from 'lodash-es/sortBy'
3 |
4 | export function useChildrens(items, collection) {
5 | return useMemo(()=>
6 | sortBy(
7 | items.filter(c=>c.parent?.$id == collection._id),
8 | ['sort']
9 | ),
10 | items
11 | )
12 | }
--------------------------------------------------------------------------------
/src/co/collections/hooks/useParents.jsx:
--------------------------------------------------------------------------------
1 | import { useMemo } from 'react'
2 |
3 | export function useParents(collections=[], collection, self) {
4 | return useMemo(()=>{
5 | const parents = []
6 |
7 | if (!collection)
8 | return parents
9 |
10 | const find = (findId)=>{
11 | const parent = collections.find(({_id})=>_id == findId)
12 |
13 | if (parent){
14 | parents.unshift(parent)
15 |
16 | if (parent.parent?.$id)
17 | find(parent.parent.$id)
18 | }
19 | }
20 | find(collection.parent?.$id)
21 |
22 | if (self)
23 | parents.push(collection)
24 |
25 | return parents
26 | }, [collections, collection])
27 | }
--------------------------------------------------------------------------------
/src/co/collections/hooks/useRoot.jsx:
--------------------------------------------------------------------------------
1 | import { useMemo } from 'react'
2 | import sortBy from 'lodash-es/sortBy'
3 |
4 | export function useRoot(items) {
5 | return useMemo(()=>
6 | sortBy(
7 | items.filter(({parent})=>{
8 | if (parent)
9 | return !items.find(({_id})=>_id==parent.$id)
10 |
11 | return true
12 | }),
13 | ['title']
14 | ),
15 | items
16 | )
17 | }
--------------------------------------------------------------------------------
/src/co/collections/listing/index.jsx:
--------------------------------------------------------------------------------
1 | import s from './index.module.css'
2 | import Single from './single'
3 |
4 | export default function CollectionsListing({ items, user, target }) {
5 | if (!items.length)
6 | return null
7 |
8 | return (
9 |
19 | )
20 | }
--------------------------------------------------------------------------------
/src/co/collections/listing/index.module.css:
--------------------------------------------------------------------------------
1 | .listing {
2 | display: grid;
3 | grid-template-columns: repeat(auto-fill, minmax(min(100%, 250px), 1fr));
4 | grid-template-rows: 1fr;
5 | margin: 0 calc(var(--padding-large) * -1);
6 | }
7 |
8 | @media screen and (max-width: 500px) {
9 | .listing {
10 | grid-template-columns: 50vw 50vw;
11 | }
12 | }
--------------------------------------------------------------------------------
/src/co/collections/listing/single/index.jsx:
--------------------------------------------------------------------------------
1 | import s from './index.module.css'
2 | import { useMemo } from 'react'
3 | import colorConvert from 'color-convert'
4 |
5 | import Cover from '../../cover'
6 |
7 | export default function CollectionsSingle({ item, user, target }) {
8 | const folderStyle = useMemo(()=>
9 | item.color ? {
10 | '--bg-rgb': colorConvert.hex.rgb(item.color.replace('#',''))
11 | } : undefined,
12 | [item.color]
13 | )
14 |
15 | return (
16 |
21 |
25 |
29 |
30 |
31 |
32 | {item.title}
33 |
34 |
35 | )
36 | }
--------------------------------------------------------------------------------
/src/co/collections/listing/single/index.module.css:
--------------------------------------------------------------------------------
1 | a.single {
2 | display: inline-grid;
3 | grid-gap: var(--padding-regular);
4 | align-content: flex-start;
5 |
6 | color: var(--text-color-regular);
7 | padding: var(--padding-large);
8 | }
9 |
10 | .folder {
11 | border-radius: var(--border-radius-regular);
12 | position: relative;
13 |
14 | background: var(--background-color-secondary);
15 | transition: background .1s linear;
16 | display: flex;
17 | align-items: center;
18 | justify-content: center;
19 | aspect-ratio: 5 / 3;
20 | }
21 |
22 | @supports not (aspect-ratio: 1/1) {
23 | .folder {
24 | height: 180px;
25 | }
26 | }
27 |
28 | .folder[data-custom-bg='true'] {
29 | background: rgba(var(--bg-rgb), .08);
30 | }
31 |
32 | .single:hover .folder[data-custom-bg='true'] {
33 | background: rgba(var(--bg-rgb), .15);
34 | }
35 |
36 | .cover {
37 | display: block;
38 | }
39 |
40 | .title {
41 | font-weight: 600;
42 | }
--------------------------------------------------------------------------------
/src/co/form/checkbox/index.jsx:
--------------------------------------------------------------------------------
1 | import s from './index.module.css'
2 | import { useCallback, useContext } from 'react'
3 | import { Context } from '../form'
4 | import Icon from '~co/icon'
5 |
6 | export function Checkbox({ className='', children, ...etc }) {
7 | const { values, onChange } = useContext(Context)
8 | const checked = values[etc.name]
9 |
10 | return (
11 |
31 | )
32 | }
--------------------------------------------------------------------------------
/src/co/form/checkbox/index.module.css:
--------------------------------------------------------------------------------
1 | .label {
2 | display: inline-grid;
3 | grid-template-areas: 'checkbox title';
4 | grid-template-columns: min-content max-content;
5 | grid-gap: var(--padding-regular);
6 | align-items: center;
7 | height: var(--button-size-regular);
8 | cursor: pointer;
9 | }
10 |
11 | .checkbox {
12 | grid-area: checkbox;
13 | position: relative;
14 | color: var(--text-color-secondary);
15 | }
16 |
17 | .checkbox > * {
18 | pointer-events: none;
19 | }
20 |
21 | .checkbox input {
22 | cursor: pointer;
23 | pointer-events: auto;
24 | position: absolute;
25 | top: 0;
26 | left: 0;
27 | right: 0;
28 | bottom: 0;
29 | width: 100%;
30 | height: 100%;
31 | appearance: none;
32 | }
33 |
34 | .checkbox .icon {
35 | display: block;
36 | }
37 |
38 | .label[data-checked='true'] .checkbox {
39 | color: var(--accent-color)
40 | }
41 |
42 | .title {
43 | grid-area: title;
44 | }
--------------------------------------------------------------------------------
/src/co/form/fields/index.jsx:
--------------------------------------------------------------------------------
1 | import s from './index.module.css'
2 |
3 | export function Fields({ className='', inset=false, ...etc }) {
4 | return (
5 |
9 | )
10 | }
--------------------------------------------------------------------------------
/src/co/form/fields/index.module.css:
--------------------------------------------------------------------------------
1 | .fields {
2 | display: grid;
3 | grid-auto-flow: row;
4 | grid-gap: var(--padding-regular);
5 | }
6 |
7 | .fields[data-inset='true'] {
8 | padding: var(--padding-regular);
9 | background-color: var(--background-color-secondary);
10 | border-radius: var(--border-radius-regular);
11 | }
--------------------------------------------------------------------------------
/src/co/form/form/index.jsx:
--------------------------------------------------------------------------------
1 | import s from './index.module.css'
2 | import { createContext, useMemo, useCallback } from 'react'
3 |
4 | export const Context = createContext({
5 | values: {}
6 | })
7 |
8 | export default function Form({ className='', value, onChange, onSubmit, ...etc }) {
9 | const onFieldChange = useCallback(e=>{
10 | const elem = e.currentTarget
11 | const fieldName = elem.name
12 | const fieldValue = elem.type == 'checkbox' ? (elem.checked||false) : elem.value
13 |
14 | onChange({
15 | ...value,
16 | [fieldName]: fieldValue
17 | })
18 | }, [value, onChange])
19 |
20 | const onFormSubmit = useCallback(e=>{
21 | e.preventDefault()
22 | if (onSubmit) onSubmit(e)
23 | }, [onSubmit])
24 |
25 | const context = useMemo(()=>{
26 | let values = {...value}
27 | for(const i in values)
28 | if (values[i]=='true')
29 | values[i] = true
30 | else if (values[i]=='false')
31 | values[i] = false
32 |
33 | return {
34 | values,
35 | onChange: onFieldChange
36 | }
37 | }, [value])
38 |
39 | return (
40 |
41 |
45 |
46 | )
47 | }
--------------------------------------------------------------------------------
/src/co/form/form/index.module.css:
--------------------------------------------------------------------------------
1 | .form {
2 | display: grid;
3 | grid-template-columns: max-content 1fr;
4 | grid-gap: var(--padding-large);
5 | padding: var(--padding-regular) 0;
6 | }
7 |
8 | .form > * {
9 | grid-column: 2 / -1;
10 | }
--------------------------------------------------------------------------------
/src/co/form/index.jsx:
--------------------------------------------------------------------------------
1 | import Form from './form'
2 | export * from './label'
3 | export * from './fields'
4 | export * from './checkbox'
5 | export * from './input'
6 | export * from './select'
7 | export * from './textarea'
8 |
9 | export default Form
--------------------------------------------------------------------------------
/src/co/form/input/index.jsx:
--------------------------------------------------------------------------------
1 | import s from './index.module.css'
2 | import { useContext } from 'react'
3 | import { Context } from '../form'
4 |
5 | export function Input({ className='', ...etc }) {
6 | const { values, onChange } = useContext(Context)
7 |
8 | return (
9 |
15 | )
16 | }
--------------------------------------------------------------------------------
/src/co/form/input/index.module.css:
--------------------------------------------------------------------------------
1 | .input {
2 | padding: var(--padding-regular);
3 | height: var(--button-size-regular);
4 | background: var(--background-color-secondary);
5 | border: 0;
6 | border-radius: var(--border-radius-regular);
7 | color: var(--text-color-regular);
8 | }
--------------------------------------------------------------------------------
/src/co/form/label/index.jsx:
--------------------------------------------------------------------------------
1 | import s from './index.module.css'
2 |
3 | export function Label({ className='', ...etc }) {
4 | return (
5 |
8 | )
9 | }
--------------------------------------------------------------------------------
/src/co/form/label/index.module.css:
--------------------------------------------------------------------------------
1 | .label {
2 | grid-column: 1;
3 | text-align: right;
4 | font-size: var(--font-size-small);
5 | color: var(--text-color-secondary);
6 | padding-top: var(--padding-small);
7 | padding-left: var(--padding-large);
8 |
9 | display: flex;
10 | align-items: center;
11 | justify-content: flex-end;
12 | gap: var(--padding-small);
13 | }
--------------------------------------------------------------------------------
/src/co/form/select/index.jsx:
--------------------------------------------------------------------------------
1 | import s from './index.module.css'
2 | import { useCallback, useContext } from 'react'
3 | import { Context } from '../form'
4 | import { Select as ButtonSelect } from '~co/button'
5 |
6 | export function Select({ className='', ...etc }) {
7 | const { values, onChange } = useContext(Context)
8 | const selected = values[etc.name]
9 | const onSelectChange = useCallback(value=>{
10 | onChange({
11 | currentTarget: {
12 | name: etc.name,
13 | value
14 | }
15 | })
16 | }, [onChange])
17 |
18 | return (
19 |
25 | )
26 | }
--------------------------------------------------------------------------------
/src/co/form/select/index.module.css:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/raindropio/pages/d012250750984f7559ff742dd5bcc6e297e8a9e1/src/co/form/select/index.module.css
--------------------------------------------------------------------------------
/src/co/form/textarea/index.jsx:
--------------------------------------------------------------------------------
1 | import s from './index.module.css'
2 | import { useCallback, useContext } from 'react'
3 | import { Context } from '../form'
4 |
5 | export function Textarea({ className='', ...etc }) {
6 | const { values, onChange } = useContext(Context)
7 | const onFocus = useCallback(e=>{
8 | if (etc.readOnly)
9 | e.target.select()
10 |
11 | if (etc.onFocus)
12 | etc.onFocus(e)
13 | })
14 |
15 | return (
16 |
22 | )
23 | }
--------------------------------------------------------------------------------
/src/co/form/textarea/index.module.css:
--------------------------------------------------------------------------------
1 | .textarea {
2 | padding: var(--padding-regular);
3 | background: var(--background-color-secondary);
4 | border: 0;
5 | border-radius: var(--border-radius-regular);
6 | color: var(--text-color-regular);
7 | line-height: 1.3;
8 | }
--------------------------------------------------------------------------------
/src/co/icon/.DS_Store:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/raindropio/pages/d012250750984f7559ff742dd5bcc6e297e8a9e1/src/co/icon/.DS_Store
--------------------------------------------------------------------------------
/src/co/icon/index.jsx:
--------------------------------------------------------------------------------
1 | import s from './index.module.css'
2 | import { ReactComponent as BrandIcon } from '~assets/brand/icon_48.svg'
3 | import { THUMBNAILS_ENDPOINT } from '~config/api'
4 | import remixIconSymbolUrl from '~assets/remixicon.symbol.svg?url'
5 |
6 | function Base({ as='svg', size, className='', ...etc }) {
7 | const Component = as
8 |
9 | return (
10 |
14 | )
15 | }
16 |
17 | export default function Icon({ name, variant='line', ...etc }) {
18 | return (
19 |
20 |
21 |
22 | )
23 | }
24 |
25 | export function Logo(props) {
26 | return (
27 |
30 | )
31 | }
32 |
33 | //Image
34 | const widths = {
35 | small: 18,
36 | regular: 24,
37 | large: 32,
38 | xlarge: 64,
39 | }
40 |
41 | export function Image({ src, ...etc }) {
42 | const width = widths[etc.size]||widths.regular
43 |
44 | return (
45 |
51 | )
52 | }
53 |
54 | export function Avatar(props) {
55 | return (
56 |
57 |
58 |
59 | )
60 | }
61 |
62 | function Indicator({ className='', ...props }) {
63 | return (
64 |
67 |
72 |
73 |
85 |
86 | )
87 | }
88 |
89 | export function ActivityIndicator(props) {
90 | return (
91 |
94 | )
95 | }
--------------------------------------------------------------------------------
/src/co/icon/index.module.css:
--------------------------------------------------------------------------------
1 | .icon {
2 | display: inline-block;
3 | width: var(--icon-size-regular);
4 | height: var(--icon-size-regular);
5 | fill: currentColor;
6 | }
7 |
8 | .icon[data-size='small'] {
9 | width: var(--icon-size-small);
10 | height: var(--icon-size-small);
11 | }
12 |
13 | .icon[data-size='large'] {
14 | width: var(--icon-size-large);
15 | height: var(--icon-size-large);
16 | }
17 |
18 | .icon[data-size='xlarge'] {
19 | width: var(--icon-size-xlarge);
20 | height: var(--icon-size-xlarge);
21 | }
22 |
23 | /* avatar */
24 | .avatar {
25 | display: inline-block;
26 | position: relative;
27 | }
28 |
29 | .avatar:after {
30 | position: absolute;
31 | left: 0;
32 | right: 0;
33 | bottom: 0;
34 | top: 0;
35 | box-shadow: inset 0 0 0 var(--line-size) var(--line-color);
36 | content: '';
37 | border-radius: 50%;
38 | }
39 |
40 | .avatar > * {
41 | display: block;
42 | border-radius: 50%;
43 | }
44 |
45 | .activityIndicator {
46 | fill: currentColor;
47 | position: relative;
48 | }
49 |
50 | .activityIndicator .bg {
51 | opacity: 0.35
52 | }
53 |
54 | .activityIndicator .main {
55 | animation: rotate .6s ease-in-out infinite;
56 | position: absolute;
57 | top: 0;
58 | right: 0;
59 | bottom: 0;
60 | left: 0;
61 | }
62 |
63 | @keyframes rotate {
64 | 0% {
65 | transform: rotate(0)
66 | }
67 | 12.5% {
68 | transform: rotate(0)
69 | }
70 | 12.500001% {
71 | transform: rotate(45deg)
72 | }
73 | 25% {
74 | transform: rotate(45deg)
75 | }
76 | 25.00001% {
77 | transform: rotate(90deg)
78 | }
79 | 37.5% {
80 | transform: rotate(90deg)
81 | }
82 | 37.500001% {
83 | transform: rotate(135deg)
84 | }
85 | 50% {
86 | transform: rotate(135deg)
87 | }
88 | 50.00001% {
89 | transform: rotate(180deg)
90 | }
91 | 62.5% {
92 | transform: rotate(180deg)
93 | }
94 | 62.500001% {
95 | transform: rotate(225deg)
96 | }
97 | 75% {
98 | transform: rotate(225deg)
99 | }
100 | 75.00001% {
101 | transform: rotate(270deg)
102 | }
103 | 87.5% {
104 | transform: rotate(270deg)
105 | }
106 | 87.500001% {
107 | transform: rotate(315deg)
108 | }
109 | 100% {
110 | transform: rotate(315deg)
111 | }
112 | }
--------------------------------------------------------------------------------
/src/co/layout/info/index.jsx:
--------------------------------------------------------------------------------
1 | import s from './index.module.css'
2 | import { Fragment } from 'react'
3 |
4 | export default function Info({ className='', children, divider='·', ...etc }) {
5 | return (
6 |
7 | {Array.isArray(children) ?
8 | children.filter(child=>!!child).map((child, i, { length })=>(
9 |
10 | {child}
11 | {i{divider}}
12 |
13 | ))
14 | : children
15 | }
16 |
17 | )
18 | }
--------------------------------------------------------------------------------
/src/co/layout/info/index.module.css:
--------------------------------------------------------------------------------
1 | .info {
2 | display: grid;
3 | grid-auto-flow: column;
4 | justify-content: flex-start;
5 | align-items: center;
6 | grid-gap: var(--padding-regular);
7 |
8 | font-size: var(--font-size-small);
9 | color: var(--text-color-secondary);
10 | }
11 |
12 | .info > * {
13 | overflow: hidden;
14 | text-overflow: ellipsis;
15 | white-space: nowrap;
16 | }
17 |
18 | .divider:first-child,
19 | .info > *:empty,
20 | .info > *:empty + .divider {
21 | display: none;
22 | }
23 |
24 | .divider {
25 | opacity: .5;
26 | }
--------------------------------------------------------------------------------
/src/co/layout/toolbar/index.jsx:
--------------------------------------------------------------------------------
1 | import s from './index.module.css'
2 | import { Buttons } from '~co/button'
3 |
4 | export default {
5 | Wrap: function({ className='', ...etc }) {
6 | return (
7 |
10 | )
11 | },
12 |
13 | Title: function({ className='', ...etc }) {
14 | return (
15 |
18 | )
19 | },
20 |
21 | Buttons: function({ className='', ...etc }) {
22 | return (
23 |
26 | )
27 | }
28 | }
--------------------------------------------------------------------------------
/src/co/layout/toolbar/index.module.css:
--------------------------------------------------------------------------------
1 | .toolbar {
2 | display: grid;
3 | grid-template-areas: 'title buttons';
4 | grid-template-columns: 1fr max-content;
5 | align-items: center;
6 | padding-top: var(--padding-large);
7 | padding-bottom: var(--padding-regular);
8 | margin: 0;
9 | margin-top: var(--padding-regular);
10 | box-shadow: 0 calc(var(--line-size) * -1) 0 var(--line-color);
11 | }
12 | .toolbar:not(:first-child) {
13 | margin-top: var(--padding-large);
14 | }
15 |
16 | .title {
17 | grid-area: title;
18 | font-size: var(--font-size-h2);
19 | font-weight: 600;
20 | }
21 |
22 | .buttons {
23 | grid-area: buttons;
24 | }
--------------------------------------------------------------------------------
/src/co/page/content/index.jsx:
--------------------------------------------------------------------------------
1 | import s from './index.module.css'
2 |
3 | export default function Content({ children }) {
4 | return (
5 |
6 | {children}
7 |
8 | )
9 | }
--------------------------------------------------------------------------------
/src/co/page/content/index.module.css:
--------------------------------------------------------------------------------
1 | .content {
2 | width: var(--page-width);
3 | margin: 0 auto;
4 | flex: 1;
5 | display: grid;
6 | grid-auto-rows: min-content;
7 | }
--------------------------------------------------------------------------------
/src/co/page/footer/index.jsx:
--------------------------------------------------------------------------------
1 | import s from './index.module.css'
2 | import links from '~config/links'
3 | import { Logo } from '~co/icon'
4 | import Button from '~co/button'
5 |
6 | export default function Footer() {
7 | return (
8 |
24 | )
25 | }
--------------------------------------------------------------------------------
/src/co/page/footer/index.module.css:
--------------------------------------------------------------------------------
1 | .footer {
2 | width: var(--page-width);
3 | margin: 0 auto;
4 |
5 | display: flex;
6 | align-items: center;
7 | flex-wrap: wrap;
8 |
9 | padding-top: calc(var(--padding-large) * 2);
10 | }
11 |
12 | .footer > *:not(:last-child) {
13 | margin-right: calc(var(--padding-regular) + var(--padding-small))
14 | }
15 |
16 | a.brand {
17 | flex: 1;
18 | color: var(--text-color-secondary)
19 | }
20 | .brand:hover {
21 | text-decoration: underline;
22 | }
23 |
24 | .site {
25 | font-size: var(--font-size-small);
26 | font-weight: 500;
27 | line-height: 1.5;
28 | display: block;
29 | }
30 |
31 | .desc {
32 | font-size: var(--font-size-micro);
33 | display: block;
34 | opacity: .7;
35 | }
--------------------------------------------------------------------------------
/src/co/page/header/index.jsx:
--------------------------------------------------------------------------------
1 | import s from './index.module.css'
2 | import { useState, useEffect } from 'react'
3 | import { Buttons } from '~co/button'
4 |
5 | export default {
6 | Wrap: function({ children }) {
7 | const [pinned, setPinned] = useState(false)
8 |
9 | useEffect(()=>{
10 | let pinned = false
11 | const onScroll = function(e) {
12 | const changed = window.scrollY > 10
13 |
14 | if (pinned != changed){
15 | pinned = changed
16 | setPinned(changed)
17 | }
18 | }
19 |
20 | onScroll()
21 |
22 | window.addEventListener('scroll', onScroll)
23 | return ()=>window.removeEventListener('scroll', onScroll)
24 | }, [])
25 |
26 | return (
27 |
30 |
31 | {children}
32 |
33 |
34 | )
35 | },
36 |
37 | Icon: function({ children }) {
38 | return (
39 |
40 | {children}
41 |
42 | )
43 | },
44 |
45 | Title: function({ children }) {
46 | return (
47 |
48 | {children}
49 |
50 | )
51 | },
52 |
53 | Buttons: function({ className='', ...etc }) {
54 | return (
55 |
56 | )
57 | }
58 | }
--------------------------------------------------------------------------------
/src/co/page/header/index.module.css:
--------------------------------------------------------------------------------
1 | .header {
2 | position: sticky;
3 | z-index: 99;
4 | top: 0;
5 |
6 | background: var(--background-color-regular);
7 | box-shadow: 0 -1px 0 var(--background-color-regular), 0 var(--line-size) 0 var(--line-color);
8 | }
9 |
10 | .header[data-pinned='false'] {
11 | box-shadow: none;
12 | }
13 | .header[data-pinned='false'] + [data-is-subheader] {
14 | position: relative;
15 | z-index: 100;
16 | }
17 |
18 | @supports (backdrop-filter: none) {
19 | .header {
20 | background: radial-gradient(circle, hsla(var(--background-hsl-regular), .8) 90%, var(--background-color-regular) 100%);
21 | backdrop-filter: blur(20px);
22 | }
23 | }
24 |
25 | .inner {
26 | width: var(--page-width);
27 | margin: 0 auto;
28 | padding: calc(var(--padding-regular) + var(--padding-small)) 0;
29 |
30 | display: flex;
31 | align-items: center;
32 | }
33 |
34 | .icon:not(:empty) {
35 | margin-right: calc(var(--padding-regular) + var(--padding-small))
36 | }
37 |
38 | .icon img {
39 | display: block;
40 | }
41 |
42 | .title {
43 | font-size: var(--font-size-h1);
44 | font-weight: 700;
45 |
46 | min-width: 0;
47 | margin: 0;
48 | padding: 0;
49 | white-space: nowrap;
50 | text-overflow: ellipsis;
51 | overflow: hidden;
52 | padding-right: var(--padding-regular);
53 | }
54 |
55 | .buttons {
56 | padding-left: var(--padding-regular);
57 | flex: 1;
58 | justify-content: flex-end;
59 | flex-wrap: nowrap !important;
60 | }
--------------------------------------------------------------------------------
/src/co/page/index.jsx:
--------------------------------------------------------------------------------
1 | import s from './index.module.css'
2 | import t from './theme.module.scss'
3 |
4 | import Helmet from 'react-helmet'
5 | import Header from './header'
6 | import Subheader from './subheader'
7 | import Content from './content'
8 | import Footer from './footer'
9 | import Pagination from './pagination'
10 | import Path from './path'
11 |
12 | export default {
13 | Wrap: function({ children, theme='auto', wide=false, embed=false, accentColor, className='' }) {
14 | return (
15 |
25 |
26 |
27 |
28 |
29 | {children}
30 |
31 | )
32 | },
33 |
34 | Header,
35 | Subheader,
36 | Content,
37 | Footer,
38 | Pagination,
39 | Path
40 | }
--------------------------------------------------------------------------------
/src/co/page/index.module.css:
--------------------------------------------------------------------------------
1 | .page {
2 | --page-width: 900px;
3 |
4 | line-height: 1.3;
5 | font-size: var(--font-size-regular);
6 | color: var(--text-color-regular);
7 |
8 | background: var(--background-color-regular);
9 | position: relative;
10 | width: 100%;
11 | padding: var(--padding-large) 0;
12 | min-height: 100vh;
13 |
14 | display: flex;
15 | flex-direction: column;
16 | }
17 |
18 | .page * {
19 | outline-color: var(--accent-color);
20 | }
21 |
22 | .page[data-wide='true'] {
23 | --page-width: calc(100% - var(--padding-large) * 6);
24 | }
25 |
26 | .page[data-embed='true'], .page[data-embed='true']:after {
27 | border-radius: var(--border-radius-regular);
28 | }
29 |
30 | .page[data-embed='true']:after {
31 | position: fixed;
32 | top: 0;
33 | bottom: 0;
34 | right: 0;
35 | left: 0;
36 | z-index: 999999999999;
37 | pointer-events: none;
38 | box-shadow: inset 0 0 0 var(--line-size) var(--line-color);
39 | content: '';
40 | }
41 |
42 | @media screen and (max-width: 1000px) {
43 | .page {
44 | padding-top: 0;
45 | --page-width: calc(100% - var(--padding-large) * 2) !important;
46 | }
47 | }
48 |
49 | .page a {
50 | text-decoration: none;
51 | }
--------------------------------------------------------------------------------
/src/co/page/pagination/index.jsx:
--------------------------------------------------------------------------------
1 | import s from './index.module.css'
2 | import { useEffect, useRef } from 'react'
3 |
4 | import { useLinkFactory } from '~modules/router'
5 | import Button from '~co/button'
6 | import Icon from '~co/icon'
7 |
8 | export default function Pagination({ count, perpage, force=false, ...etc }) {
9 | const getLink = useLinkFactory()
10 |
11 | const _pagesRef = useRef(null)
12 | const page = parseInt(etc.page)||0
13 | const pagesCount = Math.ceil(count/perpage)
14 |
15 | useEffect(()=>{
16 | const elem = document.getElementById(`page-${page}`)
17 | if (elem){
18 | _pagesRef.current.scrollLeft = elem.offsetLeft - (_pagesRef.current.clientWidth / 2)
19 | }
20 | }, [page])
21 |
22 | if (!force && pagesCount<=1)
23 | return null
24 |
25 | let pages = []
26 |
27 | if (pagesCount>1){
28 | let from = 0
29 | let to = Math.min(pagesCount, 100)
30 |
31 | for(var i=from;i<=to-1;i++)
32 | pages.push(
33 |
42 | )
43 | }
44 |
45 | return (
46 | 0}>
49 |
50 |
57 |
58 |
74 |
75 |
76 | )
77 | }
--------------------------------------------------------------------------------
/src/co/page/pagination/index.module.css:
--------------------------------------------------------------------------------
1 | .pagination {
2 | z-index: 99;
3 | bottom: 0;
4 |
5 | background: var(--background-color-regular);
6 | box-shadow: 0 1px 0 var(--background-color-regular), inset 0 var(--line-size) 0 rgba(0,0,0,.13);
7 | }
8 | .pagination[data-sticky='true'] {
9 | position: sticky;
10 | }
11 |
12 | @supports (backdrop-filter: none) {
13 | .pagination {
14 | background: radial-gradient(circle, hsla(var(--background-hsl-regular), .8) 90%, var(--background-color-regular) 100%);
15 | backdrop-filter: blur(20px);
16 | }
17 | }
18 |
19 | .inner {
20 | width: var(--page-width);
21 | margin: 0 auto;
22 | padding: var(--padding-large) 0;
23 |
24 | display: grid;
25 | grid-template-columns: 1fr auto;
26 | grid-gap: var(--padding-large);
27 | }
28 |
29 | .pages {
30 | display: grid;
31 | grid-auto-flow: column;
32 | grid-template-columns: var(--button-size-regular);
33 | grid-template-rows: 1fr;
34 | grid-gap: var(--padding-large);
35 | justify-content: flex-start;
36 | overflow: hidden;
37 | scroll-behavior: smooth;
38 | mask-image: linear-gradient(to right, white calc(100% - var(--button-size-regular)), transparent)
39 | }
40 |
41 | .page, .pages .space {
42 | width: var(--button-size-regular);
43 | justify-content: center;
44 | text-align: center;
45 | }
46 |
47 | .navigation {
48 | display: grid;
49 | grid-auto-flow: column;
50 | grid-gap: var(--padding-large);
51 | }
--------------------------------------------------------------------------------
/src/co/page/path/index.jsx:
--------------------------------------------------------------------------------
1 | import s from './index.module.css'
2 | import last from 'lodash-es/last'
3 | import Info from '~co/layout/info'
4 | import Button from '~co/button'
5 | import Icon from '~co/icon'
6 |
7 | export default {
8 | Wrap: function({ children }) {
9 | const backHref = (last(children) || {}).props?.href || ''
10 |
11 | return (
12 |
30 | )
31 | },
32 |
33 | Part: function(props) {
34 | return (
35 |
40 | )
41 | }
42 | }
--------------------------------------------------------------------------------
/src/co/page/path/index.module.css:
--------------------------------------------------------------------------------
1 | .path {
2 | width: var(--page-width);
3 | margin: 0 auto;
4 | margin-bottom: var(--padding-regular);
5 | }
6 |
7 | .back {
8 | float: left;
9 | margin-left: calc((var(--button-size-small) + var(--padding-large)) * -1);
10 | }
11 |
12 | @media screen and (max-width: 1000px) {
13 | .path {
14 | margin-top: var(--padding-large);
15 | }
16 | }
--------------------------------------------------------------------------------
/src/co/page/subheader/index.jsx:
--------------------------------------------------------------------------------
1 | import s from './index.module.css'
2 |
3 | export default function Subheader({ children }) {
4 | return (
5 |
8 | )
9 | }
--------------------------------------------------------------------------------
/src/co/page/subheader/index.module.css:
--------------------------------------------------------------------------------
1 | .subheader {
2 | width: var(--page-width);
3 | margin: 0 auto;
4 | display: grid;
5 | grid-auto-rows: min-content;
6 | grid-gap: var(--padding-large);
7 | padding-bottom: var(--padding-large);
8 | }
9 |
10 | .subheader:empty {
11 | display: none;
12 | }
13 |
14 | .subheader h2 {
15 | font-weight: normal;
16 | margin: 0;
17 | margin-top: calc(var(--padding-regular) * -1);
18 | padding: 0;
19 | color: var(--text-color-secondary);
20 | font-size: var(--font-size-h2);
21 | line-height: 145%;
22 | white-space: pre-wrap;
23 | }
--------------------------------------------------------------------------------
/src/co/page/theme.module.scss:
--------------------------------------------------------------------------------
1 | .theme {
2 | /* paddings */
3 | --padding-large: 18px;
4 | --padding-regular: 10px;
5 | --padding-small: 3px;
6 |
7 | /* fonts */
8 | --font-size-h1: 28px;
9 | --font-size-h2: 20px;
10 | --font-size-regular: 17px;
11 | --font-size-small: 15px;
12 | --font-size-micro: 13px;
13 |
14 | /* icons */
15 | --icon-size-xlarge: 64px;
16 | --icon-size-large: 32px;
17 | --icon-size-regular: 24px;
18 | --icon-size-small: 18px;
19 |
20 | /* buttons */
21 | --button-size-large: 48px;
22 | --button-size-regular: 32px;
23 | --button-size-small: 24px;
24 |
25 | /* etc */
26 | --border-radius-regular: 4px;
27 | --border-radius-large: 48px;
28 | --line-size: 1px;
29 | }
30 |
31 | /* Light theme */
32 | @mixin light() {
33 | /* colors */
34 | --background-hsl-regular: 0,0%,100%;
35 | --background-color-regular: hsl(var(--background-hsl-regular));
36 | --background-color-secondary: #F3F5F6;
37 | --background-color-disabled: rgba(0,0,0,.07);
38 |
39 | --accent-color: #3169FF;
40 | --text-color-regular: #1E1E1E;
41 | --text-color-secondary: #70767A;
42 | --text-color-disabled: rgba(0,0,0,.2);
43 | --line-color: rgba(0,0,0,.15);
44 | }
45 |
46 | .theme[data-theme='light'] {
47 | @include light();
48 | }
49 |
50 | @media (prefers-color-scheme: light) {
51 | .theme[data-theme='auto'] {
52 | @include light();
53 | }
54 | }
55 |
56 | /* Dark theme */
57 | @mixin dark() {
58 | /* colors */
59 | --background-hsl-regular: 0,0%,10%;
60 | --background-color-regular: hsl(var(--background-hsl-regular));
61 | --background-color-secondary: #222222;
62 | --background-color-disabled: rgba(255,255,255,.15);
63 |
64 | --accent-color: #3169FF;
65 | --text-color-regular: #E0E0E0;
66 | --text-color-secondary: #8A8F94;
67 | --text-color-disabled: rgba(0,0,0,.2);
68 | --line-color: rgba(255,255,255,.18);
69 | }
70 |
71 | .theme[data-theme='dark'] {
72 | @include dark();
73 | }
74 |
75 | @media (prefers-color-scheme: dark) {
76 | .theme[data-theme='auto'] {
77 | @include dark();
78 | }
79 | }
80 |
81 | /* Small screen */
82 | @media screen
83 | and (max-width: 1000px)
84 | and (min-device-width: 1000px) {
85 | .theme {
86 | /* paddings */
87 | --padding-large: 16px;
88 | --padding-regular: 8px;
89 | --padding-small: 4px;
90 |
91 | /* fonts */
92 | --font-size-h1: 20px;
93 | --font-size-h2: 17px;
94 | --font-size-regular: 15px;
95 | --font-size-small: 14px;
96 | --font-size-micro: 12px;
97 |
98 | /* icons */
99 | --icon-size-xlarge: 48px;
100 | --icon-size-large: 24px;
101 | --icon-size-regular: 18px;
102 | --icon-size-small: 14px;
103 |
104 | /* buttons */
105 | --button-size-large: 32px;
106 | --button-size-regular: 24px;
107 | --button-size-small: 18px;
108 |
109 | /* etc */
110 | --border-radius-regular: 3px;
111 | }
112 | }
113 |
114 | @media
115 | (-webkit-min-device-pixel-ratio: 2),
116 | (min-resolution: 192dpi) {
117 | .theme {
118 | --line-size: .5px;
119 | }
120 | }
--------------------------------------------------------------------------------
/src/co/picture/index.jsx:
--------------------------------------------------------------------------------
1 | import s from './index.module.css'
2 | import { THUMBNAILS_ENDPOINT } from '~config/api'
3 |
4 | export default function Image({ className='', src, width, height, ar, mode, alt, endpoint=THUMBNAILS_ENDPOINT }) {
5 | let link
6 |
7 | if (src)
8 | link = `${endpoint}/${src.includes(endpoint+'/') ? src.replace(endpoint+'/','') : encodeURIComponent(src)}?width=${width||''}&height=${height||''}&ar=${ar||''}&mode=${mode||''}`
9 |
10 | //aspect ratio
11 | let arStyle = {}
12 | if (ar) {
13 | const [a,b] = ar.split(':')
14 | arStyle = {
15 | aspectRatio: `${a}/${b}`,
16 | '--ar-w-fallback': (width ? width : (height/b*a))+'px',
17 | '--ar-h-fallback': (height ? height : (width/a*b))+'px',
18 | }
19 | }
20 |
21 | return (
22 |
25 |
26 |
27 |
28 |
31 |
32 | )
33 | }
--------------------------------------------------------------------------------
/src/co/picture/index.module.css:
--------------------------------------------------------------------------------
1 | .picture {
2 | display: inline-block;
3 | }
4 |
5 | @supports not (aspect-ratio: 1/1) {
6 | .picture {
7 | width: var(--ar-w-fallback);
8 | height: var(--ar-h-fallback);
9 | }
10 | }
11 |
12 | .picture img {
13 | display: block;
14 | width: 100%;
15 | height: 100%;
16 | }
--------------------------------------------------------------------------------
/src/co/popover/context.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | const Context = React.createContext({})
3 |
4 | export default Context
--------------------------------------------------------------------------------
/src/co/popover/index.jsx:
--------------------------------------------------------------------------------
1 | import s from './index.module.css'
2 | import React, { useState, useRef, useMemo, useEffect, useCallback } from 'react'
3 | import { PropTypes } from 'prop-types'
4 | import { Portal } from 'react-portal'
5 | import debounce from '~modules/format/callback/debounce'
6 |
7 | import Context from './context'
8 |
9 | //save mouse position
10 | let _mousePos = { x:-1, y:-1 }
11 | if (typeof document != 'undefined')
12 | document.documentElement.addEventListener('mousedown', function(e){
13 | _mousePos = { x: e.pageX, y: e.pageY }
14 | })
15 |
16 | function Popover({ pin, innerRef, className='', children, dataKey, closable=true, stretch=false, onClose, ...etc }) {
17 | const _container = useRef(null)
18 |
19 | const context = useMemo(()=>({
20 | close: ()=>{onClose && onClose()}
21 | }), [onClose])
22 |
23 | //position
24 | const [style, setStyle] = useState({ opacity: 0 })
25 |
26 | const place = useCallback(
27 | ()=>{
28 | if (!_container.current) return
29 |
30 | let y, x
31 |
32 | //use current mouse position
33 | y = _mousePos.y
34 | x = _mousePos.x
35 |
36 | //pin to active element
37 | if (pin && pin.current)
38 | try{
39 | const { left, top, height } = pin.current.getBoundingClientRect()
40 | y = top + height
41 | x = left
42 | }catch(e){}
43 |
44 | //prevent showing outside of viewport
45 | const { innerWidth, innerHeight } = window
46 | const { offsetWidth, offsetHeight } = _container.current
47 |
48 | if (x + offsetWidth > innerWidth)
49 | x = innerWidth - offsetWidth - 16
50 | if (x < 0)
51 | x = 16
52 |
53 | if (!stretch && y + offsetHeight > innerHeight)
54 | y = innerHeight - offsetHeight - 16
55 |
56 | if (y < 0)
57 | y = 16
58 |
59 | setStyle({
60 | opacity: 1,
61 | '--top': parseInt(y)+'px',
62 | '--left': parseInt(x)+'px'
63 | })
64 | },
65 | [_container, pin, stretch, setStyle]
66 | )
67 | const placeDebounced = useMemo(()=>
68 | debounce(place, 100, { leading: true, maxWait: 1000 }),
69 | [place]
70 | )
71 |
72 | //update position on some events
73 | useEffect(()=>{
74 | placeDebounced()
75 | }, [place, dataKey])
76 |
77 | //click outside
78 | useEffect(()=>{
79 | const onBodyMouseDown = e=>{
80 | if (!_container.current) return
81 | if (!closable) return
82 |
83 | if (!_container.current.contains(e.target))
84 | context.close()
85 | }
86 |
87 | setTimeout(()=>window.addEventListener('mousedown', onBodyMouseDown))
88 | return ()=>window.removeEventListener('mousedown', onBodyMouseDown)
89 | }, [_container, context, closable])
90 |
91 | //global hotkeys
92 | useEffect(()=>{
93 | const onWindowKeyDown = e=>{
94 | switch(e.key) {
95 | case 'Escape':
96 | e.preventDefault()
97 | e.stopPropagation()
98 | return context.close()
99 | }
100 | }
101 |
102 | window.addEventListener('keydown', onWindowKeyDown)
103 | return ()=>window.removeEventListener('keydown', onWindowKeyDown)
104 | }, [_container, context])
105 |
106 | //window scroll & resize
107 | useEffect(()=>{
108 | window.addEventListener('scroll', placeDebounced)
109 | window.addEventListener('resize', placeDebounced)
110 | return ()=>{
111 | window.removeEventListener('scroll', placeDebounced)
112 | window.removeEventListener('resize', placeDebounced)
113 | }
114 | }, [placeDebounced])
115 |
116 | if (innerRef)
117 | innerRef(_container)
118 |
119 | return (
120 |
121 |
122 |
129 |
130 | {children}
131 |
132 |
133 |
134 |
135 | )
136 | }
137 |
138 | Popover.propTypes = {
139 | pin: PropTypes.any,
140 | innerRef: PropTypes.any,
141 |
142 | className: PropTypes.string,
143 | dataKey: PropTypes.any,
144 |
145 | closable: PropTypes.bool,
146 | stretch: PropTypes.bool,
147 | onClose: PropTypes.func
148 | }
149 |
150 | export default Popover
--------------------------------------------------------------------------------
/src/co/popover/index.module.css:
--------------------------------------------------------------------------------
1 | .wrap {
2 | contain: content;
3 | z-index: 999;
4 | box-shadow: 0 10px 30px rgba(0,0,0,.15);
5 | border: 1px solid var(--line-color);
6 |
7 | border-radius: var(--border-radius-regular);
8 | position: fixed;
9 | overflow-y: auto;
10 | max-width: 90vw;
11 | max-height: 90vh;
12 |
13 | top: 0;
14 | left: 0;
15 | transform: translate3d(var(--left, var(--padding-regular)), var(--top, var(--padding-regular)), 0px);
16 | }
17 |
18 | .wrap[data-stretch='true'] {
19 | max-height: calc( 100vh - var(--top) - var(--padding-regular) );
20 | }
21 |
22 | .body {
23 | background: var(--background-color-regular);
24 | border-radius: var(--border-radius-regular);
25 | width: 100%;
26 | height: 100%;
27 | }
--------------------------------------------------------------------------------
/src/co/raindrops/listing/index.jsx:
--------------------------------------------------------------------------------
1 | import s from './index.module.css'
2 | import { useMemo } from 'react'
3 | import Single from '../single'
4 |
5 | export * from './useInfiniteScroll'
6 |
7 | export default function RaindropsListing({ target, items=[], collection, collections, user, options }) {
8 | const hide = useMemo(()=>[
9 | ...(options?.hide||'').split(',').map(h=>h.trim()),
10 | ...(user.config?.raindrops_hide||[])
11 | .filter(h=>h.startsWith(collection.view+'_'))
12 | .map(h=>h.replace(collection.view+'_', ''))
13 | ], [options?.hide, collection.view, user.config])
14 |
15 | const defaultTarget = useMemo(()=>
16 | user?.config?.raindrops_click == 'new_tab' ? '_blank' : '',
17 | [user?.config?.raindrops_click]
18 | )
19 |
20 | return (
21 |
22 |
23 | {items.map(item=>(
24 |
32 | ))}
33 |
34 |
35 | )
36 | }
--------------------------------------------------------------------------------
/src/co/raindrops/listing/index.module.css:
--------------------------------------------------------------------------------
1 | .listing {
2 | margin: 0 calc(var(--padding-large) * -1);
3 | --grid-item-width: 300px;
4 | }
5 |
6 | .grid .items {
7 | display: grid;
8 | grid-template-columns: repeat(auto-fill, minmax(min(50%, var(--grid-item-width)), 1fr));
9 | grid-template-rows: 1fr;
10 | }
11 |
12 | .grid, .masonry {
13 | overflow: hidden;
14 | }
15 |
16 | .grid .items, .masonry .items {
17 | margin-right: -1px;
18 | margin-bottom: -1px;
19 | }
20 |
21 | .masonry .items {
22 | columns: var(--grid-item-width);
23 | column-gap: 0;
24 | }
25 |
26 | @media screen and (max-width: 1000px) {
27 | .listing {
28 | --grid-item-width: 200px;
29 | }
30 | }
--------------------------------------------------------------------------------
/src/co/raindrops/listing/useInfiniteScroll.js:
--------------------------------------------------------------------------------
1 | import { useState, useEffect } from 'react'
2 | import Api from '~api'
3 |
4 | export function useInfiniteScroll(collection, raindrops, options) {
5 | const [items, setItems] = useState(()=>raindrops.items)
6 |
7 | useEffect(()=>{
8 | let loading = false
9 | let noMore = false
10 | let page = options.page || 0
11 |
12 | async function onScroll() {
13 | if (loading || noMore) return
14 | if (typeof window == 'undefined') return
15 | if (window.scrollY + window.innerHeight*2 < document.body.scrollHeight) return
16 |
17 | loading = true
18 | page++
19 |
20 | const { items } = await Api.raindrops.get(collection._id, {
21 | ...options,
22 | page
23 | })
24 |
25 | if (items.length)
26 | setItems(prev=>[
27 | ...prev,
28 | ...items
29 | ])
30 | else
31 | noMore = true
32 |
33 | loading = false
34 | }
35 |
36 | window.addEventListener('scroll', onScroll)
37 | window.addEventListener('resize', onScroll)
38 | return ()=>{
39 | window.removeEventListener('scroll', onScroll)
40 | window.removeEventListener('resize', onScroll)
41 | }
42 | }, [])
43 |
44 | return items
45 | }
--------------------------------------------------------------------------------
/src/co/raindrops/path/index.jsx:
--------------------------------------------------------------------------------
1 | import Page from '~co/page'
2 | import { Avatar } from '~co/icon'
3 | import Badge from '~co/badge'
4 | import { useParents } from '~co/collections/hooks'
5 |
6 | export default function RaindropsPath({ collections, collection, user, self }) {
7 | const parents = useParents(collections, collection, self)
8 |
9 | const path = [
10 |
15 | {!!user.avatar && }
16 |
17 | {user.name}
18 |
19 | {!!user.pro && (
20 | Pro
21 | )}
22 | ,
23 |
24 | ...parents.map(({ title, slug, _id })=>(
25 |
29 | {title}
30 |
31 | ))
32 | ]
33 |
34 | return (
35 |
36 | {path}
37 |
38 | )
39 | }
--------------------------------------------------------------------------------
/src/co/raindrops/single/add/index.jsx:
--------------------------------------------------------------------------------
1 | import s from './index.module.css'
2 | import links from '~config/links'
3 | import { useCallback } from 'react'
4 | import Button from '~co/button'
5 | import Icon from '~co/icon'
6 |
7 | export default function RaindropsSingleAdd({ item: { link, title } }) {
8 | const onClick = useCallback(e=>{
9 | e.preventDefault()
10 |
11 | const width = 420;
12 | const height = 600;
13 | const left = parseInt((screen.width/2)-(width/2));
14 | const top = parseInt((screen.height/2)-(height/2));
15 |
16 | window.open(e.currentTarget.href, '', `width=${width},height=${height},top=${top},left=${left},menubar=no,status=no,titlebar=no`)
17 | }, [])
18 |
19 | return (
20 |
32 | )
33 | }
--------------------------------------------------------------------------------
/src/co/raindrops/single/add/index.module.css:
--------------------------------------------------------------------------------
1 | .add {
2 | }
--------------------------------------------------------------------------------
/src/co/raindrops/single/cover/index.jsx:
--------------------------------------------------------------------------------
1 | import s from './index.module.css'
2 | import Picture from '~co/picture'
3 | import { FAVICON_ENDPOINT } from '~config/api'
4 |
5 | let sizes = {
6 | default: {
7 | width: 92,
8 | // height: 69,
9 | // mode: 'crop'
10 | },
11 | simple: {
12 | width: 24,
13 | height: 24
14 | },
15 | grid: {
16 | ar: '16:9',
17 | width: 350,
18 | mode: 'crop'
19 | },
20 | masonry: {
21 | width: 350
22 | }
23 | }
24 |
25 | export default function RaindropsCover({ className='', collection: { view }, item: { cover, link, domain, title } }) {
26 | return (
27 |
33 | )
34 | }
--------------------------------------------------------------------------------
/src/co/raindrops/single/cover/index.module.css:
--------------------------------------------------------------------------------
1 | .cover {
2 | position: relative;
3 | max-width: 100%;
4 | align-self: flex-start;
5 | color: transparent;
6 | }
7 |
8 | .cover img {
9 | object-fit: cover;
10 | border-radius: var(--border-radius-regular);
11 | overflow: hidden; /* useful when alt text is very long */
12 | }
13 |
14 | .cover img:after {
15 | content: '';
16 | display: block;
17 | position: absolute;
18 | top: 0;
19 | left: 0;
20 | width: 100%;
21 | height: 100%;
22 | background: var(--background-color-secondary);
23 | }
24 |
25 | .cover img:-moz-broken, .cover img:-moz-loading, .cover img:-moz-suppressed {
26 | background: var(--background-color-secondary)
27 | }
28 |
29 | .cover:not(.simple):after {
30 | content: '';
31 | position: absolute;
32 | left: 0;
33 | right: 0;
34 | bottom: 0;
35 | top: 0;
36 | border-radius: var(--border-radius-regular);
37 | box-shadow: inset 0 0 0 var(--line-size) var(--line-color);
38 | }
39 |
40 | .simple {
41 | width: var(--icon-size-regular) !important;
42 | height: var(--icon-size-regular) !important;
43 | }
44 |
45 | .grid, .masonry {
46 | width: 100% !important
47 | }
48 |
49 | @media screen and (max-width: 1000px) {
50 | .list {
51 | width: 68px !important;
52 | height: 51px !important;
53 | }
54 | }
--------------------------------------------------------------------------------
/src/co/raindrops/single/creator/index.jsx:
--------------------------------------------------------------------------------
1 | import { Avatar } from '~co/icon'
2 | import Button from '~co/button'
3 |
4 | export default function RaindropsSingleCreator({ item: { creatorRef }, user, target }) {
5 | if (!creatorRef ||
6 | creatorRef._id == user._id)
7 | return null
8 |
9 | return (
10 |
25 | )
26 | }
--------------------------------------------------------------------------------
/src/co/raindrops/single/highlights/highlight/index.jsx:
--------------------------------------------------------------------------------
1 | import s from './index.module.css'
2 |
3 | export default function RaindropsSingleHighlight({ className='', text, note, color }) {
4 | return (
5 |
6 |
7 | {text}
8 |
9 |
10 | {note ? (
11 |
12 | {note}
13 |
14 | ) : null}
15 |
16 | )
17 | }
--------------------------------------------------------------------------------
/src/co/raindrops/single/highlights/highlight/index.module.css:
--------------------------------------------------------------------------------
1 | .highlight {
2 | display: grid;
3 | grid-auto-flow: row;
4 | gap: var(--line-size);
5 | }
6 |
7 | .text, .note {
8 | padding-left: var(--padding-regular);
9 |
10 | display: -webkit-box;
11 | line-clamp: 5;
12 | -webkit-line-clamp: 5;
13 | -webkit-box-orient: vertical;
14 | white-space: pre;
15 | white-space: pre-wrap;
16 | overflow: hidden;
17 | }
18 |
19 | .text {
20 | position: relative;
21 | font-size: var(--font-size-regular)
22 | }
23 |
24 | .text:before {
25 | content: '';
26 | position: absolute;
27 | left: 0;top:1px;bottom:1px;
28 | width: 3px;
29 | border-radius: 3px;
30 | background: var(--highlight-color, #ffee00);
31 | background-image: linear-gradient(to bottom, rgba(255,255,255,.3) 0, rgba(255,255,255,.3) 100%);
32 | }
33 |
34 | .note {
35 | font-size: var(--font-size-small);
36 | color: var(--text-color-secondary)
37 | }
38 |
39 | .note * {
40 | vertical-align: -2px;
41 | }
--------------------------------------------------------------------------------
/src/co/raindrops/single/highlights/index.jsx:
--------------------------------------------------------------------------------
1 | import s from './index.module.css'
2 | import Item from './highlight'
3 |
4 | export default function RaindropsSingleHighlights({ className='', item: { highlights=[] } }) {
5 | if (!highlights.length)
6 | return null
7 |
8 | return (
9 |
10 | {highlights.map(item=>(
11 |
15 | ))}
16 |
17 | )
18 | }
--------------------------------------------------------------------------------
/src/co/raindrops/single/highlights/index.module.css:
--------------------------------------------------------------------------------
1 | .highlights {
2 | margin: calc(var(--padding-small) * 2) 0;
3 | box-shadow: inset 0 0 0 var(--line-size) var(--line-color);
4 | border-radius: var(--border-radius-regular);
5 | }
6 |
7 | .item {
8 | padding: var(--padding-regular)
9 | }
10 |
11 | .item:not(:last-child) {
12 | box-shadow: 0 var(--line-size) 0 var(--line-color);
13 | }
--------------------------------------------------------------------------------
/src/co/raindrops/single/important/index.jsx:
--------------------------------------------------------------------------------
1 | import Icon from '~co/icon'
2 | import Button from '~co/button'
3 | import { useFilterHref } from '~co/search/hooks'
4 |
5 | export default function RaindropsSingleImportant({ item: { important }, target }) {
6 | const href = useFilterHref('important:1')
7 |
8 | if (!important)
9 | return null
10 |
11 | return (
12 |
23 | )
24 | }
--------------------------------------------------------------------------------
/src/co/raindrops/single/index.jsx:
--------------------------------------------------------------------------------
1 | import s from './index.module.css'
2 | import { Buttons } from '~co/button'
3 | import { ShortDate } from '~modules/format/date'
4 | import Markdown from 'markdown-to-jsx'
5 |
6 | import Info from '~co/layout/info'
7 | import Cover from './cover'
8 | import Path from './path'
9 | import Tags from './tags'
10 | import Important from './important'
11 | import Creator from './creator'
12 | import Type from './type'
13 | import Add from './add'
14 | import Highlights from './highlights'
15 |
16 | export default function RaindropsSingle(props) {
17 | const { item, collection, target, options={} } = props
18 |
19 | return (
20 |
23 |
24 |
27 |
28 |
31 |
32 |
33 |
34 | {item.title}
35 |
36 |
37 | {(
38 | (item.note && !options.hide?.includes('note')) ||
39 | (item.excerpt && !options.hide?.includes('excerpt'))
40 | ) ? (
41 |
42 | {item.note ? {item.note} : item.excerpt}
43 |
44 | ) : null}
45 |
46 | {!options.hide?.includes('highlights') && (
47 |
48 | )}
49 |
50 |
51 |
52 |
53 | {!options.hide?.includes('info') && !!(collection._id && item.collection?.$id != collection._id) && (
54 |
55 | )}
56 |
57 | {!options.hide?.includes('tags') && (
58 |
59 | )}
60 |
61 |
62 | {!options.hide?.includes('info') && (
63 |
64 |
65 | {item.domain}
66 |
67 |
68 | )}
69 |
70 |
71 | {!options.hide?.includes('add') && (
72 |
73 |
74 |
75 | )}
76 |
77 |
78 |
83 | {item.title}
84 |
85 |
86 | )
87 | }
--------------------------------------------------------------------------------
/src/co/raindrops/single/index.module.css:
--------------------------------------------------------------------------------
1 | .single {
2 | position: relative;
3 | padding: 0 var(--padding-large);
4 | transition: background .1s linear;
5 | }
6 |
7 | .single:hover {
8 | background: var(--background-color-secondary);
9 | }
10 |
11 | .item {
12 | position: relative;
13 | grid-area: item;
14 | padding: var(--padding-large) 0;
15 | display: grid;
16 | grid-gap: var(--padding-large);
17 | }
18 |
19 | .cover {
20 | grid-area: cover;
21 | }
22 |
23 | .type {
24 | grid-area: cover;
25 | position: relative;
26 | }
27 |
28 | .about {
29 | flex: 1;
30 | display: grid;
31 | column-gap: var(--padding-small);
32 | row-gap: 1px;
33 | grid-template-areas: 'title' 'note' 'highlights' 'filters' 'info';
34 | grid-template-columns: 100%;
35 | grid-template-rows: min-content min-content 1fr min-content;
36 | }
37 |
38 | .title {
39 | font-weight: 600;
40 | grid-area: title;
41 | word-break: break-word;
42 | }
43 |
44 | .note {
45 | grid-area: note;
46 | min-width: 0;
47 | }
48 |
49 | .title, .note {
50 | display: block;
51 | word-wrap: break-word;
52 | /* display: -webkit-box;
53 | line-clamp: 5;
54 | -webkit-line-clamp: 5;
55 | -webkit-box-orient: vertical;
56 | overflow: hidden;
57 | white-space: pre;
58 | white-space: pre-wrap; */
59 | }
60 |
61 | .highlights {
62 | grid-area: highlights;
63 | }
64 |
65 | .filters {
66 | grid-area: filters;
67 | padding: calc(var(--padding-regular) - var(--padding-small)) 0;
68 | overflow: hidden;
69 | }
70 | .filters:empty {
71 | display: none;
72 | }
73 |
74 | .info {
75 | grid-area: info;
76 | align-self: flex-end;
77 | }
78 |
79 | .single a {
80 | position: relative;
81 | z-index: 2;
82 | }
83 |
84 | .important {
85 | color: var(--accent-color);
86 | vertical-align: -2px;
87 | margin-right: 4px;
88 | }
89 |
90 | .actions {
91 | opacity: 0;
92 | transition: opacity .1s linear;
93 | }
94 |
95 | .single:hover .actions,
96 | .actions:focus-within {
97 | opacity: 1;
98 | width: auto;
99 | transition-delay: .2s;
100 | background: var(--background-color-secondary);
101 | }
102 |
103 | a.permalink {
104 | position: absolute;
105 | left: 0;
106 | top: 0;
107 | right: 0;
108 | bottom: 0;
109 | z-index: 1;
110 | color: transparent;
111 | overflow: hidden;
112 | }
113 |
114 | /* list, simple */
115 | .list, .simple {
116 | border-radius: var(--border-radius-regular);
117 | }
118 |
119 | .list .item, .simple .item {
120 | grid-template-areas: 'cover item';
121 | grid-template-columns: min-content 1fr;
122 | grid-template-rows: 100%;
123 | }
124 |
125 | .list:not(:last-child) .item,
126 | .simple:not(:last-child) .item {
127 | box-shadow: inset 0 calc(var(--line-size) * -1) var(--line-color);
128 | }
129 |
130 | .list .actions,
131 | .simple .actions {
132 | position: absolute;
133 | right: 0;
134 | top: var(--padding-large)
135 | }
136 |
137 | .simple .note,
138 | .simple .type {
139 | display: none;
140 | }
141 |
142 | .single:hover .note {
143 | display: block; /*show full text when is truncated*/
144 | }
145 |
146 | /* grid, masonry */
147 | .grid {
148 | box-shadow: inset 0 calc(var(--line-size) * -1) var(--line-color), inset calc(var(--line-size) * -1) 0 var(--line-color);
149 | }
150 |
151 | .grid .item, .masonry .item {
152 | grid-template-areas: 'cover' 'item';
153 | grid-template-columns: 100%;
154 | grid-template-rows: min-content 1fr;
155 | }
156 |
157 | .grid .item {
158 | width: 100%;
159 | height: 100%;
160 | }
161 |
162 | .grid .actions, .masonry .actions {
163 | position: absolute;
164 | bottom: var(--padding-large);
165 | right: 0;
166 | }
167 |
168 | .masonry {
169 | display: block;
170 | page-break-inside: avoid;
171 | width: 100%;
172 | }
173 |
174 | /* Hide cover */
175 | [data-hide-cover="true"] .item {
176 | grid-template-areas: 'item';
177 | grid-template-columns: 1fr;
178 | }
179 | [data-hide-cover="true"] .cover {
180 | display: none;
181 | }
--------------------------------------------------------------------------------
/src/co/raindrops/single/path/index.jsx:
--------------------------------------------------------------------------------
1 | import s from './index.module.css'
2 | import Collection from '~co/collections/compact/single'
3 |
4 | export default function RaindropsSinglePath({ target, item, user, collections }) {
5 | const parent = (collections||[])
6 | .find(({_id})=>_id == item.collection?.$id)
7 |
8 | if (!parent)
9 | return null
10 |
11 | return (
12 |
18 | )
19 | }
--------------------------------------------------------------------------------
/src/co/raindrops/single/path/index.module.css:
--------------------------------------------------------------------------------
1 | .path {
2 | }
--------------------------------------------------------------------------------
/src/co/raindrops/single/tags/index.jsx:
--------------------------------------------------------------------------------
1 | import Tag from './tag'
2 |
3 | export default function RaindropsSingleTags({ target, item: { tags } }) {
4 | if (!(tags||[]).length)
5 | return null
6 |
7 | return tags.map(_id=>
8 |
12 | )
13 | }
--------------------------------------------------------------------------------
/src/co/raindrops/single/tags/tag/index.jsx:
--------------------------------------------------------------------------------
1 | import Button from '~co/button'
2 | import { useFilterHref } from '~co/search/hooks'
3 |
4 | export default function SearchTag({ _id, target }) {
5 | const href = useFilterHref('#'+_id)
6 |
7 | return (
8 |
14 | )
15 | }
--------------------------------------------------------------------------------
/src/co/raindrops/single/type/index.jsx:
--------------------------------------------------------------------------------
1 | import s from './index.module.css'
2 | import Icon from '~co/icon'
3 |
4 | export default function RaindropsSingleType({ className='', item: { type } }) {
5 | let iconName = ''
6 |
7 | switch (type) {
8 | case 'video': iconName = 'play'; break
9 | case 'document': iconName = 'file-text'; break
10 | case 'audio': iconName = 'headphone'; break
11 | }
12 |
13 | if (!iconName)
14 | return null
15 |
16 | return (
17 |
26 | )
27 | }
--------------------------------------------------------------------------------
/src/co/raindrops/single/type/index.module.css:
--------------------------------------------------------------------------------
1 | .type {
2 | display: flex;
3 | align-items: flex-start;
4 | justify-content: flex-end;
5 | padding: var(--padding-regular);
6 | }
7 |
8 | .bg {
9 | background: rgba(0,0,0,.25);
10 | backdrop-filter: blur(20px);
11 | padding: calc(var(--padding-small) * 2);
12 | border-radius: var(--border-radius-large);
13 | }
14 |
15 | .icon {
16 | display: block;
17 | color: rgba(255,255,255,.9);
18 | }
--------------------------------------------------------------------------------
/src/co/raindrops/sort/index.jsx:
--------------------------------------------------------------------------------
1 | import { useMemo, useCallback } from 'react'
2 | import { navigate } from 'vite-plugin-ssr/client/router'
3 |
4 | import { useLinkFactory } from '~modules/router'
5 | import { Select } from '~co/button'
6 | import Icon from '~co/icon'
7 |
8 | export default function RaindropsSort({ options={} }) {
9 | const getLink = useLinkFactory()
10 |
11 | const sort = options.sort
12 | const sorts = useMemo(()=>[
13 | { separator: true, label: 'Curator specified' },
14 | { value: '-sort', label: 'Custom sorting', dir: 'desc' },
15 |
16 | { separator: true, label: 'Date added' },
17 | { value: '-created', label: 'Newest', dir: 'desc' },
18 | { value: 'created', label: 'Oldest', dir: 'asc' },
19 |
20 | { separator: true, label: 'Name' },
21 | { value: 'title', label: 'A-Z', dir: 'desc' },
22 | { value: '-title', label: 'Z-A', dir: 'asc' },
23 |
24 | ...options.search ? [
25 | { separator: true, label: 'Search' },
26 | { value: 'score', label: 'By relevance', dir: 'desc' }
27 | ] : []
28 | ], [options])
29 |
30 | const onChange = useCallback(sort=>{
31 | navigate(getLink({ sort }))
32 | }, [getLink])
33 |
34 | return (
35 |
49 | )
50 | }
--------------------------------------------------------------------------------
/src/co/search/field/index.jsx:
--------------------------------------------------------------------------------
1 | import s from './index.module.css'
2 | import { useEffect, useState, useCallback, useRef } from 'react'
3 | import { navigate } from 'vite-plugin-ssr/client/router'
4 |
5 | import { useLinkFactory } from '~modules/router'
6 | import Icon, { ActivityIndicator } from '~co/icon'
7 | import Button from '~co/button'
8 |
9 | export default function SearchField({ value='', placeholder='Search' }) {
10 | const getLink = useLinkFactory()
11 |
12 | const input = useRef(null)
13 | const [search, setSearch] = useState(()=>value)
14 | const [loading, setLoading] = useState(false)
15 |
16 | //on search change
17 | useEffect(()=>{
18 | setSearch(value)
19 |
20 | if (input.current)
21 | input.current.focus()
22 | }, [value])
23 |
24 | //on submit
25 | const onFormSubmit = useCallback(e=>{
26 | e.preventDefault()
27 | navigate(getLink({ search }))
28 | }, [search])
29 |
30 | const onFormClick = useCallback(e=>{
31 | input.current.focus()
32 | }, [input])
33 |
34 | const onResetClick = useCallback(e=>{
35 | e.preventDefault()
36 | navigate(getLink({ search: '' }))
37 | }, [])
38 |
39 | return (
40 |
72 | )
73 | }
--------------------------------------------------------------------------------
/src/co/search/field/index.module.css:
--------------------------------------------------------------------------------
1 | .form {
2 | flex: 1;
3 | margin: var(--padding-small) 0;
4 | background: var(--background-color-secondary);
5 | border-radius: var(--border-radius-large);
6 | display: grid;
7 | grid-template-columns: min-content 1fr min-content;
8 | padding: 0 var(--padding-regular);
9 | grid-gap: var(--padding-regular);
10 | align-items: center;
11 | transition: box-shadow .2s ease-in-out, background .2s ease-in-out;
12 | }
13 | .form:focus-within {
14 | box-shadow: 0 0 0 1px var(--accent-color), inset 0 0 0 1px var(--accent-color);
15 | background: var(--background-color-regular);
16 | }
17 |
18 | .field {
19 | font-size: var(--font-size-h2);
20 | background: transparent;
21 | appearance: none;
22 | border: 0;
23 | padding: var(--padding-regular) 0;
24 | outline: none;
25 | }
26 |
27 | .magnifier {
28 | color: var(--text-color-secondary);
29 | }
--------------------------------------------------------------------------------
/src/co/search/hooks/index.jsx:
--------------------------------------------------------------------------------
1 | export * from './useFilterHref'
--------------------------------------------------------------------------------
/src/co/search/hooks/useFilterHref.jsx:
--------------------------------------------------------------------------------
1 | import { useLinkFactory } from '~modules/router'
2 |
3 | export function useFilterHref(filter) {
4 | const getLink = useLinkFactory()
5 |
6 | return getLink({
7 | search: filter.includes(' ') ? `"${filter}"` : filter
8 | })
9 | }
--------------------------------------------------------------------------------
/src/co/search/tags/index.jsx:
--------------------------------------------------------------------------------
1 | import s from './index.module.css'
2 | import { useState } from 'react'
3 | import sortBy from 'lodash-es/sortBy'
4 | import Tag from './tag'
5 | import Button, { Buttons } from '~co/button'
6 |
7 | const COLLAPSED_TAGS = 15
8 |
9 | export default function SearchTags({ tags }) {
10 | const [expandTags, setExpandTags] = useState(false)
11 |
12 | const sorted = sortBy(tags, ['_id'])
13 |
14 | if (!sorted.length)
15 | return null
16 |
17 | return (
18 |
19 | {sorted.slice(0, expandTags ? -1 : COLLAPSED_TAGS).map(tag=>(
20 |
23 | ))}
24 |
25 | {sorted.length > COLLAPSED_TAGS && (
26 |
29 | )}
30 |
31 | )
32 | }
--------------------------------------------------------------------------------
/src/co/search/tags/index.module.css:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/raindropio/pages/d012250750984f7559ff742dd5bcc6e297e8a9e1/src/co/search/tags/index.module.css
--------------------------------------------------------------------------------
/src/co/search/tags/tag/index.jsx:
--------------------------------------------------------------------------------
1 | import Button from '~co/button'
2 | import { useFilterHref } from '~co/search/hooks'
3 |
4 | export default function SearchTag({ _id }) {
5 | const href = useFilterHref('#'+_id)
6 |
7 | return (
8 |
13 | )
14 | }
--------------------------------------------------------------------------------
/src/config/api.js:
--------------------------------------------------------------------------------
1 | export const API_ENDPOINT = 'https://api.raindrop.io/v1'
2 | export const THUMBNAILS_ENDPOINT = 'https://rdl.ink/render'
3 | export const FAVICON_ENDPOINT = 'https://rdl.ink/favicon'
--------------------------------------------------------------------------------
/src/config/links.js:
--------------------------------------------------------------------------------
1 | export default {
2 | site: {
3 | index: import.meta.env.PROD ? 'https://raindrop.io' : 'http://dev.raindrop.io'
4 | },
5 | app: {
6 | index: 'https://app.raindrop.io'
7 | },
8 | help: {
9 | embed: 'https://help.raindrop.io/embed',
10 | publicPage: 'https://help.raindrop.io/public-page',
11 | search: 'https://help.raindrop.io/using-search'
12 | }
13 | }
--------------------------------------------------------------------------------
/src/config/raindrops.js:
--------------------------------------------------------------------------------
1 | export const RAINDROPS_PER_PAGE = 30
--------------------------------------------------------------------------------
/src/config/vendors.js:
--------------------------------------------------------------------------------
1 | export const FACEBOOK_APP_ID = '204807143019847'
2 | export const TWITTER_ID = 'raindrop_io'
3 | export const SENTRY_DSN = 'https://eb7b1f820adf4a3381467e60feaa6048@o199199.ingest.sentry.io/5736598'
--------------------------------------------------------------------------------
/src/modules/.DS_Store:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/raindropio/pages/d012250750984f7559ff742dd5bcc6e297e8a9e1/src/modules/.DS_Store
--------------------------------------------------------------------------------
/src/modules/async/index.js:
--------------------------------------------------------------------------------
1 | export * from './pause'
2 | export * from './timeout'
--------------------------------------------------------------------------------
/src/modules/async/pause.js:
--------------------------------------------------------------------------------
1 | export function pause (ms){
2 | return new Promise(res=>{
3 | setTimeout(() => {
4 | res(true)
5 | }, ms);
6 | })
7 | }
--------------------------------------------------------------------------------
/src/modules/async/timeout.js:
--------------------------------------------------------------------------------
1 | export function timeout(promise, ms) {
2 | let id;
3 | let timeout = new Promise((resolve) => {
4 | id = setTimeout(() => {resolve(true)}, ms)
5 | })
6 |
7 | return Promise.race([
8 | promise,
9 | timeout
10 | ]).then((result) => {
11 | clearTimeout(id)
12 | return result
13 | })
14 | }
--------------------------------------------------------------------------------
/src/modules/browser/copyText.js:
--------------------------------------------------------------------------------
1 | export async function copyText(text) {
2 | if ('permissions' in navigator == false)
3 | return
4 |
5 | await navigator.permissions.query({name: 'clipboard-write'})
6 | await navigator.clipboard.writeText(text)
7 | }
--------------------------------------------------------------------------------
/src/modules/browser/index.js:
--------------------------------------------------------------------------------
1 | export * from './copyText'
--------------------------------------------------------------------------------
/src/modules/format/callback/debounce.js:
--------------------------------------------------------------------------------
1 | const timers = new Map()
2 |
3 | export default function debounce(func, ms, options={}) {
4 | const { leading=false } = options
5 |
6 | return function(){
7 | let inProgress = false
8 | let timer = timers.get(func)
9 | if (timer){
10 | inProgress = true
11 | clearTimeout(timer)
12 | timers.delete(func)
13 | }
14 |
15 | timer = setTimeout(
16 | ()=>{
17 | func(...arguments)
18 | timers.delete(func)
19 | },
20 | leading && !inProgress ? 0 : ms
21 | )
22 | timers.set(func, timer)
23 | }
24 | }
--------------------------------------------------------------------------------
/src/modules/format/date/index.js:
--------------------------------------------------------------------------------
1 | export * from './parse'
2 | export * from './short'
--------------------------------------------------------------------------------
/src/modules/format/date/parse.js:
--------------------------------------------------------------------------------
1 | import parseISO from 'date-fns/parseISO'
2 |
3 | export const parseDate = (d) => typeof d == 'string' ? parseISO(d) : d
--------------------------------------------------------------------------------
/src/modules/format/date/short.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 |
3 | import isToday from 'date-fns/isToday'
4 | import isYesterday from 'date-fns/isYesterday'
5 | import format from 'date-fns/format'
6 | import formatRelative from 'date-fns/formatRelative'
7 | import { parseDate } from './parse'
8 |
9 | export const shortDate = (original) => {
10 | let d
11 | try{ d = parseDate(original) } catch(e){}
12 |
13 | try{
14 | if (isToday(d) || isYesterday(d))
15 | return formatRelative(d, Date.now())
16 | }catch(e){}
17 |
18 | try{
19 | return format(d, 'PP')
20 | }catch(e){}
21 |
22 | return ''
23 | }
24 |
25 | export const ShortDate = React.memo(
26 | function({ date }) {
27 | return
28 | }
29 | )
--------------------------------------------------------------------------------
/src/modules/format/url/domain.js:
--------------------------------------------------------------------------------
1 | export function compactDomain(domain) {
2 | try{
3 | const { hostname } = new URL(`https://${domain}`)
4 | const parts = hostname.split('.')
5 | return `${parts[parts.length-2]}.${parts[parts.length-1]}`
6 | } catch(e) {
7 | console.log(e)
8 | return 'unknown'
9 | }
10 | }
--------------------------------------------------------------------------------
/src/modules/format/url/index.js:
--------------------------------------------------------------------------------
1 | export * from './domain'
2 | export * from './query'
--------------------------------------------------------------------------------
/src/modules/format/url/query.js:
--------------------------------------------------------------------------------
1 | export function parseQueryParams(string) {
2 | const obj = Object.fromEntries(new URLSearchParams(string||''))
3 | for(const i in obj)
4 | try{
5 | //do not parse strings with " or '
6 | if (typeof obj[i] == 'string' && /^("|').+("|')$/.test(obj[i].trim()))
7 | obj[i] = obj[i]
8 | else
9 | obj[i] = JSON.parse(obj[i])
10 | }catch(e){}
11 |
12 | return obj
13 | }
--------------------------------------------------------------------------------
/src/modules/router/index.js:
--------------------------------------------------------------------------------
1 | export * from './linkFactory'
--------------------------------------------------------------------------------
/src/modules/router/linkFactory.js:
--------------------------------------------------------------------------------
1 | import { createContext, useContext } from 'react'
2 |
3 | /*
4 | Root component:
5 | 'url...'}
7 | */
8 | export const LinkFactory = createContext(()=>'')
9 |
10 | /*
11 | Use inside component:
12 | const getLink = useLinkFactory()
13 | getLink({ key: val })
14 | */
15 | export function useLinkFactory() {
16 | return useContext(LinkFactory)
17 | }
--------------------------------------------------------------------------------
/src/pages/_app/app.css:
--------------------------------------------------------------------------------
1 | html, body, #__next {
2 | width: 100%;
3 | height: 100%;
4 | }
5 |
6 | body {
7 | font-family: -apple-system,BlinkMacSystemFont,Segoe UI,Helvetica,Arial,sans-serif,Apple Color Emoji,Segoe UI Emoji;
8 | margin: 0;
9 | padding: 0;
10 | }
--------------------------------------------------------------------------------
/src/pages/_app/index.jsx:
--------------------------------------------------------------------------------
1 | import 'modern-normalize'
2 | import './app.css'
3 | import Helmet from 'react-helmet'
4 |
5 | export default function App({ children }) {
6 | return (
7 | <>
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 | {children}
17 |
18 |
23 | >
24 | )
25 | }
--------------------------------------------------------------------------------
/src/pages/_error/_error.page.jsx:
--------------------------------------------------------------------------------
1 | export function Page({ is404 }) {
2 | return (
3 | {is404 ? "404" : "500"} Error
4 | )
5 | }
--------------------------------------------------------------------------------
/src/pages/api/oembed/collection.js:
--------------------------------------------------------------------------------
1 | import Api from '~api'
2 | import links from '~config/links'
3 |
4 | const base = {
5 | success: true,
6 | version: '1.0',
7 | type: 'rich',
8 | provider_name: 'Raindrop.io',
9 | provider_url: links.site.index,
10 | height: 450
11 | }
12 |
13 | const regex = /^\/(.+)\/(.+)(\/|-)(\d+)/
14 |
15 | export function validateURL(url) {
16 | const { pathname } = new URL(url)
17 | return regex.test(pathname)
18 | }
19 |
20 | export function getHTML({ user, collection }, options={}) {
21 | const { height, ...etc } = options
22 |
23 | const url = `${links.site.index}/${user.name}/${collection.slug}-${collection._id}/embed`+(
24 | Object.keys(etc).length ? '/'+new URLSearchParams(etc) : ''
25 | )
26 |
27 | return (``)
32 | .replace(/\s+/g, ' ')
33 | }
34 |
35 | export default async function getJSON(url) {
36 | const [ pathname, user_name, slugOrSection, separator, id ] = new URL(url).pathname.match(regex)
37 |
38 | const [ collection, user ] = await Promise.all([
39 | Api.collection.get(id),
40 | Api.user.getByName(user_name),
41 | ])
42 |
43 | if (!collection || !user)
44 | return null
45 |
46 | return {
47 | ...base,
48 | title: collection.title,
49 | author_name: user_name,
50 | author_url: `${links.site.index}/${user_name}`,
51 | thumbnail_url: collection.cover?.length ?
52 | collection.cover[0] :
53 | `${import.meta.env.BASE_URL}icon_128.png`,
54 | thumbnail_width: 128,
55 | thumbnail_height: 128,
56 | cache_age: 3600,
57 | html: getHTML({ user, collection })
58 | }
59 | }
--------------------------------------------------------------------------------
/src/pages/api/oembed/index.page.js:
--------------------------------------------------------------------------------
1 | import * as collection from './collection'
2 | import * as user from './user'
3 |
4 | const providers = [
5 | collection,
6 | user
7 | ]
8 |
9 | export async function onBeforeRender({ url }) {
10 | const query = Object.fromEntries(new URL(url, 'http://localhost').searchParams)
11 | const destination = query.url
12 |
13 | let json
14 |
15 | for(const provider of providers){
16 | let valid = false
17 |
18 | try{ valid = provider.validateURL(destination) } catch(e) {}
19 |
20 | if (valid){
21 | json = await provider.default(destination)
22 | break
23 | }
24 | }
25 |
26 | if (json)
27 | return {
28 | pageContext: {
29 | json,
30 | headers: {
31 | 'Cache-Control': 'public,max-age=3600'
32 | }
33 | }
34 | }
35 |
36 | return {
37 | pageContext: {
38 | statusCode: 400,
39 | headers: {
40 | 'Cache-Control': 'public,max-age=3600'
41 | }
42 | }
43 | }
44 | }
45 |
46 | export default ()=>null
--------------------------------------------------------------------------------
/src/pages/api/oembed/user.js:
--------------------------------------------------------------------------------
1 | import Api from '~api'
2 | import links from '~config/links'
3 |
4 | const base = {
5 | success: true,
6 | version: '1.0',
7 | type: 'rich',
8 | provider_name: 'Raindrop.io',
9 | provider_url: links.site.index,
10 | height: 450
11 | }
12 |
13 | const regex = /^\/([a-zA-Z0-9][a-zA-Z0-9\-_]*)$/
14 |
15 | export function validateURL(url) {
16 | const { pathname } = new URL(url)
17 | return regex.test(pathname)
18 | }
19 |
20 | export function getHTML({ user }, options={}) {
21 | const { height, ...etc } = options
22 |
23 | const url = `${links.site.index}/${user.name}/embed/me`+(
24 | Object.keys(etc).length ? '/'+new URLSearchParams(etc) : ''
25 | )
26 |
27 | return (``)
32 | .replace(/\s+/g, ' ')
33 | }
34 |
35 | export default async function getJSON(url) {
36 | const [ pathname, user_name ] = new URL(url).pathname.match(regex)
37 |
38 | const user = await Api.user.getByName(user_name)
39 |
40 | if (!user)
41 | return null
42 |
43 | return {
44 | ...base,
45 | title: user.name+' bookmarks',
46 | thumbnail_url: user.avatar ?
47 | user.avatar :
48 | `${import.meta.env.BASE_URL}icon_128.png`,
49 | thumbnail_width: 128,
50 | thumbnail_height: 128,
51 | cache_age: 3600,
52 | html: getHTML({ user })
53 | }
54 | }
--------------------------------------------------------------------------------
/src/pages/api/ogimage/index.page.js:
--------------------------------------------------------------------------------
1 | export async function onBeforeRender({ url }) {
2 | const query = Object.fromEntries(new URL(url, 'http://localhost').searchParams)
3 | const destination = query.url || ''
4 |
5 | if (!destination.startsWith('https://raindrop.io'))
6 | return {
7 | pageContext: {
8 | statusCode: 404
9 | }
10 | }
11 |
12 | return {
13 | pageContext: {
14 | proxy: `https://rdl.ink/render/${destination}?width=1200&height=628`
15 | }
16 | }
17 | }
18 |
19 | export default ()=>null
--------------------------------------------------------------------------------
/src/pages/collection/embed/index.page.jsx:
--------------------------------------------------------------------------------
1 | import Helmet from 'react-helmet'
2 | import Api from '~api'
3 | import { RAINDROPS_PER_PAGE } from '~config/raindrops'
4 | import { parseQueryParams } from '~modules/format/url'
5 | import find from 'lodash-es/find'
6 |
7 | import Page from '~co/page'
8 | import { LinkFactory } from '~modules/router'
9 | import Button from '~co/button'
10 | import Icon, { Avatar } from '~co/icon'
11 | import CollectionCover from '~co/collections/cover'
12 | import Raindrops, { useInfiniteScroll } from '~co/raindrops/listing'
13 | import Toolbar from '~co/layout/toolbar'
14 |
15 | export async function onBeforeRender({ routeParams: { id, user_name, options } }) {
16 | const [ collections, user ] = await Promise.all([
17 | Api.collections.getByUserName(user_name),
18 | Api.user.getByName(user_name)
19 | ])
20 |
21 | const collection = find(collections, ['_id', parseInt(id)])
22 |
23 | //notFound: true doesn't refresh cached pages :( so instead do this:
24 | if (!collection || !user)
25 | return {
26 | pageContext: {
27 | statusCode: 404,
28 | headers: {
29 | 'Cache-Control': 'public,max-age=120'
30 | }
31 | }
32 | }
33 |
34 | const haveNested = collections.some(c=>c.parent?.$id == collection._id)
35 | options = parseQueryParams(options)
36 | options.sort = options.sort || (haveNested ? '-created' : '-sort')
37 | options.perpage = parseInt(options.perpage || RAINDROPS_PER_PAGE)
38 |
39 | const raindrops = await Api.raindrops.get(id, {
40 | ...options,
41 | nested: haveNested
42 | })
43 |
44 | return {
45 | pageContext: {
46 | pageProps: {
47 | collection,
48 | collections,
49 | raindrops,
50 | user,
51 | options
52 | },
53 | headers: {
54 | 'Cache-Control': 'public,max-age=60'
55 | }
56 | }
57 | }
58 | }
59 |
60 | export default function EmbedCollection({ statusCode, collection, collections, raindrops, user, options }) {
61 | if (statusCode)
62 | return null
63 |
64 | const baseUrl = `/${user.name}/${collection.slug}-${collection._id}`
65 | const items = useInfiniteScroll(collection, raindrops, options)
66 |
67 | return (
68 | baseUrl}>
69 |
74 |
75 |
76 |
77 |
78 | {!options['no-header'] && !options.hide?.includes('header') && (
79 |
80 |
81 |
84 |
85 |
86 | {collection.title}
87 |
88 |
89 |
95 |
96 | {!!user.avatar && (
97 |
107 | )}
108 |
109 |
110 | )}
111 |
112 |
113 | {!!options.search && !options['no-header'] && !options.hide?.includes('header') && (
114 |
115 | {options.search}
116 |
117 | )}
118 |
119 |
126 |
127 |
128 |
129 | )
130 | }
--------------------------------------------------------------------------------
/src/pages/collection/embed/index.page.route.js:
--------------------------------------------------------------------------------
1 | export default '/:user_name/:slug-:id/embed'
--------------------------------------------------------------------------------
/src/pages/collection/embed/options.page.jsx:
--------------------------------------------------------------------------------
1 | import Page from './index.page'
2 |
3 | export * from './index.page'
4 | export default Page
--------------------------------------------------------------------------------
/src/pages/collection/embed/options.page.route.js:
--------------------------------------------------------------------------------
1 | export default '/:user_name/:slug-:id/embed/:options'
--------------------------------------------------------------------------------
/src/pages/collection/empty-slug.page.js:
--------------------------------------------------------------------------------
1 | import links from '~config/links'
2 |
3 | export async function onBeforeRender({ routeParams: { user_name, id }, url }) {
4 | const { pathname } = new URL(url, 'http://localhost')
5 |
6 | return {
7 | pageContext: {
8 | redirect: `${links.site.index}${pathname.replace(`${user_name}/-${id}`, `${user_name}/a-${id}`)}`
9 | }
10 | }
11 | }
12 |
13 | export default null
--------------------------------------------------------------------------------
/src/pages/collection/empty-slug.page.route.js:
--------------------------------------------------------------------------------
1 | export default '/:user_name/-:id/*'
--------------------------------------------------------------------------------
/src/pages/collection/search/index.page.jsx:
--------------------------------------------------------------------------------
1 | import Helmet from 'react-helmet'
2 | import Api from '~api'
3 | import { RAINDROPS_PER_PAGE } from '~config/raindrops'
4 | import { parseQueryParams } from '~modules/format/url'
5 | import find from 'lodash-es/find'
6 |
7 | import Page from '~co/page'
8 | import { LinkFactory } from '~modules/router'
9 | import Button from '~co/button'
10 | import Icon from '~co/icon'
11 | import Raindrops from '~co/raindrops/listing'
12 | import Field from '~co/search/field'
13 | import Tags from '~co/search/tags'
14 | import Sort from '~co/raindrops/sort'
15 | import Path from '~co/raindrops/path'
16 | import Toolbar from '~co/layout/toolbar'
17 |
18 | export async function onBeforeRender({ routeParams: { id, user_name, options } }) {
19 | options = parseQueryParams(options)
20 | options.sort = options.sort || (options.search?.length ? 'score' : '-created')
21 | options.perpage = RAINDROPS_PER_PAGE
22 |
23 | const [ collections, raindrops, user, filters={} ] = await Promise.all([
24 | Api.collections.getByUserName(user_name),
25 | Api.raindrops.get(id, options),
26 | Api.user.getByName(user_name),
27 | (!options.page ? Api.filters.get(id, options) : undefined)
28 | ])
29 |
30 | const collection = find(collections, ['_id', parseInt(id)])
31 |
32 | if (!collection || !user)
33 | return {
34 | pageContext: {
35 | statusCode: 404,
36 | headers: {
37 | 'Cache-Control': 'public,max-age=60'
38 | }
39 | }
40 | }
41 |
42 | return {
43 | pageContext: {
44 | pageProps: {
45 | collection,
46 | collections,
47 | raindrops,
48 | filters,
49 | user,
50 | options
51 | },
52 | headers: {
53 | 'Cache-Control': 'public,max-age=20'
54 | }
55 | }
56 | }
57 | }
58 |
59 | export default function SearchScreen({ statusCode, collection, collections, raindrops, filters, user, options }) {
60 | if (statusCode)
61 | return null
62 |
63 | const collectionUrl = `/${user.name}/${collection.slug}-${collection._id}`
64 | const baseUrl = `${collectionUrl}/search`
65 |
66 | return (
67 | {
68 | const params = new URLSearchParams(options)
69 | params.set('page', 0)
70 |
71 | if (Object.keys(next))
72 | for(const [key, val] of Object.entries(next))
73 | switch(key) {
74 | case 'search':
75 | if (!(options.search||'').includes(val))
76 | params.set('search', options.search ? `${options.search.trim()} ${val}` : val)
77 | break
78 |
79 | default:
80 | params.set(key, val)
81 | break
82 | }
83 |
84 | return `${baseUrl}/${params.toString()}`
85 | }}>
86 |
89 |
90 | Search {collection.title}
91 | {!!collection.cover?.length && }
92 |
93 |
94 |
95 |
100 |
101 |
102 |
105 |
106 |
107 |
114 |
115 |
116 |
117 |
118 |
120 |
121 |
122 |
123 |
124 |
125 | {raindrops.count ? `Found ${raindrops.count} bookmarks` : 'Nothing found'}
126 |
127 |
128 | {!!raindrops.items.length && (
129 |
130 |
131 |
132 | )}
133 |
134 |
135 |
140 |
141 |
142 |
147 |
148 |
149 |
150 |
151 | )
152 | }
--------------------------------------------------------------------------------
/src/pages/collection/search/index.page.route.js:
--------------------------------------------------------------------------------
1 | export default '/:user_name/:slug-:id/search'
--------------------------------------------------------------------------------
/src/pages/collection/search/options.page.jsx:
--------------------------------------------------------------------------------
1 | import Page from './index.page'
2 |
3 | export * from './index.page'
4 | export default Page
--------------------------------------------------------------------------------
/src/pages/collection/search/options.page.route.js:
--------------------------------------------------------------------------------
1 | export default '/:user_name/:slug-:id/search/:options'
--------------------------------------------------------------------------------
/src/pages/collection/share/index.page.jsx:
--------------------------------------------------------------------------------
1 | import { useCallback, useMemo, useEffect, useState } from 'react'
2 | import Helmet from 'react-helmet'
3 | import Api from '~api'
4 | import { navigate } from 'vite-plugin-ssr/client/router'
5 | import { parseQueryParams } from '~modules/format/url'
6 | import { getHTML } from '~pages/api/oembed/collection'
7 | import { copyText } from '~modules/browser'
8 | import links from '~config/links'
9 |
10 | import Page from '~co/page'
11 | import Button, { Share, Buttons } from '~co/button'
12 | import Icon from '~co/icon'
13 | import Path from '~co/raindrops/path'
14 | import Toolbar from '~co/layout/toolbar'
15 | import Form, { Textarea, Label, Select, Input, Fields } from '~co/form'
16 |
17 | export async function onBeforeRender({ routeParams: { id, user_name, options } }) {
18 | options = parseQueryParams(options)
19 |
20 | const [ collection, user ] = await Promise.all([
21 | Api.collection.get(id),
22 | Api.user.getByName(user_name)
23 | ])
24 |
25 | if (!collection || !user)
26 | return {
27 | pageContext: {
28 | statusCode: 404,
29 | headers: {
30 | 'Cache-Control': 'public,max-age=60'
31 | }
32 | }
33 | }
34 |
35 | return {
36 | pageContext: {
37 | pageProps: {
38 | collection,
39 | user,
40 | options
41 | },
42 | headers: {
43 | 'Cache-Control': 'public,max-age=20'
44 | }
45 | }
46 | }
47 | }
48 |
49 | function PreviewDebounced({ html }) {
50 | const [load, setLoad] = useState(true)
51 |
52 | useEffect(()=>{
53 | setLoad(false)
54 | clearTimeout(window.__pdt)
55 | window.__pdt = setTimeout(() => {
56 | setLoad(true)
57 | window.__pdt = undefined
58 | }, 500)
59 | }, [html])
60 |
61 | if (!load)
62 | return null
63 |
64 | return
65 | }
66 |
67 | export default function ShareCollection({ statusCode, collection, user, options }) {
68 | if (statusCode)
69 | return null
70 |
71 | const baseUrl = `/${user.name}/${collection.slug}-${collection._id}/share`
72 | const canonicalUrl = `${links.site.index}/${user.name}/${collection.slug}-${collection._id}`
73 |
74 | //form
75 | const value = useMemo(
76 | ()=>({
77 | ...options,
78 | sort: options.sort || '',
79 | html: getHTML({ user, collection }, options)
80 | }),
81 | [options]
82 | )
83 |
84 | const onChange = useCallback(value=>{
85 | let { html, ...options } = value
86 |
87 | for(const i in options)
88 | if (!options[i])
89 | delete options[i]
90 |
91 | navigate(`${baseUrl}/${new URLSearchParams(options).toString()}`, { keepScrollPosition: true })
92 | }, [])
93 |
94 | return (
95 |
96 |
97 | Share {collection.title}
98 |
99 |
100 |
101 |
105 |
106 |
107 | Share {collection.title}
108 |
109 |
112 |
113 |
114 |
115 |
116 | Share this collection with your social community or embed to website or blog
117 |
118 |
119 |
120 |
121 | Export
122 |
123 |
124 |
129 |
130 |
131 |
132 | Embed
133 |
134 |
140 |
141 |
148 |
149 |
150 |
151 |
205 |
206 |
207 |
208 |
209 | )
210 | }
--------------------------------------------------------------------------------
/src/pages/collection/share/index.page.route.js:
--------------------------------------------------------------------------------
1 | export default '/:user_name/:slug-:id/share'
--------------------------------------------------------------------------------
/src/pages/collection/share/options.page.jsx:
--------------------------------------------------------------------------------
1 | import Page from './index.page'
2 |
3 | export * from './index.page'
4 | export default Page
--------------------------------------------------------------------------------
/src/pages/collection/share/options.page.route.js:
--------------------------------------------------------------------------------
1 | export default '/:user_name/:slug-:id/share/:options'
--------------------------------------------------------------------------------
/src/pages/collection/view/index.page.jsx:
--------------------------------------------------------------------------------
1 | import Helmet from 'react-helmet'
2 | import Api from '~api'
3 | import { RAINDROPS_PER_PAGE } from '~config/raindrops'
4 | import { parseQueryParams } from '~modules/format/url'
5 | import links from '~config/links'
6 | import find from 'lodash-es/find'
7 | import Markdown from 'markdown-to-jsx'
8 |
9 | import Page from '~co/page'
10 | import { LinkFactory } from '~modules/router'
11 | import Button from '~co/button'
12 | import Icon, { Logo } from '~co/icon'
13 | import CollectionCover from '~co/collections/cover'
14 | import Path from '~co/raindrops/path'
15 | import Raindrops from '~co/raindrops/listing'
16 | import Collections from '~co/collections/compact'
17 | import { useChildrens } from '~co/collections/hooks'
18 | import Toolbar from '~co/layout/toolbar'
19 | import Sort from '~co/raindrops/sort'
20 |
21 | export async function onBeforeRender({ routeParams: { id, user_name, options } }) {
22 | const [ collections, user ] = await Promise.all([
23 | Api.collections.getByUserName(user_name),
24 | Api.user.getByName(user_name)
25 | ])
26 |
27 | const collection = find(collections, ['_id', parseInt(id)])
28 |
29 | if (!collection || !user)
30 | return {
31 | pageContext: {
32 | statusCode: 404,
33 | headers: {
34 | 'Cache-Control': 'public,max-age=60'
35 | }
36 | }
37 | }
38 |
39 | const haveNested = collections.some(c=>c.parent?.$id == collection._id)
40 | options = parseQueryParams(options)
41 | options.sort = options.sort || (haveNested ? '-created' : '-sort')
42 | options.perpage = parseInt(options.perpage || RAINDROPS_PER_PAGE)
43 |
44 | const raindrops = await Api.raindrops.get(id, {
45 | ...options,
46 | nested: haveNested
47 | })
48 |
49 | return {
50 | pageContext: {
51 | pageProps: {
52 | collection,
53 | collections,
54 | raindrops,
55 | user,
56 | options
57 | },
58 | headers: {
59 | 'Cache-Control': 'public,max-age=20'
60 | }
61 | }
62 | }
63 | }
64 |
65 | export default function Collection({ statusCode, collection, collections, raindrops, user, options }) {
66 | if (statusCode)
67 | return null
68 |
69 | const baseUrl = `/${user.name}/${collection.slug}-${collection._id}`
70 | const fullUrl = `${links.site.index}${baseUrl}`
71 | const description = collection.description || `${raindrops.count} bookmarks`
72 |
73 | const childrens = useChildrens(collections, collection)
74 |
75 | return (
76 | {
77 | const params = new URLSearchParams(options)
78 | params.set('page', 0)
79 |
80 | if (Object.keys(next))
81 | for(const [key, val] of Object.entries(next))
82 | params.set(key, val)
83 |
84 | return `${baseUrl}${params.get('search')?'/search':''}/${params.toString()}`
85 | }}>
86 |
90 |
91 |
96 |
101 |
102 |
103 |
104 |
105 |
106 | {collection.title}
107 |
108 |
109 |
110 |
111 |
112 |
113 |
114 |
115 |
116 |
117 |
118 |
119 |
120 |
121 |
122 | {!!collection.cover?.length && }
123 |
124 |
125 |
129 |
130 |
131 |
132 |
136 |
137 |
138 | {collection.title}
139 |
140 |
141 |
147 |
148 |
155 |
156 |
162 |
163 |
164 |
165 |
166 | {!!collection.description && (
167 |
168 |
169 | {collection.description}
170 |
171 |
172 | )}
173 |
174 | {!parseInt(options.page) && (
175 |
178 | )}
179 |
180 |
181 |
182 |
183 |
184 | {options.search ? options.search : raindrops.count+' bookmarks'}
185 |
186 |
187 | {!!raindrops.items.length && (
188 |
189 |
191 |
192 | )}
193 |
194 |
195 |
200 |
201 |
202 |
206 |
207 |
208 |
209 |
210 | )
211 | }
--------------------------------------------------------------------------------
/src/pages/collection/view/index.page.route.js:
--------------------------------------------------------------------------------
1 | export default '/:user_name/:slug-:id'
--------------------------------------------------------------------------------
/src/pages/collection/view/options.page.jsx:
--------------------------------------------------------------------------------
1 | import Page from './index.page'
2 |
3 | export * from './index.page'
4 | export default Page
--------------------------------------------------------------------------------
/src/pages/collection/view/options.page.route.js:
--------------------------------------------------------------------------------
1 | export default '/:user_name/:slug-:id/:options'
--------------------------------------------------------------------------------
/src/pages/legacy/byId/collection.page.js:
--------------------------------------------------------------------------------
1 | import Api from '~api'
2 | import links from '~config/links'
3 |
4 | let cache = { }
5 |
6 | async function getUrl(id, { q, sort='' }) {
7 | const collection = cache[id] || await Api.collection.get(id)
8 | if (!collection) return null
9 |
10 | //get user name
11 | const user = await Api.user.getById(collection.user?.$id)
12 | if (!user) return null
13 | collection.user.name = user.name
14 |
15 | cache[id] = collection
16 |
17 | return `/${collection.user.name}/${collection.slug}-${collection._id}${q ? '/search' : ''}/${new URLSearchParams({
18 | sort,
19 | ...(q ? {
20 | search: q
21 | .split(',')
22 | .map(part=>{
23 | if (part.includes('word:'))
24 | return part.replace('word:', '')
25 |
26 | return part.includes(' ') ? `"${part.replace('tag:', '#')}"` : part.replace('tag:', '#')
27 | })
28 | .join(' ')
29 | } : {})
30 | })}`
31 | }
32 |
33 | export async function onBeforeRender({ routeParams: { id }, url }) {
34 | if (isNaN(id))
35 | return {
36 | pageContext: {
37 | statusCode: 404
38 | }
39 | }
40 |
41 | const destination = await getUrl(
42 | id,
43 | Object.fromEntries(new URL(url, 'http://localhost').searchParams),
44 | )
45 |
46 | if (!destination)
47 | return {
48 | pageContext: {
49 | statusCode: 404
50 | }
51 | }
52 |
53 | return {
54 | pageContext: {
55 | redirect: `${links.site.index}${destination}`
56 | }
57 | }
58 | }
59 |
60 | export default ()=>null
--------------------------------------------------------------------------------
/src/pages/legacy/byId/collection.page.route.js:
--------------------------------------------------------------------------------
1 | export default '/collection/:id'
--------------------------------------------------------------------------------
/src/pages/legacy/byId/user.page.js:
--------------------------------------------------------------------------------
1 | import Api from '~api'
2 | import links from '~config/links'
3 |
4 | const cache = {}
5 |
6 | async function getUrl(id) {
7 | if (cache[id]) return cache[id]
8 |
9 | const user = await Api.user.getById(id)
10 | if (!user) return cache[id]=null
11 |
12 | return cache[id]=`/${user.name}`
13 | }
14 |
15 | export async function onBeforeRender({ routeParams: { id } }) {
16 | if (isNaN(id))
17 | return {
18 | pageContext: {
19 | statusCode: 404
20 | }
21 | }
22 |
23 | const url = await getUrl(id)
24 | if (!url)
25 | return {
26 | pageContext: {
27 | statusCode: 404
28 | }
29 | }
30 |
31 | return {
32 | pageContext: {
33 | statusCode: 308,
34 | redirect: `${links.site.index}${url}`
35 | }
36 | }
37 | }
38 |
39 | export default ()=>null
--------------------------------------------------------------------------------
/src/pages/legacy/byId/user.page.route.js:
--------------------------------------------------------------------------------
1 | export default '/user/:id'
--------------------------------------------------------------------------------
/src/pages/legacy/obsolete/collection-id-section.page.js:
--------------------------------------------------------------------------------
1 | import links from '~config/links'
2 |
3 | export async function onBeforeRender({ routeParams: { user_name, section='', id, options='' } }) {
4 | return {
5 | pageContext: {
6 | redirect: `${links.site.index}/${user_name}/a-${id}${section && section!='view' ? `/${section}` : ''}${options ? '/'+options : ''}`,
7 | statusCode: 308
8 | }
9 | }
10 | }
11 |
12 | export default ()=>null
--------------------------------------------------------------------------------
/src/pages/legacy/obsolete/collection-id-section.page.route.js:
--------------------------------------------------------------------------------
1 | const parts = /\/(.*)\/(view|search|embed|share)\/(\d+)\/?(.*)/i
2 |
3 | export default ({ url }) => {
4 | const { pathname } = new URL(url, 'http://localhost')
5 | if (!parts.test(pathname))
6 | return false
7 |
8 | const [_, user_name, section='', id, options=''] = pathname.match(parts)
9 |
10 | return {
11 | routeParams: {
12 | user_name, section, id, options
13 | },
14 | precedence: 99
15 | }
16 | }
--------------------------------------------------------------------------------
/src/pages/legacy/obsolete/collection-view-options.page.js:
--------------------------------------------------------------------------------
1 | import Page from './collection-view.page'
2 |
3 | export * from './collection-view.page'
4 | export default Page
--------------------------------------------------------------------------------
/src/pages/legacy/obsolete/collection-view-options.page.route.js:
--------------------------------------------------------------------------------
1 | export default '/:user_name/:slug-:id/view/:options'
--------------------------------------------------------------------------------
/src/pages/legacy/obsolete/collection-view.page.js:
--------------------------------------------------------------------------------
1 | import links from '~config/links'
2 |
3 | export async function onBeforeRender({ routeParams: { user_name, slug, id, options='' } }) {
4 | return {
5 | pageContext: {
6 | redirect: `${links.site.index}/${user_name}/${slug}-${id}/${options}`,
7 | statusCode: 308
8 | }
9 | }
10 | }
11 |
12 | export default ()=>null
--------------------------------------------------------------------------------
/src/pages/legacy/obsolete/collection-view.page.route.js:
--------------------------------------------------------------------------------
1 | export default '/:user_name/:slug-:id/view'
--------------------------------------------------------------------------------
/src/pages/user/embed/index.page.jsx:
--------------------------------------------------------------------------------
1 | import Helmet from 'react-helmet'
2 | import Api from '~api'
3 | import { parseQueryParams } from '~modules/format/url'
4 |
5 | import Page from '~co/page'
6 | import Icon, { Avatar } from '~co/icon'
7 | import Button from '~co/button'
8 | import Badge from '~co/badge'
9 | import Collections from '~co/collections/listing'
10 | import { useRoot } from '~co/collections/hooks'
11 |
12 | export async function onBeforeRender({ routeParams: { user_name, options } }) {
13 | options = parseQueryParams(options)
14 |
15 | const [ user, collections ] = await Promise.all([
16 | Api.user.getByName(user_name),
17 | Api.collections.getByUserName(user_name)
18 | ])
19 |
20 | if (!user || !collections?.length)
21 | return {
22 | pageContext: {
23 | statusCode: 404,
24 | headers: {
25 | 'Cache-Control': 'public,max-age=120'
26 | }
27 | }
28 | }
29 |
30 | return {
31 | pageContext: {
32 | pageProps: {
33 | user,
34 | collections,
35 | options
36 | },
37 | headers: {
38 | 'Cache-Control': 'public,max-age=60'
39 | }
40 | }
41 | }
42 | }
43 |
44 | export default function EmbedUser({ statusCode, user, collections, options }) {
45 | if (statusCode)
46 | return null
47 |
48 | const root = useRoot(collections)
49 |
50 | return (
51 |
55 |
56 |
57 |
58 |
59 | {!options['no-header'] && !options.hide?.includes('header') && (
60 |
61 | {!!user.avatar && (
62 |
63 |
67 |
68 | )}
69 |
70 |
71 | {user.name}
72 |
73 |
74 | {!!user.pro && (
75 | Pro
76 | )}
77 |
78 |
79 |
85 |
86 |
87 | )}
88 |
89 |
90 |
94 |
95 |
96 | )
97 | }
--------------------------------------------------------------------------------
/src/pages/user/embed/index.page.route.js:
--------------------------------------------------------------------------------
1 | export default '/:user_name/embed/me'
--------------------------------------------------------------------------------
/src/pages/user/embed/options.page.jsx:
--------------------------------------------------------------------------------
1 | import Page from './index.page'
2 |
3 | export * from './index.page'
4 | export default Page
--------------------------------------------------------------------------------
/src/pages/user/embed/options.page.route.js:
--------------------------------------------------------------------------------
1 | export default '/:user_name/embed/me/:options'
--------------------------------------------------------------------------------
/src/pages/user/home/index.page.jsx:
--------------------------------------------------------------------------------
1 | import Api from '~api'
2 | import links from '~config/links'
3 | import Helmet from 'react-helmet'
4 | import Page from '~co/page'
5 | import Icon, { Logo, Avatar } from '~co/icon'
6 | import Button from '~co/button'
7 | import Info from '~co/layout/info'
8 | import { ShortDate } from '~modules/format/date'
9 | import Badge from '~co/badge'
10 | import Collections from '~co/collections/listing'
11 | import { useRoot } from '~co/collections/hooks'
12 | import Toolbar from '~co/layout/toolbar'
13 |
14 | export async function onBeforeRender({ routeParams: { user_name } }) {
15 | const [ user, collections ] = await Promise.all([
16 | Api.user.getByName(user_name),
17 | Api.collections.getByUserName(user_name)
18 | ])
19 |
20 | if (!user || !collections?.length)
21 | return {
22 | pageContext: {
23 | statusCode: 404,
24 | headers: {
25 | 'Cache-Control': 'public,max-age=60'
26 | }
27 | }
28 | }
29 |
30 | return {
31 | pageContext: {
32 | pageProps: {
33 | user,
34 | collections
35 | },
36 | headers: {
37 | 'Cache-Control': 'public,max-age=20'
38 | }
39 | }
40 | }
41 | }
42 |
43 | export default function UserIndex({ statusCode, user, collections }) {
44 | if (statusCode)
45 | return null
46 |
47 | const url = `${links.site.index}/${user.name}`
48 |
49 | const root = useRoot(collections)
50 |
51 | return (
52 |
53 |
54 |
55 |
56 | {user.name}
57 |
58 |
59 |
60 |
61 |
62 |
63 | {!!user.avatar && }
64 |
65 |
66 |
67 |
68 |
69 | {!!user.avatar && (
70 |
71 |
75 |
76 | )}
77 |
78 |
79 | {user.name}
80 |
81 |
82 | {!!user.pro && (
83 | Pro
84 | )}
85 |
86 |
87 |
93 |
94 |
100 |
101 |
102 |
103 |
104 |
105 | Member since
106 |
107 | {!!user.twitter?.screen_name && (
108 |
117 | )}
118 |
119 | {!!user.facebook?.screen_name && (
120 |
129 | )}
130 |
131 |
132 |
133 |
134 |
135 |
136 | {root.length} public collections
137 |
138 |
139 |
140 |
143 |
144 |
145 |
146 |
147 | )
148 | }
--------------------------------------------------------------------------------
/src/pages/user/home/index.page.route.js:
--------------------------------------------------------------------------------
1 | export default '/:user_name'
--------------------------------------------------------------------------------
/src/pages/user/share/index.page.jsx:
--------------------------------------------------------------------------------
1 | import { useCallback, useMemo } from 'react'
2 | import Helmet from 'react-helmet'
3 | import Api from '~api'
4 | import { navigate } from 'vite-plugin-ssr/client/router'
5 | import { parseQueryParams } from '~modules/format/url'
6 | import { getHTML } from '~pages/api/oembed/user'
7 | import { copyText } from '~modules/browser'
8 | import links from '~config/links'
9 |
10 | import Page from '~co/page'
11 | import Button, { Share } from '~co/button'
12 | import Icon from '~co/icon'
13 | import Path from '~co/raindrops/path'
14 | import Toolbar from '~co/layout/toolbar'
15 | import Form, { Textarea, Label, Checkbox, Fields, Select } from '~co/form'
16 |
17 | export async function onBeforeRender({ routeParams: { user_name, options } }) {
18 | options = parseQueryParams(options)
19 |
20 | const user = await Api.user.getByName(user_name)
21 |
22 | if (!user)
23 | return {
24 | pageContext: {
25 | statusCode: 404,
26 | headers: {
27 | 'Cache-Control': 'public,max-age=60'
28 | }
29 | }
30 | }
31 |
32 | return {
33 | pageContext: {
34 | pageProps: {
35 | user,
36 | options
37 | },
38 | headers: {
39 | 'Cache-Control': 'public,max-age=20'
40 | }
41 | }
42 | }
43 | }
44 |
45 | export default function ShareUser({ statusCode, user, options }) {
46 | if (statusCode)
47 | return null
48 |
49 | const baseUrl = `/${user.name}/share/me`
50 | const canonicalUrl = `${links.site.index}/${user.name}`
51 |
52 | //form
53 | const value = useMemo(
54 | ()=>({
55 | ...options,
56 | html: getHTML({ user }, options)
57 | }),
58 | [options]
59 | )
60 |
61 | const onChange = useCallback(value=>{
62 | let { html, ...options } = value
63 |
64 | for(const i in options)
65 | if (!options[i])
66 | delete options[i]
67 |
68 | navigate(`${baseUrl}/${new URLSearchParams(options).toString()}`, { keepScrollPosition: true })
69 | }, [])
70 |
71 | return (
72 |
73 |
74 | Share {user.name}
75 |
76 |
77 |
78 |
79 |
80 |
81 | Share User Profile
82 |
83 |
86 |
87 |
88 |
89 |
90 | Share your collections with social community or embed to website or blog
91 |
92 |
93 |
94 |
95 | Embed
96 |
97 |
104 |
105 |
106 |
107 |
131 |
132 |
133 |
134 |
135 | )
136 | }
--------------------------------------------------------------------------------
/src/pages/user/share/index.page.route.js:
--------------------------------------------------------------------------------
1 | export default '/:user_name/share/me'
--------------------------------------------------------------------------------
/src/pages/user/share/options.page.jsx:
--------------------------------------------------------------------------------
1 | import Page from './index.page'
2 |
3 | export * from './index.page'
4 | export default Page
--------------------------------------------------------------------------------
/src/pages/user/share/options.page.route.js:
--------------------------------------------------------------------------------
1 | export default '/:user_name/share/me/:options'
--------------------------------------------------------------------------------
/src/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/raindropio/pages/d012250750984f7559ff742dd5bcc6e297e8a9e1/src/public/favicon.ico
--------------------------------------------------------------------------------
/src/public/icon_128.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/raindropio/pages/d012250750984f7559ff742dd5bcc6e297e8a9e1/src/public/icon_128.png
--------------------------------------------------------------------------------
/src/renderer/_default.page.client.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { hydrate, render } from 'react-dom'
3 | import { useClientRouter } from 'vite-plugin-ssr/client/router'
4 |
5 | import App from '~pages/_app'
6 |
7 | useClientRouter({
8 | async render({ Page, pageProps, statusCode, isHydration }) {
9 | (isHydration ? hydrate : render)(
10 |
11 |
14 | ,
15 | document.getElementById('content')
16 | )
17 | },
18 | ensureHydration: true,
19 | prefetch: true,
20 | onTransitionStart: ()=>{},
21 | onTransitionEnd: ()=>{}
22 | })
--------------------------------------------------------------------------------
/src/renderer/_default.page.server.jsx:
--------------------------------------------------------------------------------
1 | import ReactDOMServer from 'react-dom/server'
2 | import React from 'react'
3 | import { escapeInject, dangerouslySkipEscape } from 'vite-plugin-ssr'
4 | import Helmet from 'react-helmet'
5 |
6 | import App from '~pages/_app'
7 |
8 | export const passToClient = ['pageProps', 'statusCode']
9 |
10 | export function render({ Page, pageProps={}, statusCode, headers={}, redirect, json, proxy }) {
11 | var documentHtml = null
12 |
13 | if (Page) {
14 | const content = ReactDOMServer.renderToString(
15 |
16 |
19 |
20 | )
21 |
22 | const helmet = Helmet.renderStatic()
23 |
24 | documentHtml = escapeInject`
25 |
26 |
27 |
28 | ${dangerouslySkipEscape(
29 | helmet.title.toString() +
30 | helmet.meta.toString() +
31 | helmet.link.toString()
32 | )}
33 |
34 |
35 | ${dangerouslySkipEscape(content)}
36 |
37 |
38 | `
39 | }
40 |
41 | return {
42 | documentHtml,
43 | pageContext: {
44 | statusCode,
45 | headers: {
46 | 'Content-Security-Policy': `
47 | default-src *;
48 | script-src 'self' https://*.raindrop.io https://*.sentry.io https://sentry.io ${import.meta.env.DEV ? '\'unsafe-inline\' \'unsafe-eval\'' : ''};
49 | style-src 'self' 'unsafe-inline' https://*.raindrop.io;
50 | img-src * blob:;
51 | object-src 'self' up.raindrop.io;
52 | `.replace(/\s+/g, ' '),
53 |
54 | 'X-Content-Type-Options': 'nosniff',
55 |
56 | ...headers,
57 | },
58 | redirect,
59 | json,
60 | proxy
61 | }
62 | }
63 | }
--------------------------------------------------------------------------------
/vite.config.js:
--------------------------------------------------------------------------------
1 | import path from 'path'
2 | import react from '@vitejs/plugin-react'
3 | import ssr from 'vite-plugin-ssr/plugin'
4 | import svgrPlugin from 'vite-plugin-svgr'
5 |
6 | const src = path.resolve(__dirname, 'src')
7 |
8 | export default {
9 | root: src,
10 | build: {
11 | outDir: '../dist',
12 | assetsDir: '__pages_assets__'
13 | },
14 | resolve: {
15 | alias: [{
16 | find: /^~(.*)/,
17 | replacement: `${src}/$1`
18 | }]
19 | },
20 | plugins: [react(), ssr(), svgrPlugin()]
21 | }
--------------------------------------------------------------------------------
/worker/index.js:
--------------------------------------------------------------------------------
1 | import { handleSsr } from './ssr'
2 | import { handleStaticAssets } from './static-assets'
3 |
4 | addEventListener('fetch', (event) => {
5 | try {
6 | event.respondWith(
7 | handleFetchEvent(event).catch((err) => {
8 | console.error(err.stack)
9 | })
10 | )
11 | } catch (err) {
12 | console.error(err.stack)
13 | event.respondWith(new Response('Internal Error', { status: 500 }))
14 | }
15 | })
16 |
17 | async function handleFetchEvent(event) {
18 | if (!isAssetUrl(event.request.url)) {
19 | //check cached
20 | let response = await caches.default.match(event.request)
21 |
22 | if (!response) {
23 | response = await handleSsr(
24 | new URL(event.request.url).toString() //be sure to wrap it in URL(), otherwise some urls fail
25 | )
26 |
27 | //save to cache
28 | if (response && response.ok && response.headers.get('cache-control'))
29 | event.waitUntil(caches.default.put(event.request, response.clone()))
30 | }
31 |
32 | if (response !== null) return response
33 | }
34 |
35 | const response = await handleStaticAssets(event)
36 | return response
37 | }
38 |
39 | function isAssetUrl(url) {
40 | const { pathname } = new URL(url)
41 | return pathname.startsWith('/__pages_assets__/')
42 | }
--------------------------------------------------------------------------------
/worker/ssr.js:
--------------------------------------------------------------------------------
1 | import { createPageRenderer } from 'vite-plugin-ssr'
2 | // `importBuild.js` enables us to bundle our worker code into a single file, see https://vite-plugin-ssr.com/cloudflare-workers and https://vite-plugin-ssr.com/importBuild.js
3 | import '../dist/server/importBuild.js'
4 |
5 | const renderPage = createPageRenderer({ isProduction: true })
6 |
7 | export async function handleSsr(url) {
8 | const { httpResponse, statusCode, headers={}, redirect, proxy, json } = await renderPage({ url })
9 |
10 | if (redirect) {
11 | return Response.redirect(redirect, statusCode||302)
12 | } else if (proxy)
13 | return fetch(proxy)
14 | else if (json)
15 | return new Response(JSON.stringify(json), {
16 | status: statusCode || 200,
17 | headers
18 | })
19 | else if (!httpResponse)
20 | return null
21 | else
22 | return new Response(
23 | httpResponse.body, {
24 | headers: {
25 | 'content-type': httpResponse.contentType,
26 | ...headers
27 | },
28 | status: statusCode || httpResponse.statusCode,
29 | }
30 | )
31 | }
--------------------------------------------------------------------------------
/worker/static-assets.js:
--------------------------------------------------------------------------------
1 | import { getAssetFromKV, NotFoundError } from '@cloudflare/kv-asset-handler'
2 |
3 | const isProd = typeof STAGE == 'string' && STAGE == 'production'
4 |
5 | export async function handleStaticAssets(event) {
6 | let options = {}
7 |
8 | try {
9 | options.cacheControl = {
10 | bypassCache: !isProd,
11 | browserTTL: 365 * 60 * 60 * 24, // 365 days
12 | edgeTTL: 365 * 60 * 60 * 24, // 365 days
13 | }
14 |
15 | return getAssetFromKV(event, options)
16 | } catch (e) {
17 | if (e instanceof NotFoundError)
18 | return new Response('Not found', { status: 500 })
19 |
20 | return new Response(
21 | isProd ? 'Server error' : e.message || e.toString(),
22 | { status: 500 }
23 | )
24 | }
25 | }
--------------------------------------------------------------------------------
/worker/webpack.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | entry: './worker/index.js',
3 | target: 'webworker',
4 | resolve: {
5 | mainFields: ['main', 'module'],
6 | alias: {}
7 | },
8 | node: {
9 | fs: 'empty'
10 | }
11 | }
--------------------------------------------------------------------------------
/wrangler.toml:
--------------------------------------------------------------------------------
1 | name = "pages"
2 | type = "webpack"
3 |
4 | account_id = "6f3fae9cff251a89f334f9debec204f1"
5 | zone_id = "797f19d9d9c9a1f63f76c73f54f89c98"
6 | workers_dev = true
7 |
8 | webpack_config = "./worker/webpack.config.js"
9 | compatibility_date = "2021-09-29"
10 |
11 | [site]
12 | bucket = "./dist/client"
13 | entry-point = "./worker"
14 |
15 | [env.production]
16 | name = "pages-prod"
17 | routes = ["https://pub.raindrop.io/*"]
18 | workers_dev = false
19 | vars = { STAGE = "production" }
--------------------------------------------------------------------------------