goodbye astro, i18n
This commit is contained in:
parent
124b43d667
commit
f5272383c2
|
@ -2,7 +2,5 @@ node_modules
|
||||||
public
|
public
|
||||||
*.cjs
|
*.cjs
|
||||||
src/graphql/*.gen.ts
|
src/graphql/*.gen.ts
|
||||||
src/legacy_*
|
|
||||||
src/components/EditorExample
|
|
||||||
dist/
|
dist/
|
||||||
.vercel/
|
.vercel/
|
||||||
|
|
2
.gitignore
vendored
2
.gitignore
vendored
|
@ -12,3 +12,5 @@ pnpm-debug.log*
|
||||||
public/upload/*
|
public/upload/*
|
||||||
src/graphql/introspec.gen.ts
|
src/graphql/introspec.gen.ts
|
||||||
stats.html
|
stats.html
|
||||||
|
*.scss.d.ts
|
||||||
|
pnpm-lock.yaml
|
||||||
|
|
2
.npmrc
2
.npmrc
|
@ -1,2 +0,0 @@
|
||||||
# Expose Astro dependencies for `pnpm` users
|
|
||||||
shamefully-hoist=true
|
|
|
@ -1,5 +1,14 @@
|
||||||
|
[0.8.0]
|
||||||
|
[+] i18next for ,solid
|
||||||
|
[-] i18n
|
||||||
|
[+] custom snackbar
|
||||||
|
[+] editor lazy load
|
||||||
|
[+] hygen
|
||||||
|
[-] astro removed
|
||||||
|
[+] vite ssr plugin
|
||||||
|
|
||||||
[0.7.1]
|
[0.7.1]
|
||||||
[+] reactions CRUL
|
[+] reactions CUDL
|
||||||
[+] api/upload with storj
|
[+] api/upload with storj
|
||||||
[+] api/feedback
|
[+] api/feedback
|
||||||
[+] bumped astro pkgs versions
|
[+] bumped astro pkgs versions
|
||||||
|
|
2
LICENSE
2
LICENSE
|
@ -1,6 +1,6 @@
|
||||||
MIT License
|
MIT License
|
||||||
|
|
||||||
Copyright (c) 2021-2022 Discours
|
Copyright (c) 2021-2023 Discours
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
const MG = require('mailgun.js')
|
const MG = require('mailgun.js')
|
||||||
const fd = require('form-data')
|
const fd = require('form-data')
|
||||||
|
const mailgun = new MG(fd)
|
||||||
|
|
||||||
const mgOptions = {
|
const mgOptions = {
|
||||||
key: process.env.MAILGUN_API_KEY,
|
key: process.env.MAILGUN_API_KEY,
|
||||||
|
@ -7,6 +8,8 @@ const mgOptions = {
|
||||||
username: 'discoursio' // FIXME
|
username: 'discoursio' // FIXME
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const client = mailgun.client(mgOptions)
|
||||||
|
|
||||||
const messageData = (subject, text) => {
|
const messageData = (subject, text) => {
|
||||||
return {
|
return {
|
||||||
from: 'Discours Feedback Robot <robot@discours.io>',
|
from: 'Discours Feedback Robot <robot@discours.io>',
|
||||||
|
@ -18,8 +21,6 @@ const messageData = (subject, text) => {
|
||||||
export default async function handler(req, res) {
|
export default async function handler(req, res) {
|
||||||
const { contact, subject, message } = req.query
|
const { contact, subject, message } = req.query
|
||||||
try {
|
try {
|
||||||
const mailgun = new MG(fd)
|
|
||||||
const client = mailgun.client(mgOptions)
|
|
||||||
const data = messageData(`${contact}: ${subject}`, message)
|
const data = messageData(`${contact}: ${subject}`, message)
|
||||||
client.messages.create(mgOptions.domain, data).then(console.log).catch(console.error)
|
client.messages.create(mgOptions.domain, data).then(console.log).catch(console.error)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
const MG = require('mailgun.js')
|
const MG = require('mailgun.js')
|
||||||
const fd = require('form-data')
|
const fd = require('form-data')
|
||||||
|
const mailgun = new MG(fd)
|
||||||
|
|
||||||
const mgOptions = {
|
const mgOptions = {
|
||||||
key: process.env.MAILGUN_API_KEY,
|
key: process.env.MAILGUN_API_KEY,
|
||||||
|
@ -7,13 +8,13 @@ const mgOptions = {
|
||||||
username: 'discoursio' // FIXME
|
username: 'discoursio' // FIXME
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const client = mailgun.client(mgOptions)
|
||||||
|
|
||||||
export default async (req, res) => {
|
export default async (req, res) => {
|
||||||
const { email } = req.query
|
const { email } = req.query
|
||||||
const mailgun = new MG(fd)
|
|
||||||
const client = mailgun.client(mgOptions)
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await client.lists.members.createMember(mgOptions.domain, {
|
const response = await client.lists.members.createMember(mgOptions.domain, {
|
||||||
address: email,
|
address: email,
|
||||||
subscribed: true,
|
subscribed: true,
|
||||||
upsert: 'yes'
|
upsert: 'yes'
|
||||||
|
@ -21,7 +22,8 @@ export default async (req, res) => {
|
||||||
|
|
||||||
return res.status(200).json({
|
return res.status(200).json({
|
||||||
success: true,
|
success: true,
|
||||||
message: 'Email added to newsletter list'
|
message: 'Email was added to newsletter list',
|
||||||
|
response: JSON.stringify(response)
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
|
|
20
api/ssr.js
Normal file
20
api/ssr.js
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
import { renderPage } from 'vite-plugin-ssr'
|
||||||
|
|
||||||
|
export default async function handler(req, res) {
|
||||||
|
const { url, cookies } = req
|
||||||
|
|
||||||
|
const pageContext = await renderPage({ urlOriginal: url, cookies })
|
||||||
|
|
||||||
|
const { httpResponse } = pageContext
|
||||||
|
|
||||||
|
if (!httpResponse) {
|
||||||
|
res.statusCode = 200
|
||||||
|
res.end()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const { body, statusCode, contentType } = httpResponse
|
||||||
|
res.statusCode = statusCode
|
||||||
|
res.setHeader('Content-Type', contentType)
|
||||||
|
res.end(body)
|
||||||
|
}
|
|
@ -1,76 +0,0 @@
|
||||||
import { defineConfig, AstroUserConfig } from 'astro/config'
|
|
||||||
import vercel from '@astrojs/vercel/serverless'
|
|
||||||
import solidJs from '@astrojs/solid-js'
|
|
||||||
import defaultGenerateScopedName from 'postcss-modules/build/generateScopedName'
|
|
||||||
import { isDev } from './src/utils/config'
|
|
||||||
// import { visualizer } from 'rollup-plugin-visualizer'
|
|
||||||
|
|
||||||
const PATH_PREFIX = '/src/'
|
|
||||||
|
|
||||||
const getDevCssClassPrefix = (filename: string): string => {
|
|
||||||
return filename
|
|
||||||
.slice(filename.indexOf(PATH_PREFIX) + PATH_PREFIX.length)
|
|
||||||
.replace('.module.scss', '')
|
|
||||||
.replaceAll(/[/?\\]/g, '-')
|
|
||||||
}
|
|
||||||
|
|
||||||
const devGenerateScopedName = (name: string, filename: string, css: string) =>
|
|
||||||
getDevCssClassPrefix(filename) + '_' + defaultGenerateScopedName(name, filename, css)
|
|
||||||
|
|
||||||
const astroConfig: AstroUserConfig = {
|
|
||||||
site: 'https://new.discours.io',
|
|
||||||
integrations: [solidJs()],
|
|
||||||
output: 'server',
|
|
||||||
adapter: vercel(),
|
|
||||||
vite: {
|
|
||||||
build: {
|
|
||||||
chunkSizeWarningLimit: 777,
|
|
||||||
rollupOptions: {
|
|
||||||
// plugins: [visualizer()],
|
|
||||||
output: {
|
|
||||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
|
||||||
/*
|
|
||||||
manualChunks(id) {
|
|
||||||
if (id.includes('p2p')) return 'p2p'
|
|
||||||
if (id.includes('editor') || id.includes('Editor')) return 'editor'
|
|
||||||
if (id.includes('node_modules')) {
|
|
||||||
let chunkid
|
|
||||||
if (id.includes('solid')) chunkid = 'solid'
|
|
||||||
if (id.includes('swiper')) chunkid = 'swiper'
|
|
||||||
if (id.includes('acorn')) chunkid = 'acorn'
|
|
||||||
if (id.includes('prosemirror')) chunkid = 'editor'
|
|
||||||
if (id.includes('markdown') || id.includes('mdurl') || id.includes('yjs')) {
|
|
||||||
chunkid = 'codecs'
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
id.includes('p2p') ||
|
|
||||||
id.includes('y-protocols') ||
|
|
||||||
id.includes('y-webrtc') ||
|
|
||||||
id.includes('simple-peer')
|
|
||||||
) {
|
|
||||||
chunkid = 'p2p'
|
|
||||||
}
|
|
||||||
return chunkid
|
|
||||||
}
|
|
||||||
}
|
|
||||||
*/
|
|
||||||
},
|
|
||||||
external: []
|
|
||||||
}
|
|
||||||
},
|
|
||||||
css: {
|
|
||||||
preprocessorOptions: {
|
|
||||||
scss: {
|
|
||||||
additionalData: '@import "src/styles/imports";\n'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
modules: {
|
|
||||||
generateScopedName: isDev ? devGenerateScopedName : defaultGenerateScopedName,
|
|
||||||
localsConvention: null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// https://astro.build/config
|
|
||||||
export default defineConfig(astroConfig)
|
|
|
@ -2,7 +2,7 @@
|
||||||
actor User
|
actor User
|
||||||
participant Browser
|
participant Browser
|
||||||
participant Vercel
|
participant Vercel
|
||||||
participant Astro
|
participant article.page.server.ts
|
||||||
participant Solid
|
participant Solid
|
||||||
participant Store
|
participant Store
|
||||||
|
|
||||||
|
@ -10,17 +10,17 @@ User -> Browser: discours.io
|
||||||
activate Browser
|
activate Browser
|
||||||
Browser -> Vercel: GET <slug>
|
Browser -> Vercel: GET <slug>
|
||||||
activate Vercel
|
activate Vercel
|
||||||
Vercel -> Astro: render
|
Vercel -> article.page.server.ts: render
|
||||||
activate Astro
|
activate article.page.server.ts
|
||||||
Astro -> apiClient: getArticle({ slug })
|
article.page.server.ts -> apiClient: getArticle({ slug })
|
||||||
activate apiClient
|
activate apiClient
|
||||||
apiClient -> DB: query: articleBySlug
|
apiClient -> DB: query: articleBySlug
|
||||||
activate DB
|
activate DB
|
||||||
DB --> apiClient: response
|
DB --> apiClient: response
|
||||||
deactivate DB
|
deactivate DB
|
||||||
apiClient --> Astro: article data
|
apiClient --> article.page.server.ts: article data
|
||||||
deactivate apiClient
|
deactivate apiClient
|
||||||
Astro -> Solid: render <ArticlePage article={article} />
|
article.page.server.ts -> Solid: render <ArticlePage article={article} />
|
||||||
activate Solid
|
activate Solid
|
||||||
Solid -> Store: useCurrentArticleStore(article)
|
Solid -> Store: useCurrentArticleStore(article)
|
||||||
activate Store
|
activate Store
|
||||||
|
@ -28,11 +28,11 @@ Store -> Store: create store with initial data (server)
|
||||||
Store --> Solid: currentArticle
|
Store --> Solid: currentArticle
|
||||||
deactivate Store
|
deactivate Store
|
||||||
Solid -> Solid: render component
|
Solid -> Solid: render component
|
||||||
Solid --> Astro: rendered component
|
Solid --> article.page.server.ts: rendered component
|
||||||
deactivate Solid
|
deactivate Solid
|
||||||
Astro --> Vercel: rendered page
|
article.page.server.ts --> Vercel: rendered page
|
||||||
Vercel -> Vercel: save rendered page to CDN
|
Vercel -> Vercel: save rendered page to CDN
|
||||||
deactivate Astro
|
deactivate article.page.server.ts
|
||||||
Vercel --> Browser: rendered page
|
Vercel --> Browser: rendered page
|
||||||
deactivate Vercel
|
deactivate Vercel
|
||||||
Browser --> User: rendered page
|
Browser --> User: rendered page
|
||||||
|
@ -50,7 +50,7 @@ Solid -> Store: loadArticleComments
|
||||||
activate Store
|
activate Store
|
||||||
Store -> apiClient: getArticleComments
|
Store -> apiClient: getArticleComments
|
||||||
activate apiClient
|
activate apiClient
|
||||||
apiClient -> DB: query: getReactionsForShouts
|
apiClient -> DB: query: getReactions
|
||||||
activate DB
|
activate DB
|
||||||
DB --> apiClient: response
|
DB --> apiClient: response
|
||||||
deactivate DB
|
deactivate DB
|
||||||
|
|
40
docs/i18n.puml
Normal file
40
docs/i18n.puml
Normal file
|
@ -0,0 +1,40 @@
|
||||||
|
@startuml
|
||||||
|
actor User
|
||||||
|
participant Browser
|
||||||
|
participant Server
|
||||||
|
|
||||||
|
User -> Browser: discours.io
|
||||||
|
activate Browser
|
||||||
|
Browser -> Server: GET\nquery { lng }\ncookies { lng }
|
||||||
|
opt lng in query
|
||||||
|
Server -> Server: lng = lng from query
|
||||||
|
else no lng in query
|
||||||
|
opt lng in cookies
|
||||||
|
Server -> Server: lng = lng from cookies
|
||||||
|
else no lng in cookies
|
||||||
|
Server -> Server: lng = 'ru'
|
||||||
|
end opt
|
||||||
|
end opt
|
||||||
|
note right
|
||||||
|
_dafault.page.server.ts render
|
||||||
|
end note
|
||||||
|
|
||||||
|
opt i18next is not initialized
|
||||||
|
Server -> Server: initialize i18next with lng
|
||||||
|
else i18next not initialized
|
||||||
|
Server -> Server: change i18next language to lng
|
||||||
|
end opt
|
||||||
|
note right
|
||||||
|
all resources loaded synchronously
|
||||||
|
end note
|
||||||
|
Server --> Browser: pageContext { lng }
|
||||||
|
Browser -> Browser: init client side i18next with http backend
|
||||||
|
activate Browser
|
||||||
|
Browser -> Server: get translations for current language
|
||||||
|
Server --> Browser: translations JSON
|
||||||
|
deactivate Browser
|
||||||
|
Browser -> Browser: render page
|
||||||
|
Browser --> User: rendered page
|
||||||
|
deactivate Browser
|
||||||
|
@enduml
|
||||||
|
|
24
docs/routing.puml
Normal file
24
docs/routing.puml
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
@startuml
|
||||||
|
actor User
|
||||||
|
participant Browser
|
||||||
|
participant Server
|
||||||
|
|
||||||
|
User -> Browser: discours.io
|
||||||
|
activate Browser
|
||||||
|
Browser -> Server: GET
|
||||||
|
activate Server
|
||||||
|
Server -> Server: resolve route
|
||||||
|
note right
|
||||||
|
based on routes from
|
||||||
|
*.page.route.ts files
|
||||||
|
end note
|
||||||
|
Server -> Server: some.page.server.ts onBeforeRender
|
||||||
|
Server -> Server: _default.page.server.tsx render
|
||||||
|
Server --> Browser: pageContent
|
||||||
|
deactivate Server
|
||||||
|
Browser -> Browser: _default.page.client.tsx render(pageContext)
|
||||||
|
|
||||||
|
Browser --> User: rendered page
|
||||||
|
deactivate Browser
|
||||||
|
@enduml
|
||||||
|
|
9084
package-lock.json
generated
9084
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
87
package.json
87
package.json
|
@ -1,14 +1,14 @@
|
||||||
{
|
{
|
||||||
"name": "discoursio-webapp",
|
"name": "discoursio-webapp",
|
||||||
"version": "0.7.1",
|
"version": "0.8.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "astro build",
|
"build": "vite build",
|
||||||
"check": "npm run lint && npm run typecheck",
|
"check": "npm run lint && npm run typecheck",
|
||||||
"codegen": "graphql-codegen",
|
"codegen": "graphql-codegen",
|
||||||
"deploy": "graphql-codegen && npm run typecheck && astro build && vercel",
|
"deploy": "graphql-codegen && npm run typecheck && vite build && vercel",
|
||||||
"dev": "astro dev",
|
"dev": "vite",
|
||||||
"ffix": "npm run fix && npm run format",
|
"ffix": "npm run fix && npm run format",
|
||||||
"fix": "npm run lint:code:fix && npm run lint:styles:fix",
|
"fix": "npm run lint:code:fix && npm run lint:styles:fix",
|
||||||
"format": "npx prettier \"{,!(node_modules)/**/}*.{js,ts,tsx,json,scss,css}\" --write --ignore-path .gitignore",
|
"format": "npx prettier \"{,!(node_modules)/**/}*.{js,ts,tsx,json,scss,css}\" --write --ignore-path .gitignore",
|
||||||
|
@ -22,35 +22,30 @@
|
||||||
"pre-push": "",
|
"pre-push": "",
|
||||||
"pre-push-old": "npm run typecheck",
|
"pre-push-old": "npm run typecheck",
|
||||||
"prepare": "husky install",
|
"prepare": "husky install",
|
||||||
"preview": "astro preview",
|
"preview": "vite preview",
|
||||||
"start": "astro dev",
|
"start": "vite",
|
||||||
"start:dev": "cross-env PUBLIC_API_URL=http://127.0.0.1:8080 vercel dev",
|
"start:local": "cross-env PUBLIC_API_URL=http://127.0.0.1:8080 vite",
|
||||||
"start:local": "cross-env PUBLIC_API_URL=http://127.0.0.1:8080 astro dev",
|
"start:production": "cross-env PUBLIC_API_URL=https://v2.discours.io vite",
|
||||||
"start:production": "cross-env PUBLIC_API_URL=https://v2.discours.io astro dev",
|
"start:staging": "cross-env PUBLIC_API_URL=https://testapi.discours.io vite",
|
||||||
"start:staging": "cross-env PUBLIC_API_URL=https://testapi.discours.io astro dev",
|
"typecheck": "tsc --noEmit",
|
||||||
"typecheck": "astro check && tsc --noEmit",
|
|
||||||
"typecheck:watch": "tsc --noEmit --watch"
|
"typecheck:watch": "tsc --noEmit --watch"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/abort-controller": "^3.266.0",
|
"@aws-sdk/abort-controller": "^3.266.0",
|
||||||
"@aws-sdk/client-s3": "^3.216.0",
|
"@aws-sdk/client-s3": "^3.216.0",
|
||||||
"@aws-sdk/lib-storage": "^3.266.0",
|
"@aws-sdk/lib-storage": "^3.266.0",
|
||||||
"@connorskees/grass": "^0.12.0",
|
|
||||||
"@solid-primitives/share": "^2.0.1",
|
|
||||||
"astro-seo-meta": "^2.0.0",
|
|
||||||
"formidable": "^2.1.1",
|
"formidable": "^2.1.1",
|
||||||
|
"i18next": "^22.4.9",
|
||||||
"mailgun.js": "^8.0.6"
|
"mailgun.js": "^8.0.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@astrojs/solid-js": "^2.0.0",
|
|
||||||
"@astrojs/vercel": "^3.0.0",
|
|
||||||
"@babel/core": "^7.20.12",
|
"@babel/core": "^7.20.12",
|
||||||
"@graphql-codegen/cli": "^2.16.4",
|
"@graphql-codegen/cli": "^3.0.0",
|
||||||
"@graphql-codegen/typescript": "^2.8.7",
|
"@graphql-codegen/typescript": "^3.0.0",
|
||||||
"@graphql-codegen/typescript-operations": "^2.5.12",
|
"@graphql-codegen/typescript-operations": "^3.0.0",
|
||||||
"@graphql-codegen/typescript-urql": "^3.7.3",
|
"@graphql-codegen/typescript-urql": "^3.7.3",
|
||||||
"@graphql-codegen/urql-introspection": "^2.2.1",
|
"@graphql-codegen/urql-introspection": "^2.2.1",
|
||||||
"@graphql-tools/url-loader": "^7.17.3",
|
"@graphql-tools/url-loader": "^7.17.13",
|
||||||
"@graphql-typed-document-node/core": "^3.1.1",
|
"@graphql-typed-document-node/core": "^3.1.1",
|
||||||
"@nanostores/router": "^0.8.1",
|
"@nanostores/router": "^0.8.1",
|
||||||
"@nanostores/solid": "^0.3.2",
|
"@nanostores/solid": "^0.3.2",
|
||||||
|
@ -58,18 +53,17 @@
|
||||||
"@solid-primitives/memo": "^1.1.3",
|
"@solid-primitives/memo": "^1.1.3",
|
||||||
"@solid-primitives/share": "^2.0.1",
|
"@solid-primitives/share": "^2.0.1",
|
||||||
"@solid-primitives/storage": "^1.3.4",
|
"@solid-primitives/storage": "^1.3.4",
|
||||||
"@solid-primitives/upload": "^0.0.105",
|
"@solid-primitives/upload": "^0.0.107",
|
||||||
|
"@solidjs/meta": "^0.28.2",
|
||||||
"@types/express": "^4.17.15",
|
"@types/express": "^4.17.15",
|
||||||
"@types/node": "^18.11.18",
|
"@types/node": "^18.11.18",
|
||||||
"@types/uuid": "^8.3.4",
|
"@types/uuid": "^9.0.0",
|
||||||
"@typescript-eslint/eslint-plugin": "^5.48.2",
|
"@typescript-eslint/eslint-plugin": "^5.52.0",
|
||||||
"@typescript-eslint/parser": "^5.48.2",
|
"@typescript-eslint/parser": "^5.52.0",
|
||||||
"@urql/core": "^3.1.1",
|
"@urql/core": "^3.1.1",
|
||||||
"@urql/devtools": "^2.0.3",
|
"@urql/devtools": "^2.0.3",
|
||||||
"@urql/exchange-graphcache": "^5.0.8",
|
"@urql/exchange-graphcache": "^5.0.8",
|
||||||
"astro": "^2.0.2",
|
"babel-preset-solid": "^1.5.6",
|
||||||
"astro-eslint-parser": "^0.11.0",
|
|
||||||
"astro-seo-meta": "^2.0.0",
|
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
"bootstrap": "^5.2.3",
|
"bootstrap": "^5.2.3",
|
||||||
"clsx": "^1.2.1",
|
"clsx": "^1.2.1",
|
||||||
|
@ -77,10 +71,9 @@
|
||||||
"cookie-signature": "^1.2.0",
|
"cookie-signature": "^1.2.0",
|
||||||
"cosmiconfig-toml-loader": "^1.0.0",
|
"cosmiconfig-toml-loader": "^1.0.0",
|
||||||
"cross-env": "^7.0.3",
|
"cross-env": "^7.0.3",
|
||||||
"eslint": "^8.32.0",
|
"eslint": "^8.34.0",
|
||||||
"eslint-config-stylelint": "^17.1.0",
|
"eslint-config-stylelint": "^18.0.0",
|
||||||
"eslint-import-resolver-typescript": "^3.5.3",
|
"eslint-import-resolver-typescript": "^3.5.3",
|
||||||
"eslint-plugin-astro": "^0.23.0",
|
|
||||||
"eslint-plugin-import": "^2.27.5",
|
"eslint-plugin-import": "^2.27.5",
|
||||||
"eslint-plugin-jsx-a11y": "^6.7.1",
|
"eslint-plugin-jsx-a11y": "^6.7.1",
|
||||||
"eslint-plugin-promise": "^6.1.1",
|
"eslint-plugin-promise": "^6.1.1",
|
||||||
|
@ -93,21 +86,21 @@
|
||||||
"hast-util-select": "^5.0.4",
|
"hast-util-select": "^5.0.4",
|
||||||
"husky": "^8.0.3",
|
"husky": "^8.0.3",
|
||||||
"hygen": "^6.2.11",
|
"hygen": "^6.2.11",
|
||||||
|
"i18next-http-backend": "^2.1.1",
|
||||||
"idb": "^7.1.1",
|
"idb": "^7.1.1",
|
||||||
"jest": "^29.3.1",
|
"jest": "^29.3.1",
|
||||||
"lint-staged": "^13.1.0",
|
"js-cookie": "^3.0.1",
|
||||||
|
"lint-staged": "^13.1.2",
|
||||||
"loglevel": "^1.8.1",
|
"loglevel": "^1.8.1",
|
||||||
"loglevel-plugin-prefix": "^0.8.4",
|
"loglevel-plugin-prefix": "^0.8.4",
|
||||||
"markdown-it": "^13.0.1",
|
"markdown-it": "^13.0.1",
|
||||||
"markdown-it-container": "^3.0.0",
|
"markdown-it-container": "^3.0.0",
|
||||||
"markdown-it-implicit-figures": "^0.10.0",
|
"markdown-it-implicit-figures": "^0.11.0",
|
||||||
"markdown-it-mark": "^3.0.1",
|
"markdown-it-mark": "^3.0.1",
|
||||||
"markdown-it-replace-link": "^1.1.0",
|
"markdown-it-replace-link": "^1.1.0",
|
||||||
"nanostores": "^0.7.1",
|
"nanostores": "^0.7.1",
|
||||||
"orderedmap": "^2.1.0",
|
"orderedmap": "^2.1.0",
|
||||||
"postcss": "^8.4.21",
|
"prettier": "^2.7.1",
|
||||||
"postcss-modules": "5.0.0",
|
|
||||||
"prettier": "^2.8.3",
|
|
||||||
"prettier-eslint": "^15.0.1",
|
"prettier-eslint": "^15.0.1",
|
||||||
"prosemirror-commands": "^1.5.0",
|
"prosemirror-commands": "^1.5.0",
|
||||||
"prosemirror-dropcursor": "^1.6.1",
|
"prosemirror-dropcursor": "^1.6.1",
|
||||||
|
@ -115,33 +108,37 @@
|
||||||
"prosemirror-gapcursor": "^1.3.1",
|
"prosemirror-gapcursor": "^1.3.1",
|
||||||
"prosemirror-history": "^1.3.0",
|
"prosemirror-history": "^1.3.0",
|
||||||
"prosemirror-inputrules": "^1.2.0",
|
"prosemirror-inputrules": "^1.2.0",
|
||||||
"prosemirror-keymap": "^1.2.0",
|
"prosemirror-keymap": "^1.2.1",
|
||||||
"prosemirror-markdown": "^1.10.1",
|
"prosemirror-markdown": "^1.10.1",
|
||||||
"prosemirror-menu": "^1.2.1",
|
"prosemirror-menu": "^1.2.1",
|
||||||
"prosemirror-model": "^1.19.0",
|
"prosemirror-model": "^1.19.0",
|
||||||
"prosemirror-schema-list": "^1.2.2",
|
"prosemirror-schema-list": "^1.2.2",
|
||||||
"prosemirror-state": "^1.4.2",
|
"prosemirror-state": "^1.4.2",
|
||||||
"prosemirror-view": "^1.30.0",
|
"prosemirror-view": "^1.30.0",
|
||||||
"rollup": "^2.79.1",
|
"rollup": "^3.15.0",
|
||||||
"rollup-plugin-visualizer": "^5.9.0",
|
"rollup-plugin-visualizer": "^5.9.0",
|
||||||
"sass": "1.32.13",
|
"sass": "^1.58.1",
|
||||||
"solid-js": "^1.6.9",
|
"solid-js": "^1.6.11",
|
||||||
"solid-transition-group": "^0.0.13",
|
"solid-transition-group": "^0.0.13",
|
||||||
"sort-package-json": "^2.3.0",
|
"sort-package-json": "^2.3.0",
|
||||||
"stylelint": "^14.16.1",
|
"stylelint": "^15.1.0",
|
||||||
"stylelint-config-css-modules": "^4.1.0",
|
"stylelint-config-css-modules": "^4.1.0",
|
||||||
"stylelint-config-prettier-scss": "^0.0.1",
|
"stylelint-config-prettier-scss": "^0.0.1",
|
||||||
"stylelint-config-standard-scss": "^6.1.0",
|
"stylelint-config-standard-scss": "^7.0.1",
|
||||||
"stylelint-order": "^6.0.1",
|
"stylelint-order": "^6.0.1",
|
||||||
"stylelint-scss": "^4.3.0",
|
"stylelint-scss": "^4.4.0",
|
||||||
"swiper": "^8.4.7",
|
"swiper": "^8.4.7",
|
||||||
"ts-node": "^10.9.1",
|
"ts-node": "^10.9.1",
|
||||||
"typescript": "^4.9.4",
|
"typescript": "^4.9.4",
|
||||||
"undici": "^5.15.1",
|
"undici": "^5.19.1",
|
||||||
"unique-names-generator": "^4.7.1",
|
"unique-names-generator": "^4.7.1",
|
||||||
"uuid": "^9.0.0",
|
"uuid": "^9.0.0",
|
||||||
"vite": "^3.2.5",
|
"vite": "^4.1.1",
|
||||||
"ws": "^8.12.0",
|
"vite-plugin-sass-dts": "^1.2.15",
|
||||||
|
"vite-plugin-solid": "^2.3.8",
|
||||||
|
"vite-plugin-ssr": "^0.4.38",
|
||||||
|
"wonka": "^6.2.3",
|
||||||
|
"ws": "^8.12.1",
|
||||||
"y-prosemirror": "^1.2.0",
|
"y-prosemirror": "^1.2.0",
|
||||||
"y-protocols": "^1.0.5",
|
"y-protocols": "^1.0.5",
|
||||||
"y-webrtc": "^10.2.4",
|
"y-webrtc": "^10.2.4",
|
||||||
|
|
233
public/locales/en/translation.json
Normal file
233
public/locales/en/translation.json
Normal file
|
@ -0,0 +1,233 @@
|
||||||
|
{
|
||||||
|
"...subscribing": "...subscribing",
|
||||||
|
"About myself": "About myself",
|
||||||
|
"About the project": "About the project",
|
||||||
|
"actions": "actions",
|
||||||
|
"Add comment": "Comment",
|
||||||
|
"Address on Discourse": "Address on Discourse",
|
||||||
|
"All": "All",
|
||||||
|
"All authors": "All authors",
|
||||||
|
"All posts": "All posts",
|
||||||
|
"all topics": "all topics",
|
||||||
|
"All topics": "All topics",
|
||||||
|
"Almost done! Check your email.": "Almost done! Just checking your email.",
|
||||||
|
"Artworks": "Artworks",
|
||||||
|
"Audio": "Audio",
|
||||||
|
"author": "author",
|
||||||
|
"Author": "Author",
|
||||||
|
"authors": "authors",
|
||||||
|
"Authors": "Authors",
|
||||||
|
"Back to main page": "Back to main page",
|
||||||
|
"Become an author": "Become an author",
|
||||||
|
"Bookmarked": "Saved",
|
||||||
|
"By alphabet": "By alphabet",
|
||||||
|
"By authors": "By authors",
|
||||||
|
"By name": "By name",
|
||||||
|
"By popularity": "By popularity",
|
||||||
|
"By rating": "By popularity",
|
||||||
|
"By relevance": "By relevance",
|
||||||
|
"By shouts": "By publications",
|
||||||
|
"By signing up you agree with our": "By signing up you agree with our",
|
||||||
|
"By title": "By title",
|
||||||
|
"By updates": "By updates",
|
||||||
|
"By views": "By views",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"Chat Title": "Chat Title",
|
||||||
|
"Choose who you want to write to": "Choose who you want to write to",
|
||||||
|
"Collaborate": "Help Edit",
|
||||||
|
"collections": "collections",
|
||||||
|
"Comments": "Comments",
|
||||||
|
"Communities": "Communities",
|
||||||
|
"community": "community",
|
||||||
|
"Cooperate": "Cooperate",
|
||||||
|
"Copy": "Copy",
|
||||||
|
"Copy link": "Copy link",
|
||||||
|
"Create account": "Create an account",
|
||||||
|
"Create Chat": "Create Chat",
|
||||||
|
"Create Group": "Create a group",
|
||||||
|
"Create post": "Create post",
|
||||||
|
"Date of Birth": "Date of Birth",
|
||||||
|
"Delete": "Delete",
|
||||||
|
"Discours": "Discours",
|
||||||
|
"Discours is an intellectual environment, a web space and tools that allows authors to collaborate with readers and come together to co-create publications and media projects": "Discours is an intellectual environment, a web space and tools that allows authors to collaborate with readers and come together to co-create publications and media projects",
|
||||||
|
"Discours is created with our common effort": "Discours exists because of our common effort",
|
||||||
|
"Discussing": "Discussing",
|
||||||
|
"discussion": "discourse",
|
||||||
|
"Discussion rules": "Discussion rules",
|
||||||
|
"Dogma": "Dogma",
|
||||||
|
"Drafts": "Drafts",
|
||||||
|
"Edit": "Edit",
|
||||||
|
"Email": "Mail",
|
||||||
|
"email not confirmed": "email not confirmed",
|
||||||
|
"enter": "enter",
|
||||||
|
"Enter": "Enter",
|
||||||
|
"Enter text": "Enter text",
|
||||||
|
"Enter the code or click the link from email to confirm": "Enter the code from the email or follow the link in the email to confirm registration",
|
||||||
|
"Enter the Discours": "Enter the Discours",
|
||||||
|
"Enter your new password": "Enter your new password",
|
||||||
|
"Error": "Error",
|
||||||
|
"Everything is ok, please give us your email address": "It's okay, just enter your email address to receive a password reset link.",
|
||||||
|
"Favorite": "Favorites",
|
||||||
|
"Favorite topics": "Favorite topics",
|
||||||
|
"feed": "feed",
|
||||||
|
"Feed settings": "Feed settings",
|
||||||
|
"Feedback": "Feedback",
|
||||||
|
"Fill email": "Fill email",
|
||||||
|
"Follow": "Follow",
|
||||||
|
"Follow the topic": "Follow the topic",
|
||||||
|
"follower": "follower",
|
||||||
|
"Followers": "Followers",
|
||||||
|
"Forgot password?": "Forgot your password?",
|
||||||
|
"Forward": "Forward",
|
||||||
|
"Full name": "First and last name",
|
||||||
|
"Get to know the most intelligent people of our time, edit and discuss the articles, share your expertise, rate and decide what to publish in the magazine": "Get to know the most intelligent people of our time, edit and discuss the articles, share your expertise, rate and decide what to publish in the magazine",
|
||||||
|
"Go to main page": "Go to main page",
|
||||||
|
"Group Chat": "Group Chat",
|
||||||
|
"Groups": "Groups",
|
||||||
|
"Help to edit": "Help to edit",
|
||||||
|
"Here you can customize your profile the way you want.": "Here you can customize your profile the way you want.",
|
||||||
|
"Hooray! Welcome!": "Hooray! Welcome!",
|
||||||
|
"Horizontal collaborative journalistic platform": "Horizontal collaborative journalism platform",
|
||||||
|
"How can I help/skills": "How can I help/skills",
|
||||||
|
"How it works": "How it works",
|
||||||
|
"How to write an article": "How to write an article",
|
||||||
|
"I have an account": "I have an account!",
|
||||||
|
"I have no account yet": "I don't have an account yet",
|
||||||
|
"I know the password": "I know the password",
|
||||||
|
"Independant magazine with an open horizontal cooperation about culture, science and society": "Independant magazine with an open horizontal cooperation about culture, science and society",
|
||||||
|
"Introduce": "Introduction",
|
||||||
|
"Invalid email": "Check if your email is correct",
|
||||||
|
"invalid password": "invalid password",
|
||||||
|
"Invite to collab": "Invite to Collab",
|
||||||
|
"It does not look like url": "It doesn't look like a link",
|
||||||
|
"Join": "Join",
|
||||||
|
"Join our maillist": "To receive the best postings, just enter your email",
|
||||||
|
"Join the community": "Join the community",
|
||||||
|
"Join the global community of authors!": "Join the global community of authors from all over the world!",
|
||||||
|
"Just start typing...": "Just start typing...",
|
||||||
|
"Knowledge base": "Knowledge base",
|
||||||
|
"Link sent, check your email": "Link sent, check your email",
|
||||||
|
"Literature": "Literature",
|
||||||
|
"Load more": "Show more",
|
||||||
|
"Loading": "Loading",
|
||||||
|
"Manifest": "Manifest",
|
||||||
|
"More": "More",
|
||||||
|
"Most commented": "Commented",
|
||||||
|
"Most read": "Readable",
|
||||||
|
"My feed": "My feed",
|
||||||
|
"My subscriptions": "Subscriptions",
|
||||||
|
"Name": "Name",
|
||||||
|
"New password": "New password",
|
||||||
|
"New stories every day and even more!": "New stories and more are waiting for you every day!",
|
||||||
|
"No such account, please try to register": "No such account found, please try to register",
|
||||||
|
"Nothing here yet": "There's nothing here yet",
|
||||||
|
"Nothing is here": "There is nothing here",
|
||||||
|
"Or continue with social network": "Or continue with social network",
|
||||||
|
"Our regular contributor": "Our regular contributor",
|
||||||
|
"Participating": "Participating",
|
||||||
|
"Partners": "Partners",
|
||||||
|
"Password": "Password",
|
||||||
|
"Password again": "Password again",
|
||||||
|
"Passwords are not equal": "Passwords are not equal",
|
||||||
|
"Personal": "Personal",
|
||||||
|
"personal data usage and email notifications": "to process personal data and receive email notifications",
|
||||||
|
"Pin": "Pin",
|
||||||
|
"Please check your email address": "Please check your email address",
|
||||||
|
"Please confirm your email to finish": "Confirm your email and the action will complete",
|
||||||
|
"Please enter a name to sign your comments and publication": "Please enter a name to sign your comments and publication",
|
||||||
|
"Please enter email": "Please enter your email",
|
||||||
|
"Please enter password": "Please enter a password",
|
||||||
|
"Please enter password again": "Please enter password again",
|
||||||
|
"Please, confirm email": "Please confirm email",
|
||||||
|
"Popular": "Popular",
|
||||||
|
"Popular authors": "Popular authors",
|
||||||
|
"post": "post",
|
||||||
|
"Principles": "Community principles",
|
||||||
|
"Profile": "Profile",
|
||||||
|
"Profile settings": "Profile settings",
|
||||||
|
"Publications": "Publications",
|
||||||
|
"Quit": "Quit",
|
||||||
|
"Reason uknown": "Reason unknown",
|
||||||
|
"Recent": "Fresh",
|
||||||
|
"register": "register",
|
||||||
|
"Reply": "Reply",
|
||||||
|
"Report": "Complain",
|
||||||
|
"Resend code": "Send confirmation",
|
||||||
|
"Restore password": "Restore password",
|
||||||
|
"Save settings": "Save settings",
|
||||||
|
"Search": "Search",
|
||||||
|
"Search author": "Search author",
|
||||||
|
"Search topic": "Search topic",
|
||||||
|
"Sections": "Sections",
|
||||||
|
"Security": "Security",
|
||||||
|
"Select": "Select",
|
||||||
|
"Send": "Send",
|
||||||
|
"Send link again": "Send link again",
|
||||||
|
"Settings": "Settings",
|
||||||
|
"Share": "Share",
|
||||||
|
"shout": "post",
|
||||||
|
"Show": "Show",
|
||||||
|
"sign up or sign in": "sign up or sign in",
|
||||||
|
"slug is used by another user": "Slug is already taken by another user",
|
||||||
|
"Social networks": "Social networks",
|
||||||
|
"Something went wrong, check email and password": "Something went wrong. Check your email and password",
|
||||||
|
"Something went wrong, please try again": "Something went wrong, please try again",
|
||||||
|
"Sorry, this address is already taken, please choose another one.": "Sorry, this address is already taken, please choose another one",
|
||||||
|
"Special projects": "Special projects",
|
||||||
|
"Start conversation": "Start a conversation",
|
||||||
|
"Subsccriptions": "Subscriptions",
|
||||||
|
"Subscribe": "Subscribe",
|
||||||
|
"Subscribe what you like to tune your personal feed": "Subscribe to topics that interest you to customize your personal feed and get instant updates on new posts and discussions",
|
||||||
|
"Subscribe who you like to tune your personal feed": "Subscribe to authors you're interested in to customize your personal feed and get instant updates on new posts and discussions",
|
||||||
|
"Subscription": "Subscription",
|
||||||
|
"Subscriptions": "Subscriptions",
|
||||||
|
"Successfully authorized": "Authorization successful",
|
||||||
|
"Suggest an idea": "Suggest an idea",
|
||||||
|
"Support us": "Help the magazine",
|
||||||
|
"terms of use": "terms of use",
|
||||||
|
"Terms of use": "Site rules",
|
||||||
|
"Thank you": "Thank you",
|
||||||
|
"This email is already taken. If it's you": "This email is already taken. If it's you",
|
||||||
|
"To leave a comment please": "To leave a comment please",
|
||||||
|
"To write a comment, you must": "To write a comment, you must",
|
||||||
|
"Top authors": "Authors rating",
|
||||||
|
"Top commented": "Most commented",
|
||||||
|
"Top discussed": "Top discussed",
|
||||||
|
"Top month articles": "Top of the month",
|
||||||
|
"Top rated": "Popular",
|
||||||
|
"Top recent": "Most recent",
|
||||||
|
"Top topics": "Interesting topics",
|
||||||
|
"Top viewed": "Most viewed",
|
||||||
|
"Topic is supported by": "Topic is supported by",
|
||||||
|
"topics": "topics",
|
||||||
|
"Topics": "Topics",
|
||||||
|
"Topics which supported by author": "Topics which supported by author",
|
||||||
|
"Try to find another way": "Try to find another way",
|
||||||
|
"Unfollow": "Unfollow",
|
||||||
|
"Unfollow the topic": "Unfollow the topic",
|
||||||
|
"user already exist": "user already exists",
|
||||||
|
"Username": "Username",
|
||||||
|
"Userpic": "Userpic",
|
||||||
|
"Video": "Video",
|
||||||
|
"view": "view",
|
||||||
|
"Views": "Views",
|
||||||
|
"We are convinced that one voice is good, but many is better": "We are convinced that one voice is good, but many is better",
|
||||||
|
"We can't find you, check email or": "We can't find you, check email or",
|
||||||
|
"We create the most amazing stories together": "We create the most amazing stories together",
|
||||||
|
"We know you, please try to login": "This email address is already registered, please try to login",
|
||||||
|
"We've sent you a message with a link to enter our website.": "We've sent you an email with a link to your email. Follow the link in the email to enter our website.",
|
||||||
|
"Where": "From",
|
||||||
|
"Work with us": "Cooperate with Discourse",
|
||||||
|
"Write": "Write",
|
||||||
|
"Write a comment...": "Write a comment...",
|
||||||
|
"Write about the topic": "Write about the topic",
|
||||||
|
"Write comment": "Write comment",
|
||||||
|
"Write message": "Write a message",
|
||||||
|
"Write to us": "Write to us",
|
||||||
|
"You are subscribed": "You are subscribed",
|
||||||
|
"You were successfully authorized": "You were successfully authorized",
|
||||||
|
"You've confirmed email": "You've confirmed email",
|
||||||
|
"You've reached a non-existed page": "You've reached a non-existed page",
|
||||||
|
"Your name will appear on your profile page and as your signature in publications, comments and responses.": "Your name will appear on your profile page and as your signature in publications, comments and responses",
|
||||||
|
"zine": "zine"
|
||||||
|
}
|
|
@ -1,6 +1,11 @@
|
||||||
{
|
{
|
||||||
"...subscribing": "...подписываем",
|
"...subscribing": "...подписываем",
|
||||||
|
"A short introduction to keep the reader interested": "Небольшое вступление, чтобы заинтересовать читателя",
|
||||||
|
"About myself": "О себе",
|
||||||
"About the project": "О проекте",
|
"About the project": "О проекте",
|
||||||
|
"Add comment": "Комментировать",
|
||||||
|
"Add to bookmarks": "Добавить в закладки",
|
||||||
|
"Address on Discourse": "Адрес на Дискурсе",
|
||||||
"All": "Все",
|
"All": "Все",
|
||||||
"All authors": "Все авторы",
|
"All authors": "Все авторы",
|
||||||
"All posts": "Все публикации",
|
"All posts": "Все публикации",
|
||||||
|
@ -8,6 +13,7 @@
|
||||||
"Almost done! Check your email.": "Почти готово! Осталось подтвердить вашу почту.",
|
"Almost done! Check your email.": "Почти готово! Осталось подтвердить вашу почту.",
|
||||||
"Artworks": "Артворки",
|
"Artworks": "Артворки",
|
||||||
"Audio": "Аудио",
|
"Audio": "Аудио",
|
||||||
|
"Author": "Автор",
|
||||||
"Authors": "Авторы",
|
"Authors": "Авторы",
|
||||||
"Back to main page": "Вернуться на главную",
|
"Back to main page": "Вернуться на главную",
|
||||||
"Become an author": "Стать автором",
|
"Become an author": "Стать автором",
|
||||||
|
@ -16,21 +22,26 @@
|
||||||
"By authors": "По авторам",
|
"By authors": "По авторам",
|
||||||
"By name": "По имени",
|
"By name": "По имени",
|
||||||
"By popularity": "По популярности",
|
"By popularity": "По популярности",
|
||||||
"By rating": "По популярности",
|
"By rating": "По рейтингу",
|
||||||
"By time": "По времени",
|
|
||||||
"By relevance": "По релевантности",
|
"By relevance": "По релевантности",
|
||||||
"By shouts": "По публикациям",
|
"By shouts": "По публикациям",
|
||||||
"By signing up you agree with our": "Регистрируясь, вы соглашаетесь с",
|
"By signing up you agree with our": "Регистрируясь, вы соглашаетесь с",
|
||||||
"By title": "По названию",
|
"By title": "По названию",
|
||||||
"By updates": "По обновлениям",
|
"By updates": "По обновлениям",
|
||||||
"By views": "По просмотрам",
|
"By views": "По просмотрам",
|
||||||
|
"Chat Title": "Тема дискурса",
|
||||||
|
"Choose who you want to write to": "Выберите кому хотите написать",
|
||||||
"Collaborate": "Помочь редактировать",
|
"Collaborate": "Помочь редактировать",
|
||||||
"Comments": "Комментарии",
|
"Comments": "Комментарии",
|
||||||
"Communities": "Сообщества",
|
"Communities": "Сообщества",
|
||||||
"Cooperate": "Соучаствовать",
|
"Cooperate": "Соучаствовать",
|
||||||
|
"Copy": "Скопировать",
|
||||||
"Copy link": "Скопировать ссылку",
|
"Copy link": "Скопировать ссылку",
|
||||||
|
"Create Chat": "Создать чат",
|
||||||
|
"Create Group": "Создать группу",
|
||||||
"Create account": "Создать аккаунт",
|
"Create account": "Создать аккаунт",
|
||||||
"Create post": "Создать публикацию",
|
"Create post": "Создать публикацию",
|
||||||
|
"Date of Birth": "Дата рождения",
|
||||||
"Delete": "Удалить",
|
"Delete": "Удалить",
|
||||||
"Discours": "Дискурс",
|
"Discours": "Дискурс",
|
||||||
"Discours is an intellectual environment, a web space and tools that allows authors to collaborate with readers and come together to co-create publications and media projects": "Дискурс — это интеллектуальная среда, веб-пространство и инструменты, которые позволяют авторам сотрудничать с читателями и объединяться для совместного создания публикаций и медиапроектов",
|
"Discours is an intellectual environment, a web space and tools that allows authors to collaborate with readers and come together to co-create publications and media projects": "Дискурс — это интеллектуальная среда, веб-пространство и инструменты, которые позволяют авторам сотрудничать с читателями и объединяться для совместного создания публикаций и медиапроектов",
|
||||||
|
@ -38,7 +49,9 @@
|
||||||
"Discussing": "Обсуждаемое",
|
"Discussing": "Обсуждаемое",
|
||||||
"Discussion rules": "Правила сообществ самиздата в соцсетях",
|
"Discussion rules": "Правила сообществ самиздата в соцсетях",
|
||||||
"Dogma": "Догма",
|
"Dogma": "Догма",
|
||||||
|
"Drafts": "Черновики",
|
||||||
"Edit": "Редактировать",
|
"Edit": "Редактировать",
|
||||||
|
"Edited": "Отредактирован",
|
||||||
"Email": "Почта",
|
"Email": "Почта",
|
||||||
"Enter": "Войти",
|
"Enter": "Войти",
|
||||||
"Enter text": "Введите текст",
|
"Enter text": "Введите текст",
|
||||||
|
@ -56,24 +69,36 @@
|
||||||
"Follow the topic": "Подписаться на тему",
|
"Follow the topic": "Подписаться на тему",
|
||||||
"Followers": "Подписчики",
|
"Followers": "Подписчики",
|
||||||
"Forgot password?": "Забыли пароль?",
|
"Forgot password?": "Забыли пароль?",
|
||||||
|
"Forward": "Переслать",
|
||||||
"Full name": "Имя и фамилия",
|
"Full name": "Имя и фамилия",
|
||||||
|
"Get notifications": "Получать уведомления",
|
||||||
"Get to know the most intelligent people of our time, edit and discuss the articles, share your expertise, rate and decide what to publish in the magazine": "Познакомитесь с выдающимися людьми нашего времени, участвуйте в редактировании и обсуждении статей, выступайте экспертом, оценивайте материалы других авторов со всего мира и определяйте, какие статьи будут опубликованы в журнале",
|
"Get to know the most intelligent people of our time, edit and discuss the articles, share your expertise, rate and decide what to publish in the magazine": "Познакомитесь с выдающимися людьми нашего времени, участвуйте в редактировании и обсуждении статей, выступайте экспертом, оценивайте материалы других авторов со всего мира и определяйте, какие статьи будут опубликованы в журнале",
|
||||||
"Go to main page": "Перейти на главную",
|
"Go to main page": "Перейти на главную",
|
||||||
|
"Group Chat": "Общий чат",
|
||||||
|
"Groups": "Группы",
|
||||||
|
"Header": "Заголовок",
|
||||||
"Help to edit": "Помочь редактировать",
|
"Help to edit": "Помочь редактировать",
|
||||||
|
"Here you can customize your profile the way you want.": "Здесь можно настроить свой профиль так, как вы хотите.",
|
||||||
"Hooray! Welcome!": "Ура! Добро пожаловать!",
|
"Hooray! Welcome!": "Ура! Добро пожаловать!",
|
||||||
"Horizontal collaborative journalistic platform": "Горизонтальная платформа для коллаборативной журналистики",
|
"Horizontal collaborative journalistic platform": "Горизонтальная платформа для коллаборативной журналистики",
|
||||||
|
"How can I help/skills": "Чем могу помочь/навыки",
|
||||||
"How it works": "Как это работает",
|
"How it works": "Как это работает",
|
||||||
"How to write an article": "Как написать статью",
|
"How to write an article": "Как написать статью",
|
||||||
"I have an account": "У меня есть аккаунт!",
|
"I have an account": "У меня есть аккаунт!",
|
||||||
"I have no account yet": "У меня еще нет аккаунта",
|
"I have no account yet": "У меня еще нет аккаунта",
|
||||||
"I know the password": "Я знаю пароль",
|
"I know the password": "Я знаю пароль",
|
||||||
"Invite to collab": "Пригласить к участию",
|
"Independant magazine with an open horizontal cooperation about culture, science and society": "Независимый журнал с открытой горизонтальной редакцией о культуре, науке и обществе",
|
||||||
|
"Introduce": "Представление",
|
||||||
"Invalid email": "Проверьте правильность ввода почты",
|
"Invalid email": "Проверьте правильность ввода почты",
|
||||||
|
"Invite experts": "Пригласить экспертов",
|
||||||
|
"Invite to collab": "Пригласить к участию",
|
||||||
|
"It does not look like url": "Это не похоже на ссылку",
|
||||||
"Join": "Присоединиться",
|
"Join": "Присоединиться",
|
||||||
"Join our maillist": "Чтобы получать рассылку лучших публикаций, просто укажите свою почту",
|
"Join our maillist": "Чтобы получать рассылку лучших публикаций, просто укажите свою почту",
|
||||||
"Join the community": "Присоединиться к сообществу",
|
"Join the community": "Присоединиться к сообществу",
|
||||||
"Join the global community of authors!": "Присоединятесь к глобальному сообществу авторов со всего мира!",
|
"Join the global community of authors!": "Присоединятесь к глобальному сообществу авторов со всего мира!",
|
||||||
"Just start typing...": "Просто начните печатать...",
|
"Just start typing...": "Просто начните печатать...",
|
||||||
|
"Karma": "Карма",
|
||||||
"Knowledge base": "База знаний",
|
"Knowledge base": "База знаний",
|
||||||
"Link sent, check your email": "Ссылка отправлена, проверьте почту",
|
"Link sent, check your email": "Ссылка отправлена, проверьте почту",
|
||||||
"Literature": "Литература",
|
"Literature": "Литература",
|
||||||
|
@ -84,9 +109,12 @@
|
||||||
"Most commented": "Комментируемое",
|
"Most commented": "Комментируемое",
|
||||||
"Most read": "Читаемое",
|
"Most read": "Читаемое",
|
||||||
"My feed": "Моя лента",
|
"My feed": "Моя лента",
|
||||||
|
"My subscriptions": "Подписки",
|
||||||
|
"Name": "Имя",
|
||||||
"New password": "Новый пароль",
|
"New password": "Новый пароль",
|
||||||
"New stories every day and even more!": "Каждый день вас ждут новые истории и ещё много всего интересного!",
|
"New stories every day and even more!": "Каждый день вас ждут новые истории и ещё много всего интересного!",
|
||||||
"No such account, please try to register": "Такой адрес не найден, попробуйте зарегистрироваться",
|
"No such account, please try to register": "Такой адрес не найден, попробуйте зарегистрироваться",
|
||||||
|
"Nothing here yet": "Здесь пока ничего нет",
|
||||||
"Nothing is here": "Здесь ничего нет",
|
"Nothing is here": "Здесь ничего нет",
|
||||||
"Or continue with social network": "Или продолжите через соцсеть",
|
"Or continue with social network": "Или продолжите через соцсеть",
|
||||||
"Our regular contributor": "Наш постоянный автор",
|
"Our regular contributor": "Наш постоянный автор",
|
||||||
|
@ -95,6 +123,8 @@
|
||||||
"Password": "Пароль",
|
"Password": "Пароль",
|
||||||
"Password again": "Пароль ещё раз",
|
"Password again": "Пароль ещё раз",
|
||||||
"Passwords are not equal": "Пароли не совпадают",
|
"Passwords are not equal": "Пароли не совпадают",
|
||||||
|
"Personal": "Личные",
|
||||||
|
"Pin": "Закрепить",
|
||||||
"Please check your email address": "Пожалуйста, проверьте введенный адрес почты",
|
"Please check your email address": "Пожалуйста, проверьте введенный адрес почты",
|
||||||
"Please confirm your email to finish": "Подтвердите почту и действие совершится",
|
"Please confirm your email to finish": "Подтвердите почту и действие совершится",
|
||||||
"Please enter a name to sign your comments and publication": "Пожалуйста, введите имя, которое будет отображаться на сайте",
|
"Please enter a name to sign your comments and publication": "Пожалуйста, введите имя, которое будет отображаться на сайте",
|
||||||
|
@ -105,7 +135,11 @@
|
||||||
"Popular": "Популярное",
|
"Popular": "Популярное",
|
||||||
"Popular authors": "Популярные авторы",
|
"Popular authors": "Популярные авторы",
|
||||||
"Principles": "Принципы сообщества",
|
"Principles": "Принципы сообщества",
|
||||||
|
"Profile": "Профиль",
|
||||||
|
"Profile settings": "Настройки профиля",
|
||||||
|
"Profile successfully saved": "Профиль успешно сохранён",
|
||||||
"Publications": "Публикации",
|
"Publications": "Публикации",
|
||||||
|
"Publish": "Опубликовать",
|
||||||
"Quit": "Выйти",
|
"Quit": "Выйти",
|
||||||
"Reason uknown": "Причина неизвестна",
|
"Reason uknown": "Причина неизвестна",
|
||||||
"Recent": "Свежее",
|
"Recent": "Свежее",
|
||||||
|
@ -113,19 +147,31 @@
|
||||||
"Report": "Пожаловаться",
|
"Report": "Пожаловаться",
|
||||||
"Resend code": "Выслать подтверждение",
|
"Resend code": "Выслать подтверждение",
|
||||||
"Restore password": "Восстановить пароль",
|
"Restore password": "Восстановить пароль",
|
||||||
|
"Save settings": "Сохранить настройки",
|
||||||
"Search": "Поиск",
|
"Search": "Поиск",
|
||||||
"Search author": "Поиск автора",
|
"Search author": "Поиск автора",
|
||||||
"Search topic": "Поиск темы",
|
"Search topic": "Поиск темы",
|
||||||
"Sections": "Разделы",
|
"Sections": "Разделы",
|
||||||
|
"Security": "Безопасность",
|
||||||
|
"Select": "Выбрать",
|
||||||
|
"Send": "Отправить",
|
||||||
"Send link again": "Прислать ссылку ещё раз",
|
"Send link again": "Прислать ссылку ещё раз",
|
||||||
|
"Settings": "Настройки",
|
||||||
"Share": "Поделиться",
|
"Share": "Поделиться",
|
||||||
"Show": "Показать",
|
"Show": "Показать",
|
||||||
|
"Social networks": "Социальные сети",
|
||||||
"Something went wrong, check email and password": "Что-то пошло не так. Проверьте адрес электронной почты и пароль",
|
"Something went wrong, check email and password": "Что-то пошло не так. Проверьте адрес электронной почты и пароль",
|
||||||
|
"Something went wrong, please try again": "Что-то пошло не так, попробуйте еще раз",
|
||||||
|
"Sorry, this address is already taken, please choose another one.": "Увы, этот адрес уже занят, выберите другой",
|
||||||
"Special projects": "Спецпроекты",
|
"Special projects": "Спецпроекты",
|
||||||
|
"Start conversation": "Начать беседу",
|
||||||
|
"Subheader": "Подзаголовок",
|
||||||
"Subscribe": "Подписаться",
|
"Subscribe": "Подписаться",
|
||||||
|
"Subscribe to comments": "Подписаться на комментарии",
|
||||||
"Subscribe what you like to tune your personal feed": "Подпишитесь на интересующие вас темы, чтобы настроить вашу персональную ленту и моментально узнавать о новых публикациях и обсуждениях",
|
"Subscribe what you like to tune your personal feed": "Подпишитесь на интересующие вас темы, чтобы настроить вашу персональную ленту и моментально узнавать о новых публикациях и обсуждениях",
|
||||||
"Subscribe who you like to tune your personal feed": "Подпишитесь на интересующих вас авторов, чтобы настроить вашу персональную ленту и моментально узнавать о новых публикациях и обсуждениях",
|
"Subscribe who you like to tune your personal feed": "Подпишитесь на интересующих вас авторов, чтобы настроить вашу персональную ленту и моментально узнавать о новых публикациях и обсуждениях",
|
||||||
"Subscription": "Подписка",
|
"Subscription": "Подписка",
|
||||||
|
"Subscriptions": "Подписки",
|
||||||
"Successfully authorized": "Авторизация успешна",
|
"Successfully authorized": "Авторизация успешна",
|
||||||
"Suggest an idea": "Предложить идею",
|
"Suggest an idea": "Предложить идею",
|
||||||
"Support us": "Помочь журналу",
|
"Support us": "Помочь журналу",
|
||||||
|
@ -133,6 +179,7 @@
|
||||||
"Thank you": "Благодарности",
|
"Thank you": "Благодарности",
|
||||||
"This email is already taken. If it's you": "Такой email уже зарегистрирован. Если это вы",
|
"This email is already taken. If it's you": "Такой email уже зарегистрирован. Если это вы",
|
||||||
"To leave a comment please": "Чтобы оставить комментарий, необходимо",
|
"To leave a comment please": "Чтобы оставить комментарий, необходимо",
|
||||||
|
"To write a comment, you must": "Чтобы написать комментарий, необходимо",
|
||||||
"Top authors": "Рейтинг авторов",
|
"Top authors": "Рейтинг авторов",
|
||||||
"Top commented": "Самое комментируемое",
|
"Top commented": "Самое комментируемое",
|
||||||
"Top discussed": "Обсуждаемое",
|
"Top discussed": "Обсуждаемое",
|
||||||
|
@ -148,6 +195,7 @@
|
||||||
"Unfollow": "Отписаться",
|
"Unfollow": "Отписаться",
|
||||||
"Unfollow the topic": "Отписаться от темы",
|
"Unfollow the topic": "Отписаться от темы",
|
||||||
"Username": "Имя пользователя",
|
"Username": "Имя пользователя",
|
||||||
|
"Userpic": "Аватар",
|
||||||
"Video": "Видео",
|
"Video": "Видео",
|
||||||
"Views": "Просмотры",
|
"Views": "Просмотры",
|
||||||
"We are convinced that one voice is good, but many is better": "Мы убеждены, один голос хорошо, а много — лучше",
|
"We are convinced that one voice is good, but many is better": "Мы убеждены, один голос хорошо, а много — лучше",
|
||||||
|
@ -155,88 +203,49 @@
|
||||||
"We create the most amazing stories together": "Самые потрясающиe истории мы создаём вместе",
|
"We create the most amazing stories together": "Самые потрясающиe истории мы создаём вместе",
|
||||||
"We know you, please try to login": "Такой адрес почты уже зарегистрирован, попробуйте залогиниться",
|
"We know you, please try to login": "Такой адрес почты уже зарегистрирован, попробуйте залогиниться",
|
||||||
"We've sent you a message with a link to enter our website.": "Мы выслали вам письмо с ссылкой на почту. Перейдите по ссылке в письме, чтобы войти на сайт.",
|
"We've sent you a message with a link to enter our website.": "Мы выслали вам письмо с ссылкой на почту. Перейдите по ссылке в письме, чтобы войти на сайт.",
|
||||||
|
"Welcome!": "Добро пожаловать!",
|
||||||
|
"Where": "Откуда",
|
||||||
"Work with us": "Сотрудничать с Дискурсом",
|
"Work with us": "Сотрудничать с Дискурсом",
|
||||||
"Write": "Написать",
|
"Write": "Написать",
|
||||||
|
"Write a comment...": "Написать комментарий...",
|
||||||
"Write about the topic": "Написать в тему",
|
"Write about the topic": "Написать в тему",
|
||||||
"Write comment": "Написать комментарий",
|
"Write comment": "Написать комментарий",
|
||||||
|
"Write message": "Написать сообщение",
|
||||||
"Write to us": "Напишите нам",
|
"Write to us": "Напишите нам",
|
||||||
"You are subscribed": "Вы подписаны",
|
"You are subscribed": "Вы подписаны",
|
||||||
"You was successfully authorized": "Вы были успешно авторизованы",
|
"You was successfully authorized": "Вы были успешно авторизованы",
|
||||||
"You've confirmed email": "Вы подтвердили почту",
|
"You've confirmed email": "Вы подтвердили почту",
|
||||||
"You've reached a non-existed page": "Вы попали на несуществующую страницу",
|
"You've reached a non-existed page": "Вы попали на несуществующую страницу",
|
||||||
|
"You've successfully logged out": "Вы успешно вышли из аккаунта",
|
||||||
|
"Your name will appear on your profile page and as your signature in publications, comments and responses.": "Ваше имя появится на странице вашего профиля и как ваша подпись в публикациях, комментариях и откликах",
|
||||||
"actions": "действия",
|
"actions": "действия",
|
||||||
"all topics": "все темы",
|
"all topics": "все темы",
|
||||||
"author": "автор",
|
"author": "автор",
|
||||||
"Author": "Автор",
|
|
||||||
"authors": "авторы",
|
"authors": "авторы",
|
||||||
|
"cancel": "Отмена",
|
||||||
"collections": "коллекции",
|
"collections": "коллекции",
|
||||||
"community": "сообщество",
|
"community": "сообщество",
|
||||||
|
"create_chat": "Создать чат",
|
||||||
|
"create_group": "Создать группу",
|
||||||
|
"discourse_theme": "Тема дискурса",
|
||||||
|
"discussion": "дискурс",
|
||||||
"email not confirmed": "email не подтвержден",
|
"email not confirmed": "email не подтвержден",
|
||||||
"enter": "войдите",
|
"enter": "войдите",
|
||||||
"feed": "лента",
|
"feed": "лента",
|
||||||
"follower": "подписчик",
|
"follower": "подписчик",
|
||||||
"invalid password": "некорректный пароль",
|
"invalid password": "некорректный пароль",
|
||||||
|
"or": "или",
|
||||||
"personal data usage and email notifications": "на обработку персональных данных и на получение почтовых уведомлений",
|
"personal data usage and email notifications": "на обработку персональных данных и на получение почтовых уведомлений",
|
||||||
"post": "пост",
|
"post": "пост",
|
||||||
"register": "зарегистрируйтесь",
|
"register": "зарегистрируйтесь",
|
||||||
"sign up": "зарегистрироваться",
|
"shout": "пост",
|
||||||
"or": "или",
|
|
||||||
"sign in": "войти",
|
"sign in": "войти",
|
||||||
|
"sign up": "зарегистрироваться",
|
||||||
|
"sign up or sign in": "зарегистрироваться или войти",
|
||||||
|
"slug is used by another user": "Имя уже занято другим пользователем",
|
||||||
"terms of use": "правилами пользования сайтом",
|
"terms of use": "правилами пользования сайтом",
|
||||||
"topics": "темы",
|
"topics": "темы",
|
||||||
"user already exist": "пользователь уже существует",
|
"user already exist": "пользователь уже существует",
|
||||||
"view": "просмотр",
|
"view": "просмотр",
|
||||||
"zine": "журнал",
|
"zine": "журнал"
|
||||||
"shout": "пост",
|
|
||||||
"discussion": "дискурс",
|
|
||||||
"Personal": "Личные",
|
|
||||||
"Groups": "Группы",
|
|
||||||
"create_chat": "Создать чат",
|
|
||||||
"create_group": "Создать группу",
|
|
||||||
"discourse_theme": "Тема дискурса",
|
|
||||||
"cancel": "Отмена",
|
|
||||||
"Send": "Отправить",
|
|
||||||
"Group Chat": "Общий чат",
|
|
||||||
"Choose who you want to write to": "Выберите кому хотите написать",
|
|
||||||
"Start conversation": "Начать беседу",
|
|
||||||
"Profile settings": "Настройки профиля",
|
|
||||||
"Here you can customize your profile the way you want.": "Здесь можно настроить свой профиль так, как вы хотите.",
|
|
||||||
"Userpic": "Аватар",
|
|
||||||
"Name": "Имя",
|
|
||||||
"Your name will appear on your profile page and as your signature in publications, comments and responses.": "Ваше имя появится на странице вашего профиля и как ваша подпись в публикациях, комментариях и откликах",
|
|
||||||
"Address on Discourse": "Адрес на Дискурсе",
|
|
||||||
"Sorry, this address is already taken, please choose another one.": "Увы, этот адрес уже занят, выберите другой",
|
|
||||||
"Introduce": "Представление",
|
|
||||||
"About myself": "О себе",
|
|
||||||
"How can I help/skills": "Чем могу помочь/навыки",
|
|
||||||
"Where": "Откуда",
|
|
||||||
"Date of Birth": "Дата рождения",
|
|
||||||
"Social networks": "Социальные сети",
|
|
||||||
"Save settings": "Сохранить настройки",
|
|
||||||
"Write message": "Написать сообщение",
|
|
||||||
"Copy": "Скопировать",
|
|
||||||
"Pin": "Закрепить",
|
|
||||||
"Forward": "Переслать",
|
|
||||||
"Select": "Выбрать",
|
|
||||||
"slug is used by another user": "Имя уже занято другим пользователем",
|
|
||||||
"It does not look like url": "Это не похоже на ссылку",
|
|
||||||
"Something went wrong, please try again": "Что-то пошло не так, попробуйте еще раз",
|
|
||||||
"To write a comment, you must": "Чтобы написать комментарий, необходимо",
|
|
||||||
"Write a comment...": "Написать комментарий...",
|
|
||||||
"Add comment": "Комментировать",
|
|
||||||
"My subscriptions": "Подписки",
|
|
||||||
"Nothing here yet": "Здесь пока ничего нет",
|
|
||||||
"Edited": "Отредактирован",
|
|
||||||
"Invite experts": "Пригласить экспертов",
|
|
||||||
"Subscribe to comments": "Подписаться на комментарии",
|
|
||||||
"Add to bookmarks": "Добавить в закладки",
|
|
||||||
"Get notifications": "Получать уведомления",
|
|
||||||
"Profile successfully saved": "Профиль успешно сохранён",
|
|
||||||
"Welcome!": "Добро пожаловать!",
|
|
||||||
"You've successfully logged out": "Вы успешно вышли из аккаунта",
|
|
||||||
"Header": "Заголовок",
|
|
||||||
"Subheader": "Подзаголовок",
|
|
||||||
"A short introduction to keep the reader interested": "Небольшое вступление, чтобы заинтересовать читателя",
|
|
||||||
"Publish": "Опубликовать",
|
|
||||||
"Karma": "Карма"
|
|
||||||
}
|
}
|
102
src/components/App.tsx
Normal file
102
src/components/App.tsx
Normal file
|
@ -0,0 +1,102 @@
|
||||||
|
// FIXME: breaks on vercel, research
|
||||||
|
// import 'solid-devtools'
|
||||||
|
|
||||||
|
import { MODALS, showModal } from '../stores/ui'
|
||||||
|
import { Component, createEffect, createMemo, Suspense } from 'solid-js'
|
||||||
|
import { ROUTES, useRouter } from '../stores/router'
|
||||||
|
import { Dynamic } from 'solid-js/web'
|
||||||
|
|
||||||
|
import type { PageProps, RootSearchParams } from '../pages/types'
|
||||||
|
import { HomePage } from '../pages/index.page'
|
||||||
|
import { AllTopicsPage } from '../pages/allTopics.page'
|
||||||
|
import { TopicPage } from '../pages/topic.page'
|
||||||
|
import { AllAuthorsPage } from '../pages/allAuthors.page'
|
||||||
|
import { AuthorPage } from '../pages/author.page'
|
||||||
|
import { FeedPage } from '../pages/feed.page'
|
||||||
|
import { ArticlePage } from '../pages/article.page'
|
||||||
|
import { SearchPage } from '../pages/search.page'
|
||||||
|
import { FourOuFourPage } from '../pages/fourOuFour.page'
|
||||||
|
import { DiscussionRulesPage } from '../pages/about/discussionRules.page'
|
||||||
|
import { DogmaPage } from '../pages/about/dogma.page'
|
||||||
|
import { GuidePage } from '../pages/about/guide.page'
|
||||||
|
import { HelpPage } from '../pages/about/help.page'
|
||||||
|
import { ManifestPage } from '../pages/about/manifest.page'
|
||||||
|
import { PartnersPage } from '../pages/about/partners.page'
|
||||||
|
import { PrinciplesPage } from '../pages/about/principles.page'
|
||||||
|
import { ProjectsPage } from '../pages/about/projects.page'
|
||||||
|
import { TermsOfUsePage } from '../pages/about/termsOfUse.page'
|
||||||
|
import { ThanksPage } from '../pages/about/thanks.page'
|
||||||
|
import { CreatePage } from '../pages/create.page'
|
||||||
|
import { ConnectPage } from '../pages/connect.page'
|
||||||
|
import { InboxPage } from '../pages/inbox.page'
|
||||||
|
import { LayoutShoutsPage } from '../pages/layoutShouts.page'
|
||||||
|
import { SessionProvider } from '../context/session'
|
||||||
|
import { ProfileSettingsPage } from '../pages/profile/profileSettings.page'
|
||||||
|
import { ProfileSecurityPage } from '../pages/profile/profileSecurity.page'
|
||||||
|
import { ProfileSubscriptionsPage } from '../pages/profile/profileSubscriptions.page'
|
||||||
|
import { CreateSettingsPage } from '../pages/createSettings.page'
|
||||||
|
import { SnackbarProvider } from '../context/snackbar'
|
||||||
|
import { LocalizeProvider } from '../context/localize'
|
||||||
|
|
||||||
|
// TODO: lazy load
|
||||||
|
// const SomePage = lazy(() => import('./Pages/SomePage'))
|
||||||
|
|
||||||
|
const pagesMap: Record<keyof typeof ROUTES, Component<PageProps>> = {
|
||||||
|
inbox: InboxPage,
|
||||||
|
expo: LayoutShoutsPage,
|
||||||
|
connect: ConnectPage,
|
||||||
|
create: CreatePage,
|
||||||
|
createSettings: CreateSettingsPage,
|
||||||
|
home: HomePage,
|
||||||
|
topics: AllTopicsPage,
|
||||||
|
topic: TopicPage,
|
||||||
|
authors: AllAuthorsPage,
|
||||||
|
author: AuthorPage,
|
||||||
|
feed: FeedPage,
|
||||||
|
article: ArticlePage,
|
||||||
|
search: SearchPage,
|
||||||
|
discussionRules: DiscussionRulesPage,
|
||||||
|
dogma: DogmaPage,
|
||||||
|
guide: GuidePage,
|
||||||
|
help: HelpPage,
|
||||||
|
manifest: ManifestPage,
|
||||||
|
projects: ProjectsPage,
|
||||||
|
partners: PartnersPage,
|
||||||
|
principles: PrinciplesPage,
|
||||||
|
termsOfUse: TermsOfUsePage,
|
||||||
|
thanks: ThanksPage,
|
||||||
|
profileSettings: ProfileSettingsPage,
|
||||||
|
profileSecurity: ProfileSecurityPage,
|
||||||
|
profileSubscriptions: ProfileSubscriptionsPage
|
||||||
|
}
|
||||||
|
|
||||||
|
export const App = (props: PageProps) => {
|
||||||
|
const { page, searchParams } = useRouter<RootSearchParams>()
|
||||||
|
// console.debug(page())
|
||||||
|
createEffect(() => {
|
||||||
|
const modal = MODALS[searchParams().modal]
|
||||||
|
if (modal) {
|
||||||
|
showModal(modal)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
// console.debug(pagesMap)
|
||||||
|
const pageComponent = createMemo(() => {
|
||||||
|
const result = pagesMap[page()?.route || 'home']
|
||||||
|
|
||||||
|
if (!result || page()?.path === '/404') {
|
||||||
|
return FourOuFourPage
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<LocalizeProvider>
|
||||||
|
<SnackbarProvider>
|
||||||
|
<SessionProvider>
|
||||||
|
<Dynamic component={pageComponent()} {...props} />
|
||||||
|
</SessionProvider>
|
||||||
|
</SnackbarProvider>
|
||||||
|
</LocalizeProvider>
|
||||||
|
)
|
||||||
|
}
|
|
@ -3,6 +3,7 @@
|
||||||
padding: 0.8rem 2.4rem;
|
padding: 0.8rem 2.4rem;
|
||||||
transition: background-color 0.3s;
|
transition: background-color 0.3s;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
list-style: none;
|
||||||
|
|
||||||
@include media-breakpoint-down(sm) {
|
@include media-breakpoint-down(sm) {
|
||||||
margin-right: -1.2rem;
|
margin-right: -1.2rem;
|
||||||
|
@ -230,3 +231,11 @@
|
||||||
.commentRatingControlDown {
|
.commentRatingControlDown {
|
||||||
border-top: 8px solid rgb(0 0 0 / 40%);
|
border-top: 8px solid rgb(0 0 0 / 40%);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.compactUserpic {
|
||||||
|
height: 28px;
|
||||||
|
width: 28px;
|
||||||
|
min-width: 28px;
|
||||||
|
display: inline-block;
|
||||||
|
vertical-align: bottom;
|
||||||
|
}
|
||||||
|
|
|
@ -4,28 +4,39 @@ import { AuthorCard } from '../Author/Card'
|
||||||
import { Show, createMemo, createSignal, For, lazy, Suspense } from 'solid-js'
|
import { Show, createMemo, createSignal, For, lazy, Suspense } from 'solid-js'
|
||||||
import { clsx } from 'clsx'
|
import { clsx } from 'clsx'
|
||||||
import type { Author, Reaction } from '../../graphql/types.gen'
|
import type { Author, Reaction } from '../../graphql/types.gen'
|
||||||
import { t } from '../../utils/intl'
|
|
||||||
import { createReaction, deleteReaction, updateReaction } from '../../stores/zine/reactions'
|
|
||||||
import MD from './MD'
|
import MD from './MD'
|
||||||
import { formatDate } from '../../utils'
|
import { formatDate } from '../../utils'
|
||||||
import Userpic from '../Author/Userpic'
|
import Userpic from '../Author/Userpic'
|
||||||
import { useSession } from '../../context/session'
|
import { useSession } from '../../context/session'
|
||||||
import { ReactionKind } from '../../graphql/types.gen'
|
import { ReactionKind } from '../../graphql/types.gen'
|
||||||
|
import { useReactions } from '../../context/reactions'
|
||||||
|
import { useSnackbar } from '../../context/snackbar'
|
||||||
|
import { ShowIfAuthenticated } from '../_shared/ShowIfAuthenticated'
|
||||||
|
import { useLocalize } from '../../context/localize'
|
||||||
const CommentEditor = lazy(() => import('../_shared/CommentEditor'))
|
const CommentEditor = lazy(() => import('../_shared/CommentEditor'))
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
comment: Reaction
|
comment: Reaction
|
||||||
compact?: boolean
|
compact?: boolean
|
||||||
reactions?: Reaction[]
|
|
||||||
isArticleAuthor?: boolean
|
isArticleAuthor?: boolean
|
||||||
|
sortedComments?: Reaction[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Comment = (props: Props) => {
|
export const Comment = (props: Props) => {
|
||||||
|
const { t } = useLocalize()
|
||||||
const [isReplyVisible, setIsReplyVisible] = createSignal(false)
|
const [isReplyVisible, setIsReplyVisible] = createSignal(false)
|
||||||
const [loading, setLoading] = createSignal<boolean>(false)
|
const [loading, setLoading] = createSignal<boolean>(false)
|
||||||
const [editMode, setEditMode] = createSignal<boolean>(false)
|
const [editMode, setEditMode] = createSignal<boolean>(false)
|
||||||
const { session } = useSession()
|
const { session } = useSession()
|
||||||
|
|
||||||
|
const {
|
||||||
|
actions: { createReaction, deleteReaction, updateReaction }
|
||||||
|
} = useReactions()
|
||||||
|
|
||||||
|
const {
|
||||||
|
actions: { showSnackbar }
|
||||||
|
} = useSnackbar()
|
||||||
|
|
||||||
const canEdit = createMemo(() => props.comment.createdBy?.slug === session()?.user?.slug)
|
const canEdit = createMemo(() => props.comment.createdBy?.slug === session()?.user?.slug)
|
||||||
|
|
||||||
const comment = createMemo(() => props.comment)
|
const comment = createMemo(() => props.comment)
|
||||||
|
@ -34,6 +45,7 @@ export const Comment = (props: Props) => {
|
||||||
if (comment()?.id) {
|
if (comment()?.id) {
|
||||||
try {
|
try {
|
||||||
await deleteReaction(comment().id)
|
await deleteReaction(comment().id)
|
||||||
|
showSnackbar({ body: t('Comment successfully deleted') })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[deleteReaction]', error)
|
console.error('[deleteReaction]', error)
|
||||||
}
|
}
|
||||||
|
@ -43,19 +55,12 @@ export const Comment = (props: Props) => {
|
||||||
const handleCreate = async (value) => {
|
const handleCreate = async (value) => {
|
||||||
try {
|
try {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
await createReaction(
|
await createReaction({
|
||||||
{
|
kind: ReactionKind.Comment,
|
||||||
kind: ReactionKind.Comment,
|
replyTo: props.comment.id,
|
||||||
replyTo: props.comment.id,
|
body: value,
|
||||||
body: value,
|
shout: props.comment.shout.id
|
||||||
shout: props.comment.shout.id
|
})
|
||||||
},
|
|
||||||
{
|
|
||||||
name: session().user.name,
|
|
||||||
userpic: session().user.userpic,
|
|
||||||
slug: session().user.slug
|
|
||||||
}
|
|
||||||
)
|
|
||||||
setIsReplyVisible(false)
|
setIsReplyVisible(false)
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
@ -93,8 +98,15 @@ export const Comment = (props: Props) => {
|
||||||
when={!props.compact}
|
when={!props.compact}
|
||||||
fallback={
|
fallback={
|
||||||
<div>
|
<div>
|
||||||
<Userpic user={comment().createdBy as Author} isBig={false} isAuthorsList={false} />
|
<Userpic
|
||||||
<small class={styles.commentArticle}>
|
user={comment().createdBy as Author}
|
||||||
|
isBig={false}
|
||||||
|
isAuthorsList={false}
|
||||||
|
class={clsx({
|
||||||
|
[styles.compactUserpic]: props.compact
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
<small>
|
||||||
<a href={`#comment-${comment()?.id}`}>{comment()?.shout.title || ''}</a>
|
<a href={`#comment-${comment()?.id}`}>{comment()?.shout.title || ''}</a>
|
||||||
</small>
|
</small>
|
||||||
</div>
|
</div>
|
||||||
|
@ -127,12 +139,12 @@ export const Comment = (props: Props) => {
|
||||||
<div
|
<div
|
||||||
class={styles.commentRating}
|
class={styles.commentRating}
|
||||||
classList={{
|
classList={{
|
||||||
[styles.commentRatingPositive]: comment().stat?.rating > 0,
|
[styles.commentRatingPositive]: comment().stat.rating > 0,
|
||||||
[styles.commentRatingNegative]: comment().stat?.rating < 0
|
[styles.commentRatingNegative]: comment().stat.rating < 0
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<button class={clsx(styles.commentRatingControl, styles.commentRatingControlUp)} />
|
<button class={clsx(styles.commentRatingControl, styles.commentRatingControlUp)} />
|
||||||
<div class={styles.commentRatingValue}>{comment().stat?.rating || 0}</div>
|
<div class={styles.commentRatingValue}>{comment().stat.rating || 0}</div>
|
||||||
<button class={clsx(styles.commentRatingControl, styles.commentRatingControlDown)} />
|
<button class={clsx(styles.commentRatingControl, styles.commentRatingControlDown)} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -147,15 +159,16 @@ export const Comment = (props: Props) => {
|
||||||
|
|
||||||
<Show when={!props.compact}>
|
<Show when={!props.compact}>
|
||||||
<div class={styles.commentControls}>
|
<div class={styles.commentControls}>
|
||||||
<button
|
<ShowIfAuthenticated>
|
||||||
disabled={loading()}
|
<button
|
||||||
onClick={() => setIsReplyVisible(!isReplyVisible())}
|
disabled={loading()}
|
||||||
class={clsx(styles.commentControl, styles.commentControlReply)}
|
onClick={() => setIsReplyVisible(!isReplyVisible())}
|
||||||
>
|
class={clsx(styles.commentControl, styles.commentControlReply)}
|
||||||
<Icon name="reply" class={styles.icon} />
|
>
|
||||||
{loading() ? t('Loading') : t('Reply')}
|
<Icon name="reply" class={styles.icon} />
|
||||||
</button>
|
{loading() ? t('Loading') : t('Reply')}
|
||||||
|
</button>
|
||||||
|
</ShowIfAuthenticated>
|
||||||
<Show when={canEdit()}>
|
<Show when={canEdit()}>
|
||||||
<button
|
<button
|
||||||
class={clsx(styles.commentControl, styles.commentControlEdit)}
|
class={clsx(styles.commentControl, styles.commentControlEdit)}
|
||||||
|
@ -201,14 +214,14 @@ export const Comment = (props: Props) => {
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={props.reactions}>
|
<Show when={props.sortedComments}>
|
||||||
<ul>
|
<ul>
|
||||||
<For each={props.reactions.filter((r) => r.replyTo === props.comment.id)}>
|
<For each={props.sortedComments.filter((r) => r.replyTo === props.comment.id)}>
|
||||||
{(reaction) => (
|
{(c) => (
|
||||||
<Comment
|
<Comment
|
||||||
|
sortedComments={props.sortedComments}
|
||||||
isArticleAuthor={props.isArticleAuthor}
|
isArticleAuthor={props.isArticleAuthor}
|
||||||
reactions={props.reactions}
|
comment={c}
|
||||||
comment={reaction}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</For>
|
</For>
|
||||||
|
@ -217,5 +230,3 @@ export const Comment = (props: Props) => {
|
||||||
</li>
|
</li>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Comment
|
|
||||||
|
|
|
@ -1,20 +1,20 @@
|
||||||
import { Show, createMemo, createSignal, onMount, For } from 'solid-js'
|
import { Show, createMemo, createSignal, onMount, For } from 'solid-js'
|
||||||
import Comment from './Comment'
|
import { Comment } from './Comment'
|
||||||
import { t } from '../../utils/intl'
|
|
||||||
import styles from '../../styles/Article.module.scss'
|
import styles from '../../styles/Article.module.scss'
|
||||||
import { createReaction, useReactionsStore } from '../../stores/zine/reactions'
|
|
||||||
import type { Reaction } from '../../graphql/types.gen'
|
|
||||||
import { clsx } from 'clsx'
|
import { clsx } from 'clsx'
|
||||||
import { byCreated, byStat } from '../../utils/sortby'
|
import { Loading } from '../_shared/Loading'
|
||||||
import { Loading } from '../Loading'
|
|
||||||
import { Author, ReactionKind } from '../../graphql/types.gen'
|
import { Author, ReactionKind } from '../../graphql/types.gen'
|
||||||
import { useSession } from '../../context/session'
|
import { useSession } from '../../context/session'
|
||||||
import CommentEditor from '../_shared/CommentEditor'
|
import CommentEditor from '../_shared/CommentEditor'
|
||||||
import { ShowOnlyOnClient } from '../_shared/ShowOnlyOnClient'
|
import { ShowOnlyOnClient } from '../_shared/ShowOnlyOnClient'
|
||||||
import Button from '../_shared/Button'
|
import { Button } from '../_shared/Button'
|
||||||
import { createStorage } from '@solid-primitives/storage'
|
import { createStorage } from '@solid-primitives/storage'
|
||||||
|
import { useReactions } from '../../context/reactions'
|
||||||
|
import { byCreated } from '../../utils/sortby'
|
||||||
|
import { ShowIfAuthenticated } from '../_shared/ShowIfAuthenticated'
|
||||||
|
import { useLocalize } from '../../context/localize'
|
||||||
|
|
||||||
const ARTICLE_COMMENTS_PAGE_SIZE = 50
|
type CommentsOrder = 'createdAt' | 'rating'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
commentAuthors: Author[]
|
commentAuthors: Author[]
|
||||||
|
@ -23,63 +23,85 @@ type Props = {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CommentsTree = (props: Props) => {
|
export const CommentsTree = (props: Props) => {
|
||||||
const [getCommentsPage, setCommentsPage] = createSignal(0)
|
|
||||||
const [commentsOrder, setCommentsOrder] = createSignal<'rating' | 'createdAt'>('createdAt')
|
|
||||||
const [isCommentsLoading, setIsCommentsLoading] = createSignal(false)
|
const [isCommentsLoading, setIsCommentsLoading] = createSignal(false)
|
||||||
const [isLoadMoreButtonVisible, setIsLoadMoreButtonVisible] = createSignal(false)
|
const [commentsOrder, setCommentsOrder] = createSignal<CommentsOrder>('createdAt')
|
||||||
const { sortedReactions, loadReactionsBy } = useReactionsStore()
|
const {
|
||||||
const [store, setStore] = createStorage({ api: localStorage })
|
reactionEntities,
|
||||||
const [newReactions, setNewReactions] = createSignal<number>()
|
actions: { loadReactionsBy, createReaction }
|
||||||
|
} = useReactions()
|
||||||
|
|
||||||
const getNewReactions = () => {
|
const { t } = useLocalize()
|
||||||
|
|
||||||
|
// TODO: server side?
|
||||||
|
const [store, setStore] = createStorage({ api: typeof localStorage === 'undefined' ? {} : localStorage })
|
||||||
|
const [newReactionsCount, setNewReactionsCount] = createSignal<number>(0)
|
||||||
|
|
||||||
|
const comments = createMemo(() =>
|
||||||
|
Object.values(reactionEntities).filter((reaction) => reaction.kind === 'COMMENT')
|
||||||
|
)
|
||||||
|
|
||||||
|
const sortedComments = createMemo(() => {
|
||||||
|
let newSortedComments = [...comments()]
|
||||||
|
newSortedComments = newSortedComments.sort(byCreated)
|
||||||
|
|
||||||
|
if (commentsOrder() === 'rating') {
|
||||||
|
newSortedComments = newSortedComments.sort((a, b) => {
|
||||||
|
if (a.replyTo && b.replyTo) {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const x = (a?.stat && a.stat.rating) || 0
|
||||||
|
const y = (b?.stat && b.stat.rating) || 0
|
||||||
|
|
||||||
|
if (x > y) {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
if (x < y) {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
newSortedComments.reverse()
|
||||||
|
|
||||||
|
return newSortedComments
|
||||||
|
})
|
||||||
|
|
||||||
|
const updateNewReactionsCount = () => {
|
||||||
const storeValue = Number(store[`${props.shoutSlug}`])
|
const storeValue = Number(store[`${props.shoutSlug}`])
|
||||||
const setVal = () => setStore(`${props.shoutSlug}`, `${sortedReactions().length}`)
|
const setVal = () => setStore(`${props.shoutSlug}`, `${comments().length}`)
|
||||||
if (!store[`${props.shoutSlug}`]) {
|
if (!store[`${props.shoutSlug}`]) {
|
||||||
setVal()
|
setVal()
|
||||||
} else if (storeValue < sortedReactions().length) {
|
} else if (storeValue < comments().length) {
|
||||||
setNewReactions(sortedReactions().length - storeValue)
|
setNewReactionsCount(comments().length - storeValue)
|
||||||
setVal()
|
setVal()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const reactions = createMemo<Reaction[]>(() =>
|
|
||||||
sortedReactions().sort(commentsOrder() === 'rating' ? byStat('rating') : byCreated)
|
|
||||||
)
|
|
||||||
|
|
||||||
const { session } = useSession()
|
const { session } = useSession()
|
||||||
const loadMore = async () => {
|
|
||||||
|
onMount(async () => {
|
||||||
try {
|
try {
|
||||||
const page = getCommentsPage()
|
|
||||||
setIsCommentsLoading(true)
|
setIsCommentsLoading(true)
|
||||||
const { hasMore } = await loadReactionsBy({
|
await loadReactionsBy({
|
||||||
by: { shout: props.shoutSlug, comment: true },
|
by: { shout: props.shoutSlug }
|
||||||
limit: ARTICLE_COMMENTS_PAGE_SIZE,
|
|
||||||
offset: page * ARTICLE_COMMENTS_PAGE_SIZE
|
|
||||||
})
|
})
|
||||||
getNewReactions()
|
updateNewReactionsCount()
|
||||||
setIsLoadMoreButtonVisible(hasMore)
|
|
||||||
} finally {
|
} finally {
|
||||||
setIsCommentsLoading(false)
|
setIsCommentsLoading(false)
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
|
|
||||||
onMount(async () => await loadMore())
|
|
||||||
|
|
||||||
const [submitted, setSubmitted] = createSignal<boolean>(false)
|
const [submitted, setSubmitted] = createSignal<boolean>(false)
|
||||||
const handleSubmitComment = async (value) => {
|
const handleSubmitComment = async (value) => {
|
||||||
try {
|
try {
|
||||||
await createReaction(
|
await createReaction({
|
||||||
{
|
kind: ReactionKind.Comment,
|
||||||
kind: ReactionKind.Comment,
|
body: value,
|
||||||
body: value,
|
shout: props.shoutId
|
||||||
shout: props.shoutId
|
})
|
||||||
},
|
|
||||||
{
|
|
||||||
name: session().user.name,
|
|
||||||
userpic: session().user.userpic,
|
|
||||||
slug: session().user.slug
|
|
||||||
}
|
|
||||||
)
|
|
||||||
setSubmitted(true)
|
setSubmitted(true)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[handleCreate reaction]:', error)
|
console.error('[handleCreate reaction]:', error)
|
||||||
|
@ -91,14 +113,14 @@ export const CommentsTree = (props: Props) => {
|
||||||
<Show when={!isCommentsLoading()} fallback={<Loading />}>
|
<Show when={!isCommentsLoading()} fallback={<Loading />}>
|
||||||
<div class={styles.commentsHeaderWrapper}>
|
<div class={styles.commentsHeaderWrapper}>
|
||||||
<h2 id="comments" class={styles.commentsHeader}>
|
<h2 id="comments" class={styles.commentsHeader}>
|
||||||
{t('Comments')} {reactions().length.toString() || ''}
|
{t('Comments')} {comments().length.toString() || ''}
|
||||||
<Show when={newReactions()}>
|
<Show when={newReactionsCount() > 0}>
|
||||||
<span class={styles.newReactions}> +{newReactions()}</span>
|
<span class={styles.newReactions}> +{newReactionsCount()}</span>
|
||||||
</Show>
|
</Show>
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<ul class={clsx(styles.commentsViewSwitcher, 'view-switcher')}>
|
<ul class={clsx(styles.commentsViewSwitcher, 'view-switcher')}>
|
||||||
<li classList={{ selected: commentsOrder() === 'createdAt' || !commentsOrder() }}>
|
<li classList={{ selected: commentsOrder() === 'createdAt' }}>
|
||||||
<Button
|
<Button
|
||||||
variant="inline"
|
variant="inline"
|
||||||
value={t('By time')}
|
value={t('By time')}
|
||||||
|
@ -119,30 +141,36 @@ export const CommentsTree = (props: Props) => {
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<ul class={styles.comments}>
|
<ul class={styles.comments}>
|
||||||
<For
|
<For each={sortedComments().filter((r) => !r.replyTo)}>
|
||||||
each={reactions()
|
|
||||||
.reverse()
|
|
||||||
.filter((r) => !r.replyTo)}
|
|
||||||
>
|
|
||||||
{(reaction) => (
|
{(reaction) => (
|
||||||
<Comment
|
<Comment
|
||||||
|
sortedComments={sortedComments()}
|
||||||
isArticleAuthor={Boolean(props.commentAuthors.some((a) => a.slug === session()?.user.slug))}
|
isArticleAuthor={Boolean(props.commentAuthors.some((a) => a.slug === session()?.user.slug))}
|
||||||
reactions={reactions()}
|
|
||||||
comment={reaction}
|
comment={reaction}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</For>
|
</For>
|
||||||
</ul>
|
</ul>
|
||||||
<Show when={isLoadMoreButtonVisible()}>
|
<ShowIfAuthenticated
|
||||||
<button onClick={loadMore}>{t('Load more')}</button>
|
fallback={
|
||||||
</Show>
|
<div class={styles.signInMessage} id="comments">
|
||||||
<ShowOnlyOnClient>
|
{t('To write a comment, you must')}
|
||||||
|
<a href="?modal=auth&mode=register" class={styles.link}>
|
||||||
|
{t('sign up')}
|
||||||
|
</a>
|
||||||
|
{t('or')}
|
||||||
|
<a href="?modal=auth&mode=login" class={styles.link}>
|
||||||
|
{t('sign in')}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
<CommentEditor
|
<CommentEditor
|
||||||
placeholder={t('Write a comment...')}
|
placeholder={t('Write a comment...')}
|
||||||
clear={submitted()}
|
clear={submitted()}
|
||||||
onSubmit={(value) => handleSubmitComment(value)}
|
onSubmit={(value) => handleSubmitComment(value)}
|
||||||
/>
|
/>
|
||||||
</ShowOnlyOnClient>
|
</ShowIfAuthenticated>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
|
@ -4,7 +4,8 @@ import { Icon } from '../_shared/Icon'
|
||||||
import { AuthorCard } from '../Author/Card'
|
import { AuthorCard } from '../Author/Card'
|
||||||
import { createMemo, For, Match, onMount, Show, Switch } from 'solid-js'
|
import { createMemo, For, Match, onMount, Show, Switch } from 'solid-js'
|
||||||
import type { Author, Shout } from '../../graphql/types.gen'
|
import type { Author, Shout } from '../../graphql/types.gen'
|
||||||
import { t } from '../../utils/intl'
|
import { ReactionKind } from '../../graphql/types.gen'
|
||||||
|
|
||||||
import MD from './MD'
|
import MD from './MD'
|
||||||
import { SharePopup } from './SharePopup'
|
import { SharePopup } from './SharePopup'
|
||||||
import { getDescription } from '../../utils/meta'
|
import { getDescription } from '../../utils/meta'
|
||||||
|
@ -18,6 +19,10 @@ import VideoPlayer from './VideoPlayer'
|
||||||
import Slider from '../_shared/Slider'
|
import Slider from '../_shared/Slider'
|
||||||
import { getPagePath } from '@nanostores/router'
|
import { getPagePath } from '@nanostores/router'
|
||||||
import { router } from '../../stores/router'
|
import { router } from '../../stores/router'
|
||||||
|
import { useReactions } from '../../context/reactions'
|
||||||
|
import { loadShout } from '../../stores/zine/articles'
|
||||||
|
import { Title } from '@solidjs/meta'
|
||||||
|
import { useLocalize } from '../../context/localize'
|
||||||
|
|
||||||
interface ArticleProps {
|
interface ArticleProps {
|
||||||
article: Shout
|
article: Shout
|
||||||
|
@ -31,6 +36,7 @@ interface MediaItem {
|
||||||
}
|
}
|
||||||
|
|
||||||
const MediaView = (props: { media: MediaItem; kind: Shout['layout'] }) => {
|
const MediaView = (props: { media: MediaItem; kind: Shout['layout'] }) => {
|
||||||
|
const { t } = useLocalize()
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Switch fallback={<a href={props.media.url}>{t('Cannot show this media type')}</a>}>
|
<Switch fallback={<a href={props.media.url}>{t('Cannot show this media type')}</a>}>
|
||||||
|
@ -52,6 +58,7 @@ const MediaView = (props: { media: MediaItem; kind: Shout['layout'] }) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const FullArticle = (props: ArticleProps) => {
|
export const FullArticle = (props: ArticleProps) => {
|
||||||
|
const { t } = useLocalize()
|
||||||
const { session } = useSession()
|
const { session } = useSession()
|
||||||
const formattedDate = createMemo(() => formatDate(new Date(props.article.createdAt)))
|
const formattedDate = createMemo(() => formatDate(new Date(props.article.createdAt)))
|
||||||
|
|
||||||
|
@ -88,8 +95,31 @@ export const FullArticle = (props: ArticleProps) => {
|
||||||
return mi
|
return mi
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const {
|
||||||
|
actions: { createReaction }
|
||||||
|
} = useReactions()
|
||||||
|
|
||||||
|
const handleUpvote = async () => {
|
||||||
|
await createReaction({
|
||||||
|
kind: ReactionKind.Like,
|
||||||
|
shout: props.article.id
|
||||||
|
})
|
||||||
|
|
||||||
|
await loadShout(props.article.slug)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDownvote = async () => {
|
||||||
|
await createReaction({
|
||||||
|
kind: ReactionKind.Dislike,
|
||||||
|
shout: props.article.id
|
||||||
|
})
|
||||||
|
|
||||||
|
await loadShout(props.article.slug)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<Title>{props.article.title}</Title>
|
||||||
<div class="shout wide-container">
|
<div class="shout wide-container">
|
||||||
<article class="col-md-6 shift-content">
|
<article class="col-md-6 shift-content">
|
||||||
<div class={styles.shoutHeader}>
|
<div class={styles.shoutHeader}>
|
||||||
|
@ -167,7 +197,12 @@ export const FullArticle = (props: ArticleProps) => {
|
||||||
<div class="col-md-8 shift-content">
|
<div class="col-md-8 shift-content">
|
||||||
<div class={styles.shoutStats}>
|
<div class={styles.shoutStats}>
|
||||||
<div class={styles.shoutStatsItem}>
|
<div class={styles.shoutStatsItem}>
|
||||||
<RatingControl rating={props.article.stat?.rating} class={styles.ratingControl} />
|
<RatingControl
|
||||||
|
rating={props.article.stat?.rating}
|
||||||
|
class={styles.ratingControl}
|
||||||
|
onUpvote={handleUpvote}
|
||||||
|
onDownvote={handleDownvote}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Show when={props.article.stat?.viewed}>
|
<Show when={props.article.stat?.viewed}>
|
||||||
|
|
|
@ -4,14 +4,22 @@ import { clsx } from 'clsx'
|
||||||
interface RatingControlProps {
|
interface RatingControlProps {
|
||||||
rating?: number
|
rating?: number
|
||||||
class?: string
|
class?: string
|
||||||
|
onUpvote: () => Promise<void> | void
|
||||||
|
onDownvote: () => Promise<void> | void
|
||||||
|
isUpvoted: boolean
|
||||||
|
isDownvoted: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export const RatingControl = (props: RatingControlProps) => {
|
export const RatingControl = (props: RatingControlProps) => {
|
||||||
return (
|
return (
|
||||||
<div class={clsx(props.class, styles.rating)}>
|
<div class={clsx(props.class, styles.rating)}>
|
||||||
<button class={styles.ratingControl}>−</button>
|
<button class={styles.ratingControl} onClick={props.onDownvote}>
|
||||||
|
−
|
||||||
|
</button>
|
||||||
<span class={styles.ratingValue}>{props?.rating || ''}</span>
|
<span class={styles.ratingValue}>{props?.rating || ''}</span>
|
||||||
<button class={styles.ratingControl}>+</button>
|
<button class={styles.ratingControl} onClick={props.onUpvote}>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,9 +1,10 @@
|
||||||
import { Icon } from '../_shared/Icon'
|
import { Icon } from '../_shared/Icon'
|
||||||
import { t } from '../../utils/intl'
|
|
||||||
import { createSocialShare, TWITTER, VK, FACEBOOK, TELEGRAM } from '@solid-primitives/share'
|
import { createSocialShare, TWITTER, VK, FACEBOOK, TELEGRAM } from '@solid-primitives/share'
|
||||||
import styles from '../_shared/Popup/Popup.module.scss'
|
import styles from '../_shared/Popup/Popup.module.scss'
|
||||||
import type { PopupProps } from '../_shared/Popup'
|
import type { PopupProps } from '../_shared/Popup'
|
||||||
import { Popup } from '../_shared/Popup'
|
import { Popup } from '../_shared/Popup'
|
||||||
|
import { useLocalize } from '../../context/localize'
|
||||||
|
|
||||||
type SharePopupProps = {
|
type SharePopupProps = {
|
||||||
title: string
|
title: string
|
||||||
|
@ -19,6 +20,7 @@ export const getShareUrl = (params: { pathname?: string } = {}) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SharePopup = (props: SharePopupProps) => {
|
export const SharePopup = (props: SharePopupProps) => {
|
||||||
|
const { t } = useLocalize()
|
||||||
const [share] = createSocialShare(() => ({
|
const [share] = createSocialShare(() => ({
|
||||||
title: props.title,
|
title: props.title,
|
||||||
url: props.shareUrl,
|
url: props.shareUrl,
|
||||||
|
|
|
@ -1,8 +1,7 @@
|
||||||
.author {
|
.author {
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-flow: row nowrap;
|
||||||
flex-wrap: nowrap;
|
|
||||||
margin-bottom: 2.4rem;
|
margin-bottom: 2.4rem;
|
||||||
|
|
||||||
&:last-child {
|
&:last-child {
|
||||||
|
|
|
@ -4,8 +4,6 @@ import { Icon } from '../_shared/Icon'
|
||||||
import styles from './Card.module.scss'
|
import styles from './Card.module.scss'
|
||||||
import { createMemo, createSignal, For, Show } from 'solid-js'
|
import { createMemo, createSignal, For, Show } from 'solid-js'
|
||||||
import { translit } from '../../utils/ru2en'
|
import { translit } from '../../utils/ru2en'
|
||||||
import { t } from '../../utils/intl'
|
|
||||||
import { locale } from '../../stores/ui'
|
|
||||||
import { follow, unfollow } from '../../stores/zine/common'
|
import { follow, unfollow } from '../../stores/zine/common'
|
||||||
import { clsx } from 'clsx'
|
import { clsx } from 'clsx'
|
||||||
import { useSession } from '../../context/session'
|
import { useSession } from '../../context/session'
|
||||||
|
@ -14,6 +12,7 @@ import { ShowOnlyOnClient } from '../_shared/ShowOnlyOnClient'
|
||||||
import { FollowingEntity } from '../../graphql/types.gen'
|
import { FollowingEntity } from '../../graphql/types.gen'
|
||||||
import { router, useRouter } from '../../stores/router'
|
import { router, useRouter } from '../../stores/router'
|
||||||
import { openPage } from '@nanostores/router'
|
import { openPage } from '@nanostores/router'
|
||||||
|
import { useLocalize } from '../../context/localize'
|
||||||
|
|
||||||
interface AuthorCardProps {
|
interface AuthorCardProps {
|
||||||
caption?: string
|
caption?: string
|
||||||
|
@ -34,6 +33,8 @@ interface AuthorCardProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AuthorCard = (props: AuthorCardProps) => {
|
export const AuthorCard = (props: AuthorCardProps) => {
|
||||||
|
const { t, lang } = useLocalize()
|
||||||
|
|
||||||
const {
|
const {
|
||||||
session,
|
session,
|
||||||
isSessionLoaded,
|
isSessionLoaded,
|
||||||
|
@ -60,7 +61,7 @@ export const AuthorCard = (props: AuthorCardProps) => {
|
||||||
const canFollow = createMemo(() => !props.hideFollow && session()?.user?.slug !== props.author.slug)
|
const canFollow = createMemo(() => !props.hideFollow && session()?.user?.slug !== props.author.slug)
|
||||||
|
|
||||||
const name = createMemo(() => {
|
const name = createMemo(() => {
|
||||||
if (locale() !== 'ru') {
|
if (lang() !== 'ru') {
|
||||||
if (props.author.name === 'Дискурс') {
|
if (props.author.name === 'Дискурс') {
|
||||||
return 'Discours'
|
return 'Discours'
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,9 +1,11 @@
|
||||||
import styles from './Banner.module.scss'
|
import styles from './Banner.module.scss'
|
||||||
import { t } from '../../utils/intl'
|
|
||||||
import { showModal } from '../../stores/ui'
|
import { showModal } from '../../stores/ui'
|
||||||
import { clsx } from 'clsx'
|
import { clsx } from 'clsx'
|
||||||
|
import { useLocalize } from '../../context/localize'
|
||||||
|
|
||||||
export default () => {
|
export default () => {
|
||||||
|
const { t } = useLocalize()
|
||||||
return (
|
return (
|
||||||
<div class={styles.discoursBanner}>
|
<div class={styles.discoursBanner}>
|
||||||
<div class="wide-container">
|
<div class="wide-container">
|
||||||
|
|
|
@ -1,9 +1,10 @@
|
||||||
import '../../styles/help.scss'
|
import '../../styles/help.scss'
|
||||||
import { createSignal, onMount } from 'solid-js'
|
import { createSignal, onMount } from 'solid-js'
|
||||||
import { showModal, warn } from '../../stores/ui'
|
import { showModal, warn } from '../../stores/ui'
|
||||||
import { t } from '../../utils/intl'
|
import { useLocalize } from '../../context/localize'
|
||||||
|
|
||||||
export const Donate = () => {
|
export const Donate = () => {
|
||||||
|
const { t } = useLocalize()
|
||||||
const once = ''
|
const once = ''
|
||||||
const monthly = 'Monthly'
|
const monthly = 'Monthly'
|
||||||
const cpOptions = {
|
const cpOptions = {
|
||||||
|
|
|
@ -1,7 +1,9 @@
|
||||||
import { hideModal } from '../../stores/ui'
|
import { hideModal } from '../../stores/ui'
|
||||||
import { t } from '../../utils/intl'
|
import { useLocalize } from '../../context/localize'
|
||||||
|
|
||||||
export const Feedback = () => {
|
export const Feedback = () => {
|
||||||
|
const { t } = useLocalize()
|
||||||
|
|
||||||
const action = '/user/feedback'
|
const action = '/user/feedback'
|
||||||
const method = 'post'
|
const method = 'post'
|
||||||
let msgElement: HTMLTextAreaElement | undefined
|
let msgElement: HTMLTextAreaElement | undefined
|
||||||
|
|
|
@ -1,14 +1,16 @@
|
||||||
import { createMemo, For } from 'solid-js'
|
import { createEffect, createMemo, For } from 'solid-js'
|
||||||
import styles from './Footer.module.scss'
|
import styles from './Footer.module.scss'
|
||||||
import { Icon } from '../_shared/Icon'
|
import { Icon } from '../_shared/Icon'
|
||||||
import Subscribe from './Subscribe'
|
import Subscribe from './Subscribe'
|
||||||
import { t } from '../../utils/intl'
|
|
||||||
import { locale } from '../../stores/ui'
|
|
||||||
import { clsx } from 'clsx'
|
import { clsx } from 'clsx'
|
||||||
|
import { useLocalize } from '../../context/localize'
|
||||||
|
|
||||||
export const Footer = () => {
|
export const Footer = () => {
|
||||||
const locale_title = createMemo(() => (locale() === 'ru' ? 'English' : 'Русский'))
|
const { t, lang } = useLocalize()
|
||||||
const locale_link = createMemo(() => '?lang=' + (locale() === 'ru' ? 'en' : 'ru'))
|
|
||||||
|
const changeLangTitle = createMemo(() => (lang() === 'ru' ? 'English' : 'Русский'))
|
||||||
|
const changeLangLink = createMemo(() => '?lng=' + (lang() === 'ru' ? 'en' : 'ru'))
|
||||||
const links = createMemo(() => [
|
const links = createMemo(() => [
|
||||||
{
|
{
|
||||||
header: 'About the project',
|
header: 'About the project',
|
||||||
|
@ -82,8 +84,9 @@ export const Footer = () => {
|
||||||
slug: '/about/projects'
|
slug: '/about/projects'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: locale_title(),
|
title: changeLangTitle(),
|
||||||
slug: locale_link()
|
slug: changeLangLink(),
|
||||||
|
rel: 'external'
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
@ -117,10 +120,12 @@ export const Footer = () => {
|
||||||
<h5>{t(header)}</h5>
|
<h5>{t(header)}</h5>
|
||||||
<ul>
|
<ul>
|
||||||
<For each={items}>
|
<For each={items}>
|
||||||
{({ slug, title }) => (
|
{({ slug, title, ...rest }) => (
|
||||||
<li>
|
<li>
|
||||||
{' '}
|
{' '}
|
||||||
<a href={slug}>{slug.startsWith('?') ? title : t(title)}</a>{' '}
|
<a href={slug} {...rest}>
|
||||||
|
{slug.startsWith('?') ? title : t(title)}
|
||||||
|
</a>{' '}
|
||||||
</li>
|
</li>
|
||||||
)}
|
)}
|
||||||
</For>
|
</For>
|
||||||
|
@ -137,12 +142,14 @@ export const Footer = () => {
|
||||||
|
|
||||||
<div class={clsx(styles.footerCopyright, 'row')}>
|
<div class={clsx(styles.footerCopyright, 'row')}>
|
||||||
<div class="col-md-9 col-lg-10">
|
<div class="col-md-9 col-lg-10">
|
||||||
Независимый журнал с открытой горизонтальной редакцией о культуре, науке
|
{t(
|
||||||
и обществе. Дискурс © 2015–2022{' '}
|
'Independant magazine with an open horizontal cooperation about culture, science and society'
|
||||||
|
)}
|
||||||
|
. {t('Discours')} © 2015–{new Date().getFullYear()}{' '}
|
||||||
<a href="/about/terms-of-use">{t('Terms of use')}</a>
|
<a href="/about/terms-of-use">{t('Terms of use')}</a>
|
||||||
</div>
|
</div>
|
||||||
<div class={clsx(styles.footerCopyrightSocial, 'col-md-3 col-lg-2')}>
|
<div class={clsx(styles.footerCopyrightSocial, 'col-md-3 col-lg-2')}>
|
||||||
<For each={[...SOCIAL]}>
|
<For each={SOCIAL}>
|
||||||
{(social) => (
|
{(social) => (
|
||||||
<div class={clsx(styles.socialItem, styles[`socialItem${social.name}`])}>
|
<div class={clsx(styles.socialItem, styles[`socialItem${social.name}`])}>
|
||||||
<a href={social.href}>
|
<a href={social.href}>
|
||||||
|
|
|
@ -1,8 +1,10 @@
|
||||||
import './Hero.scss'
|
import './Hero.scss'
|
||||||
import { t } from '../../utils/intl'
|
|
||||||
import { showModal } from '../../stores/ui'
|
import { showModal } from '../../stores/ui'
|
||||||
|
import { useLocalize } from '../../context/localize'
|
||||||
|
|
||||||
export default () => {
|
export default () => {
|
||||||
|
const { t } = useLocalize()
|
||||||
return (
|
return (
|
||||||
<div class="about-discours">
|
<div class="about-discours">
|
||||||
<div class="wide-container">
|
<div class="wide-container">
|
||||||
|
|
|
@ -1,19 +1,21 @@
|
||||||
import styles from './ProfileSettingsNavigation.module.scss'
|
import styles from './ProfileSettingsNavigation.module.scss'
|
||||||
import { clsx } from 'clsx'
|
import { clsx } from 'clsx'
|
||||||
|
import { useLocalize } from '../../context/localize'
|
||||||
|
|
||||||
export default () => {
|
export default () => {
|
||||||
|
const { t } = useLocalize()
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<h4 class={styles.navigationHeader}>Настройки</h4>
|
<h4 class={styles.navigationHeader}>{t('Settings')}</h4>
|
||||||
<ul class={clsx(styles.navigation, 'nodash')}>
|
<ul class={clsx(styles.navigation, 'nodash')}>
|
||||||
<li>
|
<li>
|
||||||
<a href="/profile/settings">Профиль</a>
|
<a href="/profile/settings">{t('Profile')}</a>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a href="/profile/subscriptions">Подписки</a>
|
<a href="/profile/subscriptions">{t('Subscriptions')}</a>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a href="/profile/security">Вход и безопасность</a>
|
<a href="/profile/security">{t('Security')}</a>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</>
|
</>
|
||||||
|
|
|
@ -1,9 +1,11 @@
|
||||||
import { createSignal } from 'solid-js'
|
import { createSignal } from 'solid-js'
|
||||||
import styles from './Subscribe.module.scss'
|
import styles from './Subscribe.module.scss'
|
||||||
import { t } from '../../utils/intl'
|
|
||||||
import { clsx } from 'clsx'
|
import { clsx } from 'clsx'
|
||||||
|
import { useLocalize } from '../../context/localize'
|
||||||
|
|
||||||
export default () => {
|
export default () => {
|
||||||
|
const { t } = useLocalize()
|
||||||
let emailElement: HTMLInputElement | undefined
|
let emailElement: HTMLInputElement | undefined
|
||||||
const [title, setTitle] = createSignal('')
|
const [title, setTitle] = createSignal('')
|
||||||
const subscribe = async () => {
|
const subscribe = async () => {
|
||||||
|
|
|
@ -1,73 +0,0 @@
|
||||||
.editor {
|
|
||||||
flex: 1;
|
|
||||||
padding-top: 1em;
|
|
||||||
|
|
||||||
a {
|
|
||||||
color: rgb(0 100 200);
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
a:hover {
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
|
|
||||||
a:visited {
|
|
||||||
color: rgb(0 80 160);
|
|
||||||
}
|
|
||||||
|
|
||||||
label {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
input,
|
|
||||||
button,
|
|
||||||
select,
|
|
||||||
textarea {
|
|
||||||
font-family: inherit;
|
|
||||||
font-size: inherit;
|
|
||||||
-webkit-padding: 0.4em 0;
|
|
||||||
padding: 0.4em;
|
|
||||||
margin: 0 0 0.5em;
|
|
||||||
box-sizing: border-box;
|
|
||||||
border: 1px solid #ccc;
|
|
||||||
border-radius: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
input:disabled {
|
|
||||||
color: #ccc;
|
|
||||||
}
|
|
||||||
|
|
||||||
button {
|
|
||||||
color: #333;
|
|
||||||
background-color: #f4f4f4;
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
button:disabled {
|
|
||||||
color: #999;
|
|
||||||
}
|
|
||||||
|
|
||||||
button:not(:disabled):active {
|
|
||||||
background-color: #ddd;
|
|
||||||
}
|
|
||||||
|
|
||||||
button:focus {
|
|
||||||
border-color: #666;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.error {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.markdown {
|
|
||||||
white-space: pre-wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tooltip {
|
|
||||||
background: #fff;
|
|
||||||
box-shadow: 0 4px 10px rgb(0 0 0 / 25%);
|
|
||||||
color: #000;
|
|
||||||
display: flex;
|
|
||||||
position: absolute;
|
|
||||||
}
|
|
|
@ -1,28 +0,0 @@
|
||||||
import type { EditorView } from 'prosemirror-view'
|
|
||||||
import type { EditorState } from 'prosemirror-state'
|
|
||||||
import { useState } from '../store/context'
|
|
||||||
import { ProseMirror } from './ProseMirror'
|
|
||||||
import styles from './Editor.module.scss'
|
|
||||||
import { clsx } from 'clsx'
|
|
||||||
|
|
||||||
export const Editor = () => {
|
|
||||||
const [store, ctrl] = useState()
|
|
||||||
const onInit = (text: EditorState, editorView: EditorView) => ctrl.setState({ editorView, text })
|
|
||||||
const onReconfigure = (text: EditorState) => ctrl.setState({ text })
|
|
||||||
const onChange = (text: EditorState) => ctrl.setState({ text, lastModified: new Date() })
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ProseMirror
|
|
||||||
cssClass={clsx(styles.editor, 'col-md-6', 'shift-content', {
|
|
||||||
[styles.error]: store.error,
|
|
||||||
[styles.markdown]: store.markdown
|
|
||||||
})}
|
|
||||||
editorView={store.editorView}
|
|
||||||
text={store.text}
|
|
||||||
extensions={store.extensions}
|
|
||||||
onInit={onInit}
|
|
||||||
onReconfigure={onReconfigure}
|
|
||||||
onChange={onChange}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
|
@ -1,52 +0,0 @@
|
||||||
.error {
|
|
||||||
width: 100%;
|
|
||||||
overflow: auto;
|
|
||||||
padding: 50px;
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
|
|
||||||
::-webkit-scrollbar {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
button {
|
|
||||||
height: 50px;
|
|
||||||
padding: 0 20px;
|
|
||||||
font-size: 18px;
|
|
||||||
cursor: pointer;
|
|
||||||
display: inline-flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
outline: none;
|
|
||||||
text-decoration: none;
|
|
||||||
background: none;
|
|
||||||
font-family: inherit;
|
|
||||||
color: var(--foreground);
|
|
||||||
border: 1px solid var(--foreground);
|
|
||||||
|
|
||||||
&.primary {
|
|
||||||
color: var(--primary-foreground);
|
|
||||||
border: 0;
|
|
||||||
background: var(--primary-background);
|
|
||||||
}
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
opacity: 0.8;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.container {
|
|
||||||
max-width: 800px;
|
|
||||||
width: 100%;
|
|
||||||
height: fit-content;
|
|
||||||
}
|
|
||||||
|
|
||||||
pre {
|
|
||||||
background: var(--foreground);
|
|
||||||
border: 1px solid var(--foreground);
|
|
||||||
white-space: pre-wrap;
|
|
||||||
word-wrap: break-word;
|
|
||||||
border-radius: 2px;
|
|
||||||
padding: 10px;
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,68 +0,0 @@
|
||||||
import { Switch, Match } from 'solid-js'
|
|
||||||
import { useState } from '../store/context'
|
|
||||||
import styles from './Error.module.scss'
|
|
||||||
|
|
||||||
export default () => {
|
|
||||||
const [store] = useState()
|
|
||||||
return (
|
|
||||||
<Switch fallback={<Other />}>
|
|
||||||
<Match when={store.error.id === 'invalid_state'}>
|
|
||||||
<InvalidState title="Invalid State" />
|
|
||||||
</Match>
|
|
||||||
<Match when={store.error.id === 'invalid_config'}>
|
|
||||||
<InvalidState title="Invalid Config" />
|
|
||||||
</Match>
|
|
||||||
<Match when={store.error.id === 'invalid_draft'}>
|
|
||||||
<InvalidState title="Invalid Draft" />
|
|
||||||
</Match>
|
|
||||||
</Switch>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const InvalidState = (props: { title: string }) => {
|
|
||||||
const [store, ctrl] = useState()
|
|
||||||
const onClick = () => ctrl.clean()
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div class={styles.error}>
|
|
||||||
<div class={styles.container}>
|
|
||||||
<h1>{props.title}</h1>
|
|
||||||
<p>
|
|
||||||
There is an error with the editor state. This is probably due to an old version in which the data
|
|
||||||
structure has changed. Automatic data migrations may be supported in the future. To fix this now,
|
|
||||||
you can copy important notes from below, clean the state and paste it again.
|
|
||||||
</p>
|
|
||||||
<pre>
|
|
||||||
<code>{JSON.stringify(store.error.props)}</code>
|
|
||||||
</pre>
|
|
||||||
<button class={styles.primary} onClick={onClick}>
|
|
||||||
Clean
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const Other = () => {
|
|
||||||
const [store, ctrl] = useState()
|
|
||||||
const onClick = () => ctrl.discard()
|
|
||||||
|
|
||||||
const getMessage = () => {
|
|
||||||
const err = (store.error.props as any).error
|
|
||||||
return typeof err === 'string' ? err : err.message
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div class={styles.error}>
|
|
||||||
<div class={styles.container}>
|
|
||||||
<h1>An error occurred.</h1>
|
|
||||||
<pre>
|
|
||||||
<code>{getMessage()}</code>
|
|
||||||
</pre>
|
|
||||||
<button class={styles.primary} onClick={onClick}>
|
|
||||||
Close
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
|
@ -1,19 +0,0 @@
|
||||||
.layout {
|
|
||||||
display: flex;
|
|
||||||
font-size: 18px;
|
|
||||||
background: var(--background);
|
|
||||||
color: var(--foreground);
|
|
||||||
border-color: var(--background);
|
|
||||||
min-height: 100vh;
|
|
||||||
|
|
||||||
.dark & {
|
|
||||||
background: var(--foreground);
|
|
||||||
color: var(--background);
|
|
||||||
border-color: var(--foreground);
|
|
||||||
}
|
|
||||||
|
|
||||||
.drop-cursor {
|
|
||||||
height: 2px !important;
|
|
||||||
opacity: 0.5;
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,24 +0,0 @@
|
||||||
import type { JSX } from 'solid-js/jsx-runtime'
|
|
||||||
import type { Config } from '../store/context'
|
|
||||||
import { clsx } from 'clsx'
|
|
||||||
import styles from './Layout.module.scss'
|
|
||||||
|
|
||||||
export type Styled = {
|
|
||||||
children: JSX.Element
|
|
||||||
config?: Config
|
|
||||||
'data-testid'?: string
|
|
||||||
onClick?: () => void
|
|
||||||
onMouseEnter?: (e: MouseEvent) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export const Layout = (props: Styled) => {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
onMouseEnter={props.onMouseEnter}
|
|
||||||
class={clsx(styles.layout, 'container')}
|
|
||||||
data-testid={props['data-testid']}
|
|
||||||
>
|
|
||||||
{props.children}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
|
@ -1,107 +0,0 @@
|
||||||
import { createEffect, untrack } from 'solid-js'
|
|
||||||
import { Store, unwrap } from 'solid-js/store'
|
|
||||||
import { EditorState, EditorStateConfig, Transaction } from 'prosemirror-state'
|
|
||||||
import { EditorView } from 'prosemirror-view'
|
|
||||||
import { Schema } from 'prosemirror-model'
|
|
||||||
import type { NodeViewFn, ProseMirrorExtension, ProseMirrorState } from '../prosemirror/helpers'
|
|
||||||
import '../styles/ProseMirror.scss'
|
|
||||||
|
|
||||||
interface ProseMirrorProps {
|
|
||||||
cssClass?: string
|
|
||||||
text?: Store<ProseMirrorState>
|
|
||||||
editorView?: Store<EditorView>
|
|
||||||
extensions?: Store<ProseMirrorExtension[]>
|
|
||||||
onInit: (s: EditorState, v: EditorView) => void
|
|
||||||
onReconfigure: (s: EditorState) => void
|
|
||||||
onChange: (s: EditorState) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ProseMirror = (props: ProseMirrorProps) => {
|
|
||||||
let editorRef: HTMLDivElement
|
|
||||||
const editorView = () => untrack(() => unwrap(props.editorView))
|
|
||||||
|
|
||||||
const dispatchTransaction = (tr: Transaction) => {
|
|
||||||
if (!editorView()) return
|
|
||||||
const newState = editorView().state.apply(tr)
|
|
||||||
editorView().updateState(newState)
|
|
||||||
if (!tr.docChanged) return
|
|
||||||
props.onChange(newState)
|
|
||||||
}
|
|
||||||
|
|
||||||
createEffect(
|
|
||||||
(payload: [EditorState, ProseMirrorExtension[]]) => {
|
|
||||||
const [prevText, prevExtensions] = payload
|
|
||||||
const text = unwrap(props.text)
|
|
||||||
const extensions: ProseMirrorExtension[] = unwrap(props.extensions)
|
|
||||||
if (!text || !extensions?.length) {
|
|
||||||
return [text, extensions]
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!props.editorView) {
|
|
||||||
const { editorState, nodeViews } = createEditorState(text, extensions)
|
|
||||||
const view = new EditorView(editorRef, { state: editorState, nodeViews, dispatchTransaction })
|
|
||||||
view.focus()
|
|
||||||
props.onInit(editorState, view)
|
|
||||||
return [editorState, extensions]
|
|
||||||
}
|
|
||||||
|
|
||||||
if (extensions !== prevExtensions || (!(text instanceof EditorState) && text !== prevText)) {
|
|
||||||
const { editorState, nodeViews } = createEditorState(text, extensions, prevText)
|
|
||||||
if (!editorState) return
|
|
||||||
editorView().updateState(editorState)
|
|
||||||
editorView().setProps({ nodeViews, dispatchTransaction })
|
|
||||||
props.onReconfigure(editorState)
|
|
||||||
editorView().focus()
|
|
||||||
return [editorState, extensions]
|
|
||||||
}
|
|
||||||
|
|
||||||
return [text, extensions]
|
|
||||||
},
|
|
||||||
[props.text, props.extensions]
|
|
||||||
)
|
|
||||||
|
|
||||||
return <div ref={editorRef} class={props.cssClass} spell-check={false} />
|
|
||||||
}
|
|
||||||
|
|
||||||
const createEditorState = (
|
|
||||||
text: ProseMirrorState,
|
|
||||||
extensions: ProseMirrorExtension[],
|
|
||||||
prevText?: EditorState
|
|
||||||
): {
|
|
||||||
editorState: EditorState
|
|
||||||
nodeViews: { [key: string]: NodeViewFn }
|
|
||||||
} => {
|
|
||||||
const reconfigure = text instanceof EditorState && prevText?.schema
|
|
||||||
let schemaSpec = { nodes: {} }
|
|
||||||
let nodeViews = {}
|
|
||||||
let plugins = []
|
|
||||||
|
|
||||||
for (const extension of extensions) {
|
|
||||||
if (extension.schema) {
|
|
||||||
schemaSpec = extension.schema(schemaSpec)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (extension.nodeViews) {
|
|
||||||
nodeViews = { ...nodeViews, ...extension.nodeViews }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const schema = reconfigure ? prevText.schema : new Schema(schemaSpec)
|
|
||||||
for (const extension of extensions) {
|
|
||||||
if (extension.plugins) {
|
|
||||||
plugins = extension.plugins(plugins, schema)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let editorState: EditorState
|
|
||||||
if (reconfigure) {
|
|
||||||
editorState = text.reconfigure({ schema, plugins } as EditorStateConfig)
|
|
||||||
} else if (text instanceof EditorState) {
|
|
||||||
editorState = EditorState.fromJSON({ schema, plugins }, text.toJSON())
|
|
||||||
} else if (text) {
|
|
||||||
console.debug(text)
|
|
||||||
editorState = EditorState.fromJSON({ schema, plugins }, text)
|
|
||||||
}
|
|
||||||
|
|
||||||
return { editorState, nodeViews }
|
|
||||||
}
|
|
|
@ -1,221 +0,0 @@
|
||||||
.sidebarContainer {
|
|
||||||
color: rgb(255 255 255 / 50%);
|
|
||||||
@include font-size(1.6rem);
|
|
||||||
|
|
||||||
overflow: hidden;
|
|
||||||
position: relative;
|
|
||||||
top: 0;
|
|
||||||
|
|
||||||
p {
|
|
||||||
color: var(--foreground);
|
|
||||||
}
|
|
||||||
|
|
||||||
h4 {
|
|
||||||
@include font-size(120%);
|
|
||||||
|
|
||||||
margin-left: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
button {
|
|
||||||
height: auto;
|
|
||||||
min-height: 50px;
|
|
||||||
padding: 0 1rem;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebarOff {
|
|
||||||
background: #1f1f1f;
|
|
||||||
height: 100%;
|
|
||||||
min-height: 100vh;
|
|
||||||
padding: 40px 20px 20px;
|
|
||||||
top: 0;
|
|
||||||
transform: translateX(0);
|
|
||||||
transition: transform 0.3s;
|
|
||||||
overflow-y: auto;
|
|
||||||
scrollbar-width: none;
|
|
||||||
width: 350px;
|
|
||||||
|
|
||||||
.sidebarContainerHidden & {
|
|
||||||
transform: translateX(100%);
|
|
||||||
}
|
|
||||||
|
|
||||||
::-webkit-scrollbar {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebarOpener {
|
|
||||||
color: #000;
|
|
||||||
cursor: pointer;
|
|
||||||
opacity: 1;
|
|
||||||
position: absolute;
|
|
||||||
top: 1em;
|
|
||||||
transition: opacity 0.3s;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
opacity: 0.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
&::after {
|
|
||||||
background-image: url("data:image/svg+xml,%3Csvg width='18' height='18' viewBox='0 0 18 18' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cmask id='mask0_1090_23825' style='mask-type:alpha' maskUnits='userSpaceOnUse' x='0' y='14' width='4' height='4'%3E%3Crect y='14.8237' width='3.17647' height='3.17647' fill='%23fff'/%3E%3C/mask%3E%3Cg mask='url(%23mask0_1090_23825)'%3E%3Cpath d='M16.0941 1.05908H0.847027C0.379194 1.05908 0 1.43828 0 1.90611V18.0003L3.38824 14.612H16.0942C16.562 14.612 16.9412 14.2328 16.9412 13.765V1.90614C16.9412 1.43831 16.562 1.05912 16.0942 1.05912L16.0941 1.05908ZM15.2471 12.9179H1.69412V2.7532H15.2471V12.9179Z' fill='black'/%3E%3C/g%3E%3Crect x='1' y='1' width='16' height='12.8235' stroke='black' stroke-width='2'/%3E%3Crect x='4.23535' y='3.17627' width='9.52941' height='2.11765' fill='black'/%3E%3Crect x='4.23535' y='9.5293' width='7.41176' height='2.11765' fill='black'/%3E%3Crect x='4.23535' y='6.35303' width='5.29412' height='2.11765' fill='black'/%3E%3C/svg%3E");
|
|
||||||
content: '';
|
|
||||||
height: 18px;
|
|
||||||
left: 100%;
|
|
||||||
margin-left: 0.3em;
|
|
||||||
position: absolute;
|
|
||||||
top: 50%;
|
|
||||||
transform: translateY(-50%);
|
|
||||||
width: 18px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebarCloser {
|
|
||||||
background-image: url("data:image/svg+xml,%3Csvg width='16' height='16' viewBox='0 0 16 16' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M13.1517 0.423857L0.42375 13.1518L2.84812 15.5761L15.576 2.84822L13.1517 0.423857Z M15.576 13.1518L2.84812 0.423855L0.423751 2.84822L13.1517 15.5761L15.576 13.1518Z' fill='white'/%3E%3C/svg%3E%0A");
|
|
||||||
cursor: pointer;
|
|
||||||
height: 16px;
|
|
||||||
opacity: 1;
|
|
||||||
position: absolute;
|
|
||||||
transition: opacity 0.3s;
|
|
||||||
top: 20px;
|
|
||||||
width: 16px;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
opacity: 0.5;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebarLabel {
|
|
||||||
color: var(--foreground);
|
|
||||||
|
|
||||||
> i {
|
|
||||||
text-transform: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebarContainer button,
|
|
||||||
.sidebarContainer a,
|
|
||||||
.sidebarItem {
|
|
||||||
margin: 0;
|
|
||||||
outline: none;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
line-height: 24px;
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebarContainer a,
|
|
||||||
.sidebarItem {
|
|
||||||
font-size: 18px;
|
|
||||||
padding: 2px 0;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebarLink {
|
|
||||||
background: none;
|
|
||||||
border: 0;
|
|
||||||
color: inherit;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: inherit;
|
|
||||||
justify-content: flex-start;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
color: #fff !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:active {
|
|
||||||
> span i {
|
|
||||||
position: relative;
|
|
||||||
box-shadow: none;
|
|
||||||
top: 1px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&[disabled] {
|
|
||||||
color: var(--foreground);
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.draft {
|
|
||||||
color: rgb(255 255 255 / 50%);
|
|
||||||
line-height: 1.4;
|
|
||||||
margin: 0 0 1em 1.5em;
|
|
||||||
width: calc(100% - 2rem);
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
> span {
|
|
||||||
justify-self: flex-end;
|
|
||||||
margin-left: auto;
|
|
||||||
|
|
||||||
> i {
|
|
||||||
border: 1px solid;
|
|
||||||
border-bottom-width: 2px;
|
|
||||||
border-radius: 0.2rem;
|
|
||||||
display: inline-block;
|
|
||||||
color: inherit;
|
|
||||||
font-size: 13px;
|
|
||||||
line-height: 1.4;
|
|
||||||
margin: 0 0.5em 0 0;
|
|
||||||
padding: 1px 4px;
|
|
||||||
|
|
||||||
&:last-child {
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.themeSwitcher {
|
|
||||||
border-bottom: 1px solid rgb(255 255 255 / 30%);
|
|
||||||
border-top: 1px solid rgb(255 255 255 / 30%);
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
margin: 1rem;
|
|
||||||
padding: 1em 0;
|
|
||||||
|
|
||||||
input[type='checkbox'] {
|
|
||||||
opacity: 0;
|
|
||||||
position: absolute;
|
|
||||||
|
|
||||||
+ label {
|
|
||||||
background: url("data:image/svg+xml,%3Csvg width='10' height='10' viewBox='0 0 10 10' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M6.20869 7.73227C5.22953 7.36499 4.38795 6.70402 3.79906 5.83976C3.2103 4.97565 2.90318 3.95064 2.91979 2.90512C2.93639 1.8597 3.27597 0.844915 3.8919 0C2.82862 0.254038 1.87585 0.844877 1.17594 1.68438C0.475894 2.52388 0.0660276 3.5671 0.00731938 4.6585C-0.0513888 5.74989 0.244296 6.83095 0.850296 7.74073C1.45631 8.65037 2.34006 9.33992 3.36994 9.70637C4.39987 10.073 5.52063 10.0969 6.56523 9.77466C7.60985 9.45247 8.52223 8.80134 9.16667 7.91837C8.1842 8.15404 7.15363 8.08912 6.20869 7.73205V7.73227Z' fill='white'/%3E%3C/svg%3E%0A")
|
|
||||||
no-repeat 30px 9px,
|
|
||||||
url("data:image/svg+xml,%3Csvg width='12' height='12' viewBox='0 0 12 12' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M6.41196 0H5.58811V2.43024H6.41196V0ZM5.99988 8.96576C4.36601 8.96576 3.03419 7.63397 3.03419 6.00007C3.04792 4.3662 4.36598 3.04818 5.99988 3.03439C7.63375 3.03439 8.96557 4.3662 8.96557 6.00007C8.96557 7.63395 7.63375 8.96576 5.99988 8.96576ZM5.58811 9.56977H6.41196V12H5.58811V9.56977ZM12.0002 5.58811H9.56996V6.41196H12.0002V5.58811ZM0 5.58811H2.43024V6.41196H0V5.58811ZM8.81339 3.76727L10.5318 2.04891L9.94925 1.46641L8.23089 3.18477L8.81339 3.76727ZM3.7745 8.8129L2.05614 10.5313L1.47364 9.94877L3.192 8.2304L3.7745 8.8129ZM9.95043 10.5269L10.5329 9.94437L8.81456 8.22601L8.23207 8.80851L9.95043 10.5269ZM3.76864 3.18731L3.18614 3.76981L1.46778 2.05145L2.05028 1.46895L3.76864 3.18731Z' fill='%231F1F1F'/%3E%3C/svg%3E%0A")
|
|
||||||
#000 no-repeat 8px 8px;
|
|
||||||
border-radius: 14px;
|
|
||||||
cursor: pointer;
|
|
||||||
display: block;
|
|
||||||
height: 28px;
|
|
||||||
line-height: 10em;
|
|
||||||
overflow: hidden;
|
|
||||||
position: relative;
|
|
||||||
transition: background-color 0.3s;
|
|
||||||
width: 46px;
|
|
||||||
|
|
||||||
&::before {
|
|
||||||
background-color: #fff;
|
|
||||||
border-radius: 100%;
|
|
||||||
content: '';
|
|
||||||
height: 16px;
|
|
||||||
left: 6px;
|
|
||||||
position: absolute;
|
|
||||||
top: 6px;
|
|
||||||
transition: left 0.3s, color 0.3s;
|
|
||||||
width: 16px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&:checked + label {
|
|
||||||
background-color: #fff;
|
|
||||||
|
|
||||||
&::before {
|
|
||||||
background-color: #1f1f1f;
|
|
||||||
left: 24px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,225 +0,0 @@
|
||||||
import { For, Show, createEffect, createSignal, onCleanup, onMount } from 'solid-js'
|
|
||||||
import { unwrap } from 'solid-js/store'
|
|
||||||
import { undo, redo } from 'prosemirror-history'
|
|
||||||
import { Draft, useState } from '../store/context'
|
|
||||||
import * as remote from '../remote'
|
|
||||||
import { isEmpty } from '../prosemirror/helpers'
|
|
||||||
import type { Styled } from './Layout'
|
|
||||||
import { clsx } from 'clsx'
|
|
||||||
import styles from './Sidebar.module.scss'
|
|
||||||
import { useOutsideClickHandler } from '../../../utils/useOutsideClickHandler'
|
|
||||||
import { useEscKeyDownHandler } from '../../../utils/useEscKeyDownHandler'
|
|
||||||
|
|
||||||
const Off = (props) => <div class={styles.sidebarOff}>{props.children}</div>
|
|
||||||
|
|
||||||
const Label = (props: Styled) => <h3 class={styles.sidebarLabel}>{props.children}</h3>
|
|
||||||
|
|
||||||
const Link = (
|
|
||||||
props: Styled & { withMargin?: boolean; disabled?: boolean; title?: string; className?: string }
|
|
||||||
) => (
|
|
||||||
<button
|
|
||||||
class={clsx(styles.sidebarLink, props.className, {
|
|
||||||
[styles.withMargin]: props.withMargin
|
|
||||||
})}
|
|
||||||
onClick={props.onClick}
|
|
||||||
disabled={props.disabled}
|
|
||||||
title={props.title}
|
|
||||||
data-testid={props['data-testid']}
|
|
||||||
>
|
|
||||||
{props.children}
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
|
|
||||||
export const Sidebar = () => {
|
|
||||||
const [store, ctrl] = useState()
|
|
||||||
const [lastAction, setLastAction] = createSignal<string | undefined>()
|
|
||||||
|
|
||||||
const toggleTheme = () => {
|
|
||||||
document.body.classList.toggle('dark')
|
|
||||||
ctrl.updateConfig({ theme: document.body.className })
|
|
||||||
}
|
|
||||||
|
|
||||||
const collabText = () => {
|
|
||||||
if (store.collab?.started) {
|
|
||||||
return 'Stop'
|
|
||||||
}
|
|
||||||
|
|
||||||
if (store.collab?.error) {
|
|
||||||
return 'Restart 🚨'
|
|
||||||
}
|
|
||||||
|
|
||||||
return 'Start'
|
|
||||||
}
|
|
||||||
|
|
||||||
const discardText = () => {
|
|
||||||
if (store.path) {
|
|
||||||
return 'Close'
|
|
||||||
}
|
|
||||||
|
|
||||||
if (store.drafts.length > 0 && isEmpty(store.text)) {
|
|
||||||
return 'Delete ⚠️'
|
|
||||||
}
|
|
||||||
|
|
||||||
return 'Clear'
|
|
||||||
}
|
|
||||||
|
|
||||||
const editorView = () => unwrap(store.editorView)
|
|
||||||
const onToggleMarkdown = () => ctrl.toggleMarkdown()
|
|
||||||
const onOpenDraft = (draft: Draft) => ctrl.openDraft(unwrap(draft))
|
|
||||||
const collabUsers = () => store.collab?.y?.provider.awareness.meta.size ?? 0
|
|
||||||
const onUndo = () => undo(editorView().state, editorView().dispatch)
|
|
||||||
const onRedo = () => redo(editorView().state, editorView().dispatch)
|
|
||||||
const onCopyAllAsMd = () =>
|
|
||||||
remote.copyAllAsMarkdown(editorView().state).then(() => setLastAction('copy-md'))
|
|
||||||
const onDiscard = () => ctrl.discard()
|
|
||||||
const [isHidden, setIsHidden] = createSignal(true)
|
|
||||||
|
|
||||||
const toggleSidebar = () => {
|
|
||||||
setIsHidden((oldIsHidden) => !oldIsHidden)
|
|
||||||
}
|
|
||||||
|
|
||||||
const onCollab = () => {
|
|
||||||
const state = unwrap(store)
|
|
||||||
store.collab?.started ? ctrl.stopCollab(state) : ctrl.startCollab(state)
|
|
||||||
}
|
|
||||||
|
|
||||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
|
||||||
const DraftLink = (p: { draft: Draft }) => {
|
|
||||||
const length = 100
|
|
||||||
let content = ''
|
|
||||||
const getContent = (node: any) => {
|
|
||||||
if (node.text) {
|
|
||||||
content += node.text
|
|
||||||
}
|
|
||||||
|
|
||||||
if (content.length > length) {
|
|
||||||
content = content.slice(0, Math.max(0, length)) + '...'
|
|
||||||
return content
|
|
||||||
}
|
|
||||||
|
|
||||||
if (node.content) {
|
|
||||||
for (const child of node.content) {
|
|
||||||
if (content.length >= length) {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
content = getContent(child)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return content
|
|
||||||
}
|
|
||||||
|
|
||||||
const text = () =>
|
|
||||||
p.draft.path
|
|
||||||
? p.draft.path.slice(Math.max(0, p.draft.path.length - length))
|
|
||||||
: getContent(p.draft.text?.doc)
|
|
||||||
|
|
||||||
return (
|
|
||||||
// eslint-disable-next-line solid/no-react-specific-props
|
|
||||||
<Link className={styles.draft} onClick={() => onOpenDraft(p.draft)} data-testid="open">
|
|
||||||
{text()} {p.draft.path && '📎'}
|
|
||||||
</Link>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const Keys = (props) => (
|
|
||||||
<span>
|
|
||||||
<For each={props.keys}>{(k: Element) => <i>{k}</i>}</For>
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
setLastAction()
|
|
||||||
})
|
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
if (!lastAction()) return
|
|
||||||
const id = setTimeout(() => {
|
|
||||||
setLastAction()
|
|
||||||
}, 1000)
|
|
||||||
onCleanup(() => clearTimeout(id))
|
|
||||||
})
|
|
||||||
|
|
||||||
const [mod, setMod] = createSignal<'Ctrl' | 'Cmd'>('Ctrl')
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
setMod(navigator.platform.includes('Mac') ? 'Cmd' : 'Ctrl')
|
|
||||||
})
|
|
||||||
|
|
||||||
const containerRef: { current: HTMLElement } = {
|
|
||||||
current: null
|
|
||||||
}
|
|
||||||
|
|
||||||
useEscKeyDownHandler(() => setIsHidden(true))
|
|
||||||
useOutsideClickHandler({
|
|
||||||
containerRef,
|
|
||||||
predicate: () => !isHidden(),
|
|
||||||
handler: () => setIsHidden(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
class={clsx(styles.sidebarContainer, {
|
|
||||||
[styles.sidebarContainerHidden]: isHidden()
|
|
||||||
})}
|
|
||||||
ref={(el) => (containerRef.current = el)}
|
|
||||||
>
|
|
||||||
<span class={styles.sidebarOpener} onClick={toggleSidebar}>
|
|
||||||
Советы и предложения
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<Off onClick={() => editorView().focus()}>
|
|
||||||
<div class={styles.sidebarCloser} onClick={toggleSidebar} />
|
|
||||||
|
|
||||||
<div>
|
|
||||||
{store.path && (
|
|
||||||
<Label>
|
|
||||||
<i>({store.path.slice(Math.max(0, store.path.length - 24))})</i>
|
|
||||||
</Label>
|
|
||||||
)}
|
|
||||||
<Link>Пригласить соавторов</Link>
|
|
||||||
<Link>Настройки публикации</Link>
|
|
||||||
<Link>История правок</Link>
|
|
||||||
|
|
||||||
<div class={styles.themeSwitcher}>
|
|
||||||
Ночная тема
|
|
||||||
<input type="checkbox" name="theme" id="theme" onClick={toggleTheme} />
|
|
||||||
<label for="theme">Ночная тема</label>
|
|
||||||
</div>
|
|
||||||
<Link
|
|
||||||
onClick={onDiscard}
|
|
||||||
disabled={!store.path && store.drafts.length === 0 && isEmpty(store.text)}
|
|
||||||
data-testid="discard"
|
|
||||||
>
|
|
||||||
{discardText()} <Keys keys={[mod(), 'w']} />
|
|
||||||
</Link>
|
|
||||||
<Link onClick={onUndo}>
|
|
||||||
Undo <Keys keys={[mod(), 'z']} />
|
|
||||||
</Link>
|
|
||||||
<Link onClick={onRedo}>
|
|
||||||
Redo <Keys keys={[mod(), 'Shift', 'z']} />
|
|
||||||
</Link>
|
|
||||||
<Link onClick={onToggleMarkdown} data-testid="markdown">
|
|
||||||
Markdown mode {store.markdown && '✅'} <Keys keys={[mod(), 'm']} />
|
|
||||||
</Link>
|
|
||||||
<Link onClick={onCopyAllAsMd}>Copy all as MD {lastAction() === 'copy-md' && '📋'}</Link>
|
|
||||||
<Show when={store.drafts.length > 0}>
|
|
||||||
<h4>Drafts:</h4>
|
|
||||||
<p>
|
|
||||||
<For each={store.drafts}>{(draft) => <DraftLink draft={draft} />}</For>
|
|
||||||
</p>
|
|
||||||
</Show>
|
|
||||||
<Link onClick={onCollab} title={store.collab?.error ? 'Connection error' : ''}>
|
|
||||||
Collab {collabText()}
|
|
||||||
</Link>
|
|
||||||
<Show when={collabUsers() > 0}>
|
|
||||||
<span>
|
|
||||||
{collabUsers()} {collabUsers() === 1 ? 'user' : 'users'} connected
|
|
||||||
</span>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
</Off>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
|
@ -1,32 +0,0 @@
|
||||||
import { openDB } from 'idb'
|
|
||||||
|
|
||||||
const dbPromise = () => {
|
|
||||||
return openDB('discours.io', 2, {
|
|
||||||
upgrade(db) {
|
|
||||||
db.createObjectStore('keyval')
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export default {
|
|
||||||
async get(key: string) {
|
|
||||||
const result = await dbPromise()
|
|
||||||
return result.get('keyval', key)
|
|
||||||
},
|
|
||||||
async set(key: string, val: string) {
|
|
||||||
const result = await dbPromise()
|
|
||||||
return result.put('keyval', val, key)
|
|
||||||
},
|
|
||||||
async delete(key: string) {
|
|
||||||
const result = await dbPromise()
|
|
||||||
return result.delete('keyval', key)
|
|
||||||
},
|
|
||||||
async clear() {
|
|
||||||
const result = await dbPromise()
|
|
||||||
return result.clear('keyval')
|
|
||||||
},
|
|
||||||
async keys() {
|
|
||||||
const result = await dbPromise()
|
|
||||||
return result.getAllKeys('keyval')
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,2 +0,0 @@
|
||||||
export const isDark = () =>
|
|
||||||
typeof window !== undefined && window.matchMedia('(prefers-color-scheme: dark)').matches
|
|
|
@ -1,207 +0,0 @@
|
||||||
import markdownit from 'markdown-it'
|
|
||||||
import {
|
|
||||||
MarkdownSerializer,
|
|
||||||
MarkdownParser,
|
|
||||||
defaultMarkdownSerializer,
|
|
||||||
MarkdownSerializerState
|
|
||||||
} from 'prosemirror-markdown'
|
|
||||||
import type { Node, Schema } from 'prosemirror-model'
|
|
||||||
import type { EditorState } from 'prosemirror-state'
|
|
||||||
|
|
||||||
export const serialize = (state: EditorState) => {
|
|
||||||
let text = markdownSerializer.serialize(state.doc)
|
|
||||||
if (text.charAt(text.length - 1) !== '\n') {
|
|
||||||
text += '\n'
|
|
||||||
}
|
|
||||||
|
|
||||||
return text
|
|
||||||
}
|
|
||||||
|
|
||||||
function findAlignment(cell: Node): string | null {
|
|
||||||
const alignment = cell.attrs.style as string
|
|
||||||
if (!alignment) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const match = alignment.match(/text-align: ?(left|right|center)/)
|
|
||||||
if (match && match[1]) {
|
|
||||||
return match[1]
|
|
||||||
}
|
|
||||||
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
export const markdownSerializer = new MarkdownSerializer(
|
|
||||||
{
|
|
||||||
...defaultMarkdownSerializer.nodes,
|
|
||||||
image(state: MarkdownSerializerState, node: Node) {
|
|
||||||
const alt = state.esc(node.attrs.alt || '')
|
|
||||||
const src = node.attrs.path ?? node.attrs.src
|
|
||||||
const title = node.attrs.title ? `"${node.attrs.title}"` : undefined
|
|
||||||
state.write(`\n`)
|
|
||||||
/*  */
|
|
||||||
},
|
|
||||||
code_block(state, node) {
|
|
||||||
const src = node.attrs.params.src
|
|
||||||
if (src) {
|
|
||||||
const title = state.esc(node.attrs.params.title || '')
|
|
||||||
state.write(`\n`)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
state.write('```' + (node.attrs.params.lang || '') + '\n')
|
|
||||||
state.text(node.textContent, false)
|
|
||||||
state.ensureNewLine()
|
|
||||||
state.write('```')
|
|
||||||
state.closeBlock(node)
|
|
||||||
},
|
|
||||||
todo_item(state, node) {
|
|
||||||
state.write((node.attrs.done ? '[x]' : '[ ]') + ' ')
|
|
||||||
state.renderContent(node)
|
|
||||||
},
|
|
||||||
table(state, node) {
|
|
||||||
function serializeTableHead(head: Node) {
|
|
||||||
let columnAlignments: string[] = []
|
|
||||||
head.forEach((headRow) => {
|
|
||||||
if (headRow.type.name === 'table_row') {
|
|
||||||
columnAlignments = serializeTableRow(headRow)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// write table header separator
|
|
||||||
for (const alignment of columnAlignments) {
|
|
||||||
state.write('|')
|
|
||||||
state.write(alignment === 'left' || alignment === 'center' ? ':' : ' ')
|
|
||||||
state.write('---')
|
|
||||||
state.write(alignment === 'right' || alignment === 'center' ? ':' : ' ')
|
|
||||||
}
|
|
||||||
state.write('|')
|
|
||||||
state.ensureNewLine()
|
|
||||||
}
|
|
||||||
|
|
||||||
function serializeTableBody(body: Node) {
|
|
||||||
body.forEach((bodyRow) => {
|
|
||||||
if (bodyRow.type.name === 'table_row') {
|
|
||||||
serializeTableRow(bodyRow)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
state.ensureNewLine()
|
|
||||||
}
|
|
||||||
|
|
||||||
function serializeTableRow(row: Node): string[] {
|
|
||||||
const columnAlignment: string[] = []
|
|
||||||
row.forEach((cell) => {
|
|
||||||
if (cell.type.name === 'table_header' || cell.type.name === 'table_cell') {
|
|
||||||
const alignment = serializeTableCell(cell)
|
|
||||||
columnAlignment.push(alignment)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
state.write('|')
|
|
||||||
state.ensureNewLine()
|
|
||||||
return columnAlignment
|
|
||||||
}
|
|
||||||
|
|
||||||
function serializeTableCell(cell: Node): string | null {
|
|
||||||
state.write('| ')
|
|
||||||
state.renderInline(cell)
|
|
||||||
state.write(' ')
|
|
||||||
return findAlignment(cell)
|
|
||||||
}
|
|
||||||
|
|
||||||
node.forEach((table_child) => {
|
|
||||||
if (table_child.type.name === 'table_head') serializeTableHead(table_child)
|
|
||||||
if (table_child.type.name === 'table_body') serializeTableBody(table_child)
|
|
||||||
})
|
|
||||||
|
|
||||||
state.ensureNewLine()
|
|
||||||
state.write('\n')
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
...defaultMarkdownSerializer.marks,
|
|
||||||
strikethrough: {
|
|
||||||
open: '~~',
|
|
||||||
close: '~~',
|
|
||||||
mixable: true,
|
|
||||||
expelEnclosingWhitespace: true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
function listIsTight(tokens: any, idx: number) {
|
|
||||||
let i = idx
|
|
||||||
while (++i < tokens.length) {
|
|
||||||
if (tokens[i].type !== 'list_item_open') return tokens[i].hidden
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
const md = markdownit({ html: false })
|
|
||||||
|
|
||||||
export const createMarkdownParser = (schema: Schema) =>
|
|
||||||
new MarkdownParser(schema, md, {
|
|
||||||
table: { block: 'table' },
|
|
||||||
thead: { block: 'table_head' },
|
|
||||||
tbody: { block: 'table_body' },
|
|
||||||
th: {
|
|
||||||
block: 'table_header',
|
|
||||||
getAttrs: (tok) => ({
|
|
||||||
style: tok.attrGet('style')
|
|
||||||
})
|
|
||||||
},
|
|
||||||
tr: { block: 'table_row' },
|
|
||||||
td: {
|
|
||||||
block: 'table_cell',
|
|
||||||
getAttrs: (tok) => ({
|
|
||||||
style: tok.attrGet('style')
|
|
||||||
})
|
|
||||||
},
|
|
||||||
blockquote: { block: 'blockquote' },
|
|
||||||
paragraph: { block: 'paragraph' },
|
|
||||||
list_item: { block: 'list_item' },
|
|
||||||
bullet_list: {
|
|
||||||
block: 'bullet_list',
|
|
||||||
getAttrs: (_, tokens, i) => ({ tight: listIsTight(tokens, i) })
|
|
||||||
},
|
|
||||||
ordered_list: {
|
|
||||||
block: 'ordered_list',
|
|
||||||
getAttrs: (tok, tokens, i) => ({
|
|
||||||
order: +tok.attrGet('start') || 1,
|
|
||||||
tight: listIsTight(tokens, i)
|
|
||||||
})
|
|
||||||
},
|
|
||||||
heading: {
|
|
||||||
block: 'heading',
|
|
||||||
getAttrs: (tok) => ({ level: +tok.tag.slice(1) })
|
|
||||||
},
|
|
||||||
code_block: {
|
|
||||||
block: 'code_block',
|
|
||||||
noCloseToken: true
|
|
||||||
},
|
|
||||||
fence: {
|
|
||||||
block: 'code_block',
|
|
||||||
getAttrs: (tok) => ({ params: { lang: tok.info } }),
|
|
||||||
noCloseToken: true
|
|
||||||
},
|
|
||||||
hr: { node: 'horizontal_rule' },
|
|
||||||
image: {
|
|
||||||
node: 'image',
|
|
||||||
getAttrs: (tok) => ({
|
|
||||||
src: tok.attrGet('src'),
|
|
||||||
title: tok.attrGet('title') || null,
|
|
||||||
alt: (tok.children[0] && tok.children[0].content) || null
|
|
||||||
})
|
|
||||||
},
|
|
||||||
hardbreak: { node: 'hard_break' },
|
|
||||||
em: { mark: 'em' },
|
|
||||||
strong: { mark: 'strong' },
|
|
||||||
s: { mark: 'strikethrough' },
|
|
||||||
link: {
|
|
||||||
mark: 'link',
|
|
||||||
getAttrs: (tok) => ({
|
|
||||||
href: tok.attrGet('href'),
|
|
||||||
title: tok.attrGet('title') || null
|
|
||||||
})
|
|
||||||
},
|
|
||||||
code_inline: { mark: 'code', noCloseToken: true }
|
|
||||||
})
|
|
|
@ -1,60 +0,0 @@
|
||||||
import { schema as markdownSchema } from 'prosemirror-markdown'
|
|
||||||
import { NodeSpec, Schema } from 'prosemirror-model'
|
|
||||||
import { baseKeymap } from 'prosemirror-commands'
|
|
||||||
import { sinkListItem, liftListItem } from 'prosemirror-schema-list'
|
|
||||||
import { history } from 'prosemirror-history'
|
|
||||||
import { dropCursor } from 'prosemirror-dropcursor'
|
|
||||||
import { buildKeymap } from 'prosemirror-example-setup'
|
|
||||||
import { keymap } from 'prosemirror-keymap'
|
|
||||||
import type { ProseMirrorExtension } from '../helpers'
|
|
||||||
import type OrderedMap from 'orderedmap'
|
|
||||||
|
|
||||||
import layoutStyles from '../../components/Layout.module.scss'
|
|
||||||
|
|
||||||
const plainSchema = new Schema({
|
|
||||||
nodes: {
|
|
||||||
doc: {
|
|
||||||
content: 'block+'
|
|
||||||
},
|
|
||||||
paragraph: {
|
|
||||||
content: 'inline*',
|
|
||||||
group: 'block',
|
|
||||||
parseDOM: [{ tag: 'p' }],
|
|
||||||
toDOM: () => ['p', 0]
|
|
||||||
},
|
|
||||||
text: {
|
|
||||||
group: 'inline'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const blockquoteSchema = {
|
|
||||||
content: 'block+',
|
|
||||||
group: 'block',
|
|
||||||
toDOM: () => ['div', ['blockquote', 0]]
|
|
||||||
} as NodeSpec
|
|
||||||
|
|
||||||
export default (plain = false): ProseMirrorExtension => ({
|
|
||||||
schema: () =>
|
|
||||||
plain
|
|
||||||
? {
|
|
||||||
nodes: plainSchema.spec.nodes,
|
|
||||||
marks: plainSchema.spec.marks
|
|
||||||
}
|
|
||||||
: {
|
|
||||||
nodes: (markdownSchema.spec.nodes as OrderedMap<NodeSpec>).update('blockquote', blockquoteSchema),
|
|
||||||
marks: markdownSchema.spec.marks
|
|
||||||
},
|
|
||||||
plugins: (prev, schema) => [
|
|
||||||
...prev,
|
|
||||||
keymap({
|
|
||||||
Tab: sinkListItem(schema.nodes.list_item),
|
|
||||||
'Shift-Tab': liftListItem(schema.nodes.list_item)
|
|
||||||
}),
|
|
||||||
keymap({ Tab: () => true }),
|
|
||||||
keymap(buildKeymap(schema)),
|
|
||||||
keymap(baseKeymap),
|
|
||||||
history(),
|
|
||||||
dropCursor({ class: layoutStyles.dropCursor })
|
|
||||||
]
|
|
||||||
})
|
|
|
@ -1,49 +0,0 @@
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
||||||
// @ts-nocheck
|
|
||||||
import { inputRules } from 'prosemirror-inputrules'
|
|
||||||
import type { Mark, MarkType } from 'prosemirror-model'
|
|
||||||
import type { EditorState, Transaction } from 'prosemirror-state'
|
|
||||||
import type { EditorView } from 'prosemirror-view'
|
|
||||||
import { keymap } from 'prosemirror-keymap'
|
|
||||||
import { markInputRule } from './mark-input-rule'
|
|
||||||
import type { ProseMirrorExtension } from '../helpers'
|
|
||||||
|
|
||||||
const blank = '\u00A0'
|
|
||||||
|
|
||||||
const onArrow =
|
|
||||||
(dir: 'left' | 'right') =>
|
|
||||||
(state: EditorState, dispatch: (tr: Transaction) => void, editorView: EditorView) => {
|
|
||||||
if (!state.selection.empty) return false
|
|
||||||
const $pos = state.selection.$head
|
|
||||||
const isCode = $pos.marks().find((m: Mark) => m.type.name === 'code')
|
|
||||||
const tr = state.tr
|
|
||||||
|
|
||||||
if (dir === 'left') {
|
|
||||||
const up = editorView.endOfTextblock('up')
|
|
||||||
if (!$pos.nodeBefore && up && isCode) {
|
|
||||||
tr.insertText(blank, $pos.pos - 1, $pos.pos)
|
|
||||||
dispatch(tr)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const down = editorView.endOfTextblock('down')
|
|
||||||
if (!$pos.nodeAfter && down && isCode) {
|
|
||||||
tr.insertText(blank, $pos.pos, $pos.pos + 1)
|
|
||||||
dispatch(tr)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const codeKeymap = {
|
|
||||||
ArrowLeft: onArrow('left'),
|
|
||||||
ArrowRight: onArrow('right')
|
|
||||||
}
|
|
||||||
|
|
||||||
const codeRule = (nodeType: MarkType) => markInputRule(/`([^`]+)`$/, nodeType)
|
|
||||||
|
|
||||||
export default (): ProseMirrorExtension => ({
|
|
||||||
plugins: (prev, schema) => [
|
|
||||||
...prev,
|
|
||||||
inputRules({ rules: [codeRule(schema.marks.code)] }),
|
|
||||||
keymap(codeKeymap)
|
|
||||||
]
|
|
||||||
})
|
|
|
@ -1,32 +0,0 @@
|
||||||
import { ySyncPlugin, yCursorPlugin, yUndoPlugin } from 'y-prosemirror'
|
|
||||||
import type { ProseMirrorExtension } from '../helpers'
|
|
||||||
import type { YOptions } from '../../store/context'
|
|
||||||
|
|
||||||
interface YUser {
|
|
||||||
background: string
|
|
||||||
foreground: string
|
|
||||||
name: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export const cursorBuilder = (user: YUser): HTMLElement => {
|
|
||||||
const cursor = document.createElement('span')
|
|
||||||
cursor.classList.add('ProseMirror-yjs-cursor')
|
|
||||||
cursor.setAttribute('style', `border-color: ${user.background}`)
|
|
||||||
const userDiv = document.createElement('span')
|
|
||||||
userDiv.setAttribute('style', `background-color: ${user.background}; color: ${user.foreground}`)
|
|
||||||
userDiv.textContent = user.name
|
|
||||||
cursor.append(userDiv)
|
|
||||||
return cursor
|
|
||||||
}
|
|
||||||
|
|
||||||
export default (y: YOptions): ProseMirrorExtension => ({
|
|
||||||
plugins: (prev) =>
|
|
||||||
y
|
|
||||||
? [
|
|
||||||
...prev,
|
|
||||||
ySyncPlugin(y.type),
|
|
||||||
yCursorPlugin(y.provider.awareness, { cursorBuilder }),
|
|
||||||
yUndoPlugin()
|
|
||||||
]
|
|
||||||
: prev
|
|
||||||
})
|
|
|
@ -1,51 +0,0 @@
|
||||||
import { Plugin, NodeSelection } from 'prosemirror-state'
|
|
||||||
import { DecorationSet, Decoration } from 'prosemirror-view'
|
|
||||||
import type { ProseMirrorExtension } from '../helpers'
|
|
||||||
|
|
||||||
const handleIcon = `
|
|
||||||
<svg viewBox="0 0 10 10" height="14" width="14">
|
|
||||||
<path d="M3 2a1 1 0 110-2 1 1 0 010 2zm0 4a1 1 0 110-2 1 1 0 010 2zm0 4a1 1 0 110-2 1 1 0 010 2zm4-8a1 1 0 110-2 1 1 0 010 2zm0 4a1 1 0 110-2 1 1 0 010 2zm0 4a1 1 0 110-2 1 1 0 010 2z"/>
|
|
||||||
</svg>`
|
|
||||||
|
|
||||||
const createDragHandle = () => {
|
|
||||||
const handle = document.createElement('span')
|
|
||||||
handle.setAttribute('contenteditable', 'false')
|
|
||||||
const icon = document.createElement('span')
|
|
||||||
icon.innerHTML = handleIcon
|
|
||||||
handle.appendChild(icon)
|
|
||||||
handle.classList.add('handle')
|
|
||||||
return handle
|
|
||||||
}
|
|
||||||
|
|
||||||
const handlePlugin = new Plugin({
|
|
||||||
props: {
|
|
||||||
decorations(state) {
|
|
||||||
const decos = []
|
|
||||||
state.doc.forEach((node, pos) => {
|
|
||||||
decos.push(
|
|
||||||
Decoration.widget(pos + 1, createDragHandle),
|
|
||||||
Decoration.node(pos, pos + node.nodeSize, { class: 'draggable' })
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
return DecorationSet.create(state.doc, decos)
|
|
||||||
},
|
|
||||||
handleDOMEvents: {
|
|
||||||
mousedown: (editorView, event) => {
|
|
||||||
const target = event.target as Element
|
|
||||||
if (target.classList.contains('handle')) {
|
|
||||||
const pos = editorView.posAtCoords({ left: event.x, top: event.y })
|
|
||||||
const resolved = editorView.state.doc.resolve(pos.pos)
|
|
||||||
const tr = editorView.state.tr
|
|
||||||
tr.setSelection(NodeSelection.create(editorView.state.doc, resolved.before()))
|
|
||||||
editorView.dispatch(tr)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
export default (): ProseMirrorExtension => ({
|
|
||||||
plugins: (prev) => [...prev, handlePlugin]
|
|
||||||
})
|
|
|
@ -1,174 +0,0 @@
|
||||||
import { Plugin } from 'prosemirror-state'
|
|
||||||
import type { Node, Schema } from 'prosemirror-model'
|
|
||||||
import type { EditorView } from 'prosemirror-view'
|
|
||||||
import type { NodeViewFn, ProseMirrorExtension } from '../helpers'
|
|
||||||
import type OrderedMap from 'orderedmap'
|
|
||||||
|
|
||||||
const REGEX = /^!\[([^[\]]*?)]\((.+?)\)\s+/
|
|
||||||
const MAX_MATCH = 500
|
|
||||||
|
|
||||||
const isUrl = (str: string) => {
|
|
||||||
try {
|
|
||||||
const url = new URL(str)
|
|
||||||
return url.protocol === 'http:' || url.protocol === 'https:'
|
|
||||||
} catch {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const isBlank = (text: string) => text === ' ' || text === '\u00A0'
|
|
||||||
|
|
||||||
const imageInput = (schema: Schema, path?: string) =>
|
|
||||||
new Plugin({
|
|
||||||
props: {
|
|
||||||
handleTextInput(view, from, to, text) {
|
|
||||||
if (view.composing || !isBlank(text)) return false
|
|
||||||
const $from = view.state.doc.resolve(from)
|
|
||||||
if ($from.parent.type.spec.code) return false
|
|
||||||
const textBefore =
|
|
||||||
$from.parent.textBetween(
|
|
||||||
Math.max(0, $from.parentOffset - MAX_MATCH),
|
|
||||||
$from.parentOffset,
|
|
||||||
null,
|
|
||||||
'\uFFFC'
|
|
||||||
) + text
|
|
||||||
|
|
||||||
const match = REGEX.exec(textBefore)
|
|
||||||
if (match) {
|
|
||||||
const [, title, src] = match
|
|
||||||
if (isUrl(src)) {
|
|
||||||
const node = schema.node('image', { src, title })
|
|
||||||
const start = from - (match[0].length - text.length)
|
|
||||||
const tr = view.state.tr
|
|
||||||
tr.delete(start, to)
|
|
||||||
tr.insert(start, node)
|
|
||||||
view.dispatch(tr)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const imageSchema = {
|
|
||||||
inline: true,
|
|
||||||
attrs: {
|
|
||||||
src: {},
|
|
||||||
alt: { default: null },
|
|
||||||
title: { default: null },
|
|
||||||
path: { default: null },
|
|
||||||
width: { default: null }
|
|
||||||
},
|
|
||||||
group: 'inline',
|
|
||||||
draggable: true,
|
|
||||||
parseDOM: [
|
|
||||||
{
|
|
||||||
tag: 'img[src]',
|
|
||||||
getAttrs: (dom: HTMLElement) => ({
|
|
||||||
src: dom.getAttribute('src'),
|
|
||||||
title: dom.getAttribute('title'),
|
|
||||||
alt: dom.getAttribute('alt'),
|
|
||||||
path: dom.dataset.path
|
|
||||||
})
|
|
||||||
}
|
|
||||||
],
|
|
||||||
toDOM: (node: Node) => [
|
|
||||||
'img',
|
|
||||||
{
|
|
||||||
src: node.attrs.src,
|
|
||||||
title: node.attrs.title,
|
|
||||||
alt: node.attrs.alt,
|
|
||||||
'data-path': node.attrs.path
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
export const insertImage = (view: EditorView, src: string, left: number, top: number) => {
|
|
||||||
const state = view.state
|
|
||||||
const tr = state.tr
|
|
||||||
const node = state.schema.nodes.image.create({ src })
|
|
||||||
const pos = view.posAtCoords({ left, top }).pos
|
|
||||||
tr.insert(pos, node)
|
|
||||||
view.dispatch(tr)
|
|
||||||
}
|
|
||||||
|
|
||||||
class ImageView {
|
|
||||||
node: Node
|
|
||||||
view: EditorView
|
|
||||||
getPos: () => number
|
|
||||||
schema: Schema
|
|
||||||
dom: Element
|
|
||||||
contentDOM: Element
|
|
||||||
container: HTMLElement
|
|
||||||
handle: HTMLElement
|
|
||||||
onResizeFn: any
|
|
||||||
onResizeEndFn: any
|
|
||||||
width: number
|
|
||||||
updating: number
|
|
||||||
|
|
||||||
constructor(node: Node, view: EditorView, getPos: () => number, schema: Schema, path: string) {
|
|
||||||
this.node = node
|
|
||||||
this.view = view
|
|
||||||
this.getPos = getPos
|
|
||||||
this.schema = schema
|
|
||||||
this.onResizeFn = this.onResize.bind(this)
|
|
||||||
this.onResizeEndFn = this.onResizeEnd.bind(this)
|
|
||||||
|
|
||||||
this.container = document.createElement('span')
|
|
||||||
this.container.className = 'image-container'
|
|
||||||
if (node.attrs.width) this.setWidth(node.attrs.width)
|
|
||||||
|
|
||||||
const image = document.createElement('img')
|
|
||||||
image.setAttribute('title', node.attrs.title ?? '')
|
|
||||||
image.setAttribute('src', node.attrs.src)
|
|
||||||
|
|
||||||
this.handle = document.createElement('span')
|
|
||||||
this.handle.className = 'resize-handle'
|
|
||||||
this.handle.addEventListener('mousedown', (e) => {
|
|
||||||
e.preventDefault()
|
|
||||||
window.addEventListener('mousemove', this.onResizeFn)
|
|
||||||
window.addEventListener('mouseup', this.onResizeEndFn)
|
|
||||||
})
|
|
||||||
|
|
||||||
this.container.appendChild(image)
|
|
||||||
this.container.appendChild(this.handle)
|
|
||||||
this.dom = this.container
|
|
||||||
}
|
|
||||||
|
|
||||||
onResize(e: MouseEvent) {
|
|
||||||
this.width = e.pageX - this.container.getBoundingClientRect().left
|
|
||||||
this.setWidth(this.width)
|
|
||||||
}
|
|
||||||
|
|
||||||
onResizeEnd() {
|
|
||||||
window.removeEventListener('mousemove', this.onResizeFn)
|
|
||||||
if (this.updating === this.width) return
|
|
||||||
this.updating = this.width
|
|
||||||
const tr = this.view.state.tr
|
|
||||||
tr.setNodeMarkup(this.getPos(), undefined, {
|
|
||||||
...this.node.attrs,
|
|
||||||
width: this.width
|
|
||||||
})
|
|
||||||
|
|
||||||
this.view.dispatch(tr)
|
|
||||||
}
|
|
||||||
|
|
||||||
setWidth(width: number) {
|
|
||||||
this.container.style.width = width + 'px'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default (path?: string): ProseMirrorExtension => ({
|
|
||||||
schema: (prev) => ({
|
|
||||||
...prev,
|
|
||||||
nodes: (prev.nodes as OrderedMap<any>).update('image', imageSchema)
|
|
||||||
}),
|
|
||||||
plugins: (prev, schema) => [...prev, imageInput(schema, path)],
|
|
||||||
nodeViews: {
|
|
||||||
image: (node, view, getPos) => {
|
|
||||||
return new ImageView(node, view, getPos, view.state.schema, path)
|
|
||||||
}
|
|
||||||
} as unknown as { [key: string]: NodeViewFn }
|
|
||||||
})
|
|
|
@ -1,165 +0,0 @@
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
||||||
// @ts-nocheck
|
|
||||||
import { Plugin, PluginKey, TextSelection, Transaction } from 'prosemirror-state'
|
|
||||||
import type { EditorView } from 'prosemirror-view'
|
|
||||||
import type { Mark, Node, Schema } from 'prosemirror-model'
|
|
||||||
import type { ProseMirrorExtension } from '../helpers'
|
|
||||||
|
|
||||||
const REGEX = /(^|\s)\[(.+)]\(([^ ]+)(?: "(.+)")?\)/
|
|
||||||
|
|
||||||
const findMarkPosition = (mark: Mark, doc: Node, from: number, to: number) => {
|
|
||||||
let markPos = { from: -1, to: -1 }
|
|
||||||
doc.nodesBetween(from, to, (node, pos) => {
|
|
||||||
if (markPos.from > -1) return false
|
|
||||||
if (markPos.from === -1 && mark.isInSet(node.marks)) {
|
|
||||||
markPos = { from: pos, to: pos + Math.max(node.textContent.length, 1) }
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return markPos
|
|
||||||
}
|
|
||||||
|
|
||||||
const pluginKey = new PluginKey('markdown-links')
|
|
||||||
|
|
||||||
const markdownLinks = (schema: Schema) =>
|
|
||||||
new Plugin({
|
|
||||||
key: pluginKey,
|
|
||||||
state: {
|
|
||||||
init() {
|
|
||||||
return { schema }
|
|
||||||
},
|
|
||||||
apply(tr, state: any) {
|
|
||||||
const action = tr.getMeta(this)
|
|
||||||
if (action?.pos) {
|
|
||||||
state.pos = action.pos
|
|
||||||
}
|
|
||||||
|
|
||||||
return state
|
|
||||||
}
|
|
||||||
},
|
|
||||||
props: {
|
|
||||||
handleDOMEvents: {
|
|
||||||
keyup: (view) => {
|
|
||||||
return handleMove(view)
|
|
||||||
},
|
|
||||||
click: (view, e) => {
|
|
||||||
if (handleMove(view)) {
|
|
||||||
e.preventDefault()
|
|
||||||
}
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const resolvePos = (view: EditorView, pos: number) => {
|
|
||||||
try {
|
|
||||||
return view.state.doc.resolve(pos)
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
|
||||||
const toLink = (view: EditorView, tr: Transaction) => {
|
|
||||||
const sel = view.state.selection
|
|
||||||
const state = pluginKey.getState(view.state)
|
|
||||||
const lastPos = state.pos
|
|
||||||
|
|
||||||
if (lastPos !== undefined) {
|
|
||||||
const $from = resolvePos(view, lastPos)
|
|
||||||
if (!$from || $from.depth === 0 || $from.parent.type.spec.code) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
const lineFrom = $from.before()
|
|
||||||
const lineTo = $from.after()
|
|
||||||
|
|
||||||
const line = view.state.doc.textBetween(lineFrom, lineTo, '\0', '\0')
|
|
||||||
const match = REGEX.exec(line)
|
|
||||||
|
|
||||||
if (match) {
|
|
||||||
const [full, , text, href] = match
|
|
||||||
const spaceLeft = full.indexOf(text) - 1
|
|
||||||
const spaceRight = full.length - text.length - href.length - spaceLeft - 4
|
|
||||||
const start = match.index + $from.start() + spaceLeft
|
|
||||||
const end = start + full.length - spaceLeft - spaceRight
|
|
||||||
|
|
||||||
if (sel.$from.pos >= start && sel.$from.pos <= end) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Do not convert md links if content has marks
|
|
||||||
const $startPos = resolvePos(view, start)
|
|
||||||
if ($startPos.marks().length > 0) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
const textStart = start + 1
|
|
||||||
const textEnd = textStart + text.length
|
|
||||||
|
|
||||||
if (textEnd < end) tr.delete(textEnd, end)
|
|
||||||
if (textStart > start) tr.delete(start, textStart)
|
|
||||||
|
|
||||||
const to = start + text.length
|
|
||||||
tr.addMark(start, to, state.schema.marks.link.create({ href }))
|
|
||||||
|
|
||||||
const sub = end - textEnd + textStart - start
|
|
||||||
tr.setMeta(pluginKey, { pos: sel.$head.pos - sub })
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
const toMarkdown = (view: EditorView, tr: Transaction) => {
|
|
||||||
const { schema } = pluginKey.getState(view.state)
|
|
||||||
const sel = view.state.selection
|
|
||||||
if (sel.$head.depth === 0 || sel.$head.parent.type.spec.code) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
const mark = schema.marks.link.isInSet(sel.$head.marks())
|
|
||||||
const textFrom = sel.$head.pos - sel.$head.textOffset
|
|
||||||
const textTo = sel.$head.after()
|
|
||||||
|
|
||||||
if (mark) {
|
|
||||||
const { href } = mark.attrs
|
|
||||||
const range = findMarkPosition(mark, view.state.doc, textFrom, textTo)
|
|
||||||
const text = view.state.doc.textBetween(range.from, range.to, '\0', '\0')
|
|
||||||
tr.replaceRangeWith(range.from, range.to, view.state.schema.text(`[${text}](${href})`))
|
|
||||||
tr.setSelection(new TextSelection(tr.doc.resolve(sel.$head.pos + 1)))
|
|
||||||
tr.setMeta(pluginKey, { pos: sel.$head.pos })
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleMove = (view: EditorView) => {
|
|
||||||
const sel = view.state.selection
|
|
||||||
if (!sel.empty || !sel.$head) return false
|
|
||||||
const pos = sel.$head.pos
|
|
||||||
const tr = view.state.tr
|
|
||||||
|
|
||||||
if (toLink(view, tr)) {
|
|
||||||
view.dispatch(tr)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
if (toMarkdown(view, tr)) {
|
|
||||||
view.dispatch(tr)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
tr.setMeta(pluginKey, { pos })
|
|
||||||
view.dispatch(tr)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
export default (): ProseMirrorExtension => ({
|
|
||||||
plugins: (prev, schema) => [...prev, markdownLinks(schema)]
|
|
||||||
})
|
|
|
@ -1,27 +0,0 @@
|
||||||
import { InputRule } from 'prosemirror-inputrules'
|
|
||||||
import type { EditorState } from 'prosemirror-state'
|
|
||||||
import type { MarkType } from 'prosemirror-model'
|
|
||||||
|
|
||||||
export const markInputRule = (regexp: RegExp, nodeType: MarkType, getAttrs = null) =>
|
|
||||||
new InputRule(regexp, (state: EditorState, match: string[], start: number, end: number) => {
|
|
||||||
let markEnd = end
|
|
||||||
const attrs = getAttrs instanceof Function ? getAttrs(match) : getAttrs
|
|
||||||
const tr = state.tr
|
|
||||||
if (match[1]) {
|
|
||||||
const textStart = start + match[0].indexOf(match[1])
|
|
||||||
const textEnd = textStart + match[1].length
|
|
||||||
let hasMarks = false
|
|
||||||
state.doc.nodesBetween(textStart, textEnd, (node) => {
|
|
||||||
hasMarks = node.marks.length > 0
|
|
||||||
})
|
|
||||||
|
|
||||||
if (hasMarks) return
|
|
||||||
if (textEnd < end) tr.delete(textEnd, end)
|
|
||||||
if (textStart > start) tr.delete(start, textStart)
|
|
||||||
markEnd = start + match[1].length
|
|
||||||
}
|
|
||||||
|
|
||||||
tr.addMark(start, markEnd, nodeType.create(attrs))
|
|
||||||
tr.removeStoredMark(nodeType)
|
|
||||||
return tr
|
|
||||||
})
|
|
|
@ -1,40 +0,0 @@
|
||||||
import {
|
|
||||||
inputRules,
|
|
||||||
textblockTypeInputRule,
|
|
||||||
wrappingInputRule,
|
|
||||||
smartQuotes,
|
|
||||||
emDash,
|
|
||||||
ellipsis
|
|
||||||
} from 'prosemirror-inputrules'
|
|
||||||
import type { NodeType, Schema } from 'prosemirror-model'
|
|
||||||
import type { ProseMirrorExtension } from '../helpers'
|
|
||||||
|
|
||||||
const blockQuoteRule = (nodeType: NodeType) => wrappingInputRule(/^\s*>\s$/, nodeType)
|
|
||||||
|
|
||||||
const orderedListRule = (nodeType: NodeType) =>
|
|
||||||
wrappingInputRule(
|
|
||||||
/^(\d+)\.\s$/,
|
|
||||||
nodeType,
|
|
||||||
(match) => ({ order: +match[1] }),
|
|
||||||
(match, node) => node.childCount + node.attrs.order === +match[1]
|
|
||||||
)
|
|
||||||
|
|
||||||
const bulletListRule = (nodeType: NodeType) => wrappingInputRule(/^\s*([*+-])\s$/, nodeType)
|
|
||||||
|
|
||||||
const headingRule = (nodeType: NodeType, maxLevel: number) =>
|
|
||||||
textblockTypeInputRule(new RegExp('^(#{1,' + maxLevel + '})\\s$'), nodeType, (match) => ({
|
|
||||||
level: match[1].length
|
|
||||||
}))
|
|
||||||
|
|
||||||
const markdownRules = (schema: Schema) => {
|
|
||||||
const rules = [...smartQuotes, ellipsis, emDash]
|
|
||||||
if (schema.nodes.blockquote) rules.push(blockQuoteRule(schema.nodes.blockquote))
|
|
||||||
if (schema.nodes.ordered_list) rules.push(orderedListRule(schema.nodes.ordered_list))
|
|
||||||
if (schema.nodes.bullet_list) rules.push(bulletListRule(schema.nodes.bullet_list))
|
|
||||||
if (schema.nodes.heading) rules.push(headingRule(schema.nodes.heading, 6))
|
|
||||||
return rules
|
|
||||||
}
|
|
||||||
|
|
||||||
export default (): ProseMirrorExtension => ({
|
|
||||||
plugins: (prev, schema) => [...prev, inputRules({ rules: markdownRules(schema) })]
|
|
||||||
})
|
|
|
@ -1,354 +0,0 @@
|
||||||
import { toggleMark } from 'prosemirror-commands'
|
|
||||||
import {
|
|
||||||
blockTypeItem,
|
|
||||||
// joinUpItem,
|
|
||||||
// liftItem,
|
|
||||||
// selectParentNodeItem,
|
|
||||||
// undoItem,
|
|
||||||
// redoItem,
|
|
||||||
menuBar,
|
|
||||||
icons,
|
|
||||||
MenuItem,
|
|
||||||
wrapItem,
|
|
||||||
Dropdown
|
|
||||||
} from 'prosemirror-menu'
|
|
||||||
|
|
||||||
import { wrapInList } from 'prosemirror-schema-list'
|
|
||||||
import type { NodeSelection } from 'prosemirror-state'
|
|
||||||
|
|
||||||
import { TextField, openPrompt } from './prompt'
|
|
||||||
import type { ProseMirrorExtension } from '../helpers'
|
|
||||||
import type { Schema } from 'prosemirror-model'
|
|
||||||
|
|
||||||
// Helpers to create specific types of items
|
|
||||||
|
|
||||||
const cut = (something) => something.filter(Boolean)
|
|
||||||
|
|
||||||
function canInsert(state, nodeType) {
|
|
||||||
const $from = state.selection.$from
|
|
||||||
|
|
||||||
for (let d = $from.depth; d >= 0; d--) {
|
|
||||||
const index = $from.index(d)
|
|
||||||
|
|
||||||
if ($from.node(d).canReplaceWith(index, index, nodeType)) return true
|
|
||||||
}
|
|
||||||
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
function insertImageItem(nodeType) {
|
|
||||||
return new MenuItem({
|
|
||||||
icon: icons.image,
|
|
||||||
label: 'image',
|
|
||||||
enable(state) {
|
|
||||||
return canInsert(state, nodeType)
|
|
||||||
},
|
|
||||||
run(state, _, view) {
|
|
||||||
const {
|
|
||||||
from,
|
|
||||||
to,
|
|
||||||
node: { attrs }
|
|
||||||
} = state.selection as NodeSelection
|
|
||||||
|
|
||||||
openPrompt({
|
|
||||||
title: 'Insert image',
|
|
||||||
fields: {
|
|
||||||
src: new TextField({
|
|
||||||
label: 'Location',
|
|
||||||
required: true,
|
|
||||||
value: attrs && attrs.src
|
|
||||||
}),
|
|
||||||
title: new TextField({ label: 'Title', value: attrs && attrs.title }),
|
|
||||||
alt: new TextField({
|
|
||||||
label: 'Description',
|
|
||||||
value: attrs ? attrs.alt : state.doc.textBetween(from, to, ' ')
|
|
||||||
})
|
|
||||||
},
|
|
||||||
callback(newAttrs) {
|
|
||||||
view.dispatch(view.state.tr.replaceSelectionWith(nodeType.createAndFill(newAttrs)))
|
|
||||||
view.focus()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function cmdItem(cmd, options) {
|
|
||||||
const passedOptions = {
|
|
||||||
label: options.title,
|
|
||||||
run: cmd
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const prop in options) passedOptions[prop] = options[prop]
|
|
||||||
|
|
||||||
if ((!options.enable || options.enable === true) && !options.select) {
|
|
||||||
passedOptions[options.enable ? 'enable' : 'select'] = (state) => cmd(state)
|
|
||||||
}
|
|
||||||
|
|
||||||
return new MenuItem(passedOptions)
|
|
||||||
}
|
|
||||||
|
|
||||||
function markActive(state, type) {
|
|
||||||
const { from, $from, to, empty } = state.selection
|
|
||||||
|
|
||||||
if (empty) return type.isInSet(state.storedMarks || $from.marks())
|
|
||||||
|
|
||||||
return state.doc.rangeHasMark(from, to, type)
|
|
||||||
}
|
|
||||||
|
|
||||||
function markItem(markType, options) {
|
|
||||||
const passedOptions = {
|
|
||||||
active(state) {
|
|
||||||
return markActive(state, markType)
|
|
||||||
},
|
|
||||||
enable: true
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const prop in options) passedOptions[prop] = options[prop]
|
|
||||||
|
|
||||||
return cmdItem(toggleMark(markType), passedOptions)
|
|
||||||
}
|
|
||||||
|
|
||||||
function linkItem(markType) {
|
|
||||||
return new MenuItem({
|
|
||||||
title: 'Add or remove link',
|
|
||||||
icon: {
|
|
||||||
width: 18,
|
|
||||||
height: 18,
|
|
||||||
path: 'M3.27177 14.7277C2.06258 13.5186 2.06258 11.5527 3.27177 10.3435L6.10029 7.51502L4.75675 6.17148L1.92823 9C-0.0234511 10.9517 -0.0234511 14.1196 1.92823 16.0713C3.87991 18.023 7.04785 18.023 8.99952 16.0713L11.828 13.2428L10.4845 11.8992L7.65598 14.7277C6.44679 15.9369 4.48097 15.9369 3.27177 14.7277ZM6.87756 12.536L12.5346 6.87895L11.1203 5.46469L5.4633 11.1217L6.87756 12.536ZM6.17055 4.75768L8.99907 1.92916C10.9507 -0.0225206 14.1187 -0.0225201 16.0704 1.92916C18.022 3.88084 18.022 7.04878 16.0704 9.00046L13.2418 11.829L11.8983 10.4854L14.7268 7.65691C15.936 6.44772 15.936 4.4819 14.7268 3.27271C13.5176 2.06351 11.5518 2.06351 10.3426 3.2727L7.51409 6.10122L6.17055 4.75768Z'
|
|
||||||
},
|
|
||||||
active(state) {
|
|
||||||
return markActive(state, markType)
|
|
||||||
},
|
|
||||||
enable(state) {
|
|
||||||
return !state.selection.empty
|
|
||||||
},
|
|
||||||
run(state, dispatch, view) {
|
|
||||||
if (markActive(state, markType)) {
|
|
||||||
toggleMark(markType)(state, dispatch)
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
openPrompt({
|
|
||||||
fields: {
|
|
||||||
href: new TextField({
|
|
||||||
label: 'Link target',
|
|
||||||
required: true
|
|
||||||
})
|
|
||||||
},
|
|
||||||
callback(attrs) {
|
|
||||||
toggleMark(markType, attrs)(view.state, view.dispatch)
|
|
||||||
view.focus()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function wrapListItem(nodeType, options) {
|
|
||||||
return cmdItem(wrapInList(nodeType, options.attrs), options)
|
|
||||||
}
|
|
||||||
|
|
||||||
// :: (Schema) → Object
|
|
||||||
// Given a schema, look for default mark and node types in it and
|
|
||||||
// return an object with relevant menu items relating to those marks:
|
|
||||||
//
|
|
||||||
// **`toggleStrong`**`: MenuItem`
|
|
||||||
// : A menu item to toggle the [strong mark](#schema-basic.StrongMark).
|
|
||||||
//
|
|
||||||
// **`toggleEm`**`: MenuItem`
|
|
||||||
// : A menu item to toggle the [emphasis mark](#schema-basic.EmMark).
|
|
||||||
//
|
|
||||||
// **`toggleCode`**`: MenuItem`
|
|
||||||
// : A menu item to toggle the [code font mark](#schema-basic.CodeMark).
|
|
||||||
//
|
|
||||||
// **`toggleLink`**`: MenuItem`
|
|
||||||
// : A menu item to toggle the [link mark](#schema-basic.LinkMark).
|
|
||||||
//
|
|
||||||
// **`insertImage`**`: MenuItem`
|
|
||||||
// : A menu item to insert an [image](#schema-basic.Image).
|
|
||||||
//
|
|
||||||
// **`wrapBulletList`**`: MenuItem`
|
|
||||||
// : A menu item to wrap the selection in a [bullet list](#schema-list.BulletList).
|
|
||||||
//
|
|
||||||
// **`wrapOrderedList`**`: MenuItem`
|
|
||||||
// : A menu item to wrap the selection in an [ordered list](#schema-list.OrderedList).
|
|
||||||
//
|
|
||||||
// **`wrapBlockQuote`**`: MenuItem`
|
|
||||||
// : A menu item to wrap the selection in a [block quote](#schema-basic.BlockQuote).
|
|
||||||
//
|
|
||||||
// **`makeParagraph`**`: MenuItem`
|
|
||||||
// : A menu item to set the current textblock to be a normal
|
|
||||||
// [paragraph](#schema-basic.Paragraph).
|
|
||||||
//
|
|
||||||
// **`makeCodeBlock`**`: MenuItem`
|
|
||||||
// : A menu item to set the current textblock to be a
|
|
||||||
// [code block](#schema-basic.CodeBlock).
|
|
||||||
//
|
|
||||||
// **`makeHead[N]`**`: MenuItem`
|
|
||||||
// : Where _N_ is 1 to 6. Menu items to set the current textblock to
|
|
||||||
// be a [heading](#schema-basic.Heading) of level _N_.
|
|
||||||
//
|
|
||||||
// **`insertHorizontalRule`**`: MenuItem`
|
|
||||||
// : A menu item to insert a horizontal rule.
|
|
||||||
//
|
|
||||||
// The return value also contains some prefabricated menu elements and
|
|
||||||
// menus, that you can use instead of composing your own menu from
|
|
||||||
// scratch:
|
|
||||||
//
|
|
||||||
// **`insertMenu`**`: Dropdown`
|
|
||||||
// : A dropdown containing the `insertImage` and
|
|
||||||
// `insertHorizontalRule` items.
|
|
||||||
//
|
|
||||||
// **`typeMenu`**`: Dropdown`
|
|
||||||
// : A dropdown containing the items for making the current
|
|
||||||
// textblock a paragraph, code block, or heading.
|
|
||||||
//
|
|
||||||
// **`fullMenu`**`: [[MenuElement]]`
|
|
||||||
// : An array of arrays of menu elements for use as the full menu
|
|
||||||
// for, for example the [menu bar](https://github.com/prosemirror/prosemirror-menu#user-content-menubar).
|
|
||||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
|
||||||
export function buildMenuItems(schema: Schema<any, any>) {
|
|
||||||
const r: { [key: string]: MenuItem | MenuItem[] } = {}
|
|
||||||
let type
|
|
||||||
|
|
||||||
if ((type = schema.marks.strong)) {
|
|
||||||
r.toggleStrong = markItem(type, {
|
|
||||||
title: 'Toggle strong style',
|
|
||||||
icon: {
|
|
||||||
width: 13,
|
|
||||||
height: 16,
|
|
||||||
path: 'M9.82857 7.76C10.9371 6.99429 11.7143 5.73714 11.7143 4.57143C11.7143 1.98857 9.71428 0 7.14286 0H0V16H8.04571C10.4343 16 12.2857 14.0571 12.2857 11.6686C12.2857 9.93143 11.3029 8.44571 9.82857 7.76ZM3.42799 2.85708H6.85656C7.80513 2.85708 8.57085 3.6228 8.57085 4.57137C8.57085 5.51994 7.80513 6.28565 6.85656 6.28565H3.42799V2.85708ZM3.42799 13.1429H7.42799C8.37656 13.1429 9.14228 12.3772 9.14228 11.4286C9.14228 10.4801 8.37656 9.71434 7.42799 9.71434H3.42799V13.1429Z'
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if ((type = schema.marks.em)) {
|
|
||||||
r.toggleEm = markItem(type, {
|
|
||||||
title: 'Toggle emphasis',
|
|
||||||
icon: {
|
|
||||||
width: 14,
|
|
||||||
height: 16,
|
|
||||||
path: 'M4.39216 0V3.42857H6.81882L3.06353 12.5714H0V16H8.78431V12.5714H6.35765L10.1129 3.42857H13.1765V0H4.39216Z'
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if ((type = schema.marks.code)) {
|
|
||||||
r.toggleCode = markItem(type, {
|
|
||||||
title: 'Toggle code font',
|
|
||||||
icon: icons.code
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if ((type = schema.marks.link)) r.toggleLink = linkItem(type)
|
|
||||||
|
|
||||||
if ((type = schema.marks.blockquote) && (type = schema.nodes.image)) r.insertImage = insertImageItem(type)
|
|
||||||
|
|
||||||
if ((type = schema.nodes.bullet_list)) {
|
|
||||||
r.wrapBulletList = wrapListItem(type, {
|
|
||||||
title: 'Wrap in bullet list',
|
|
||||||
icon: {
|
|
||||||
width: 20,
|
|
||||||
height: 16,
|
|
||||||
path: 'M0.000114441 1.6C0.000114441 0.714665 0.71478 0 1.60011 0C2.48544 0 3.20011 0.714665 3.20011 1.6C3.20011 2.48533 2.48544 3.19999 1.60011 3.19999C0.71478 3.19999 0.000114441 2.48533 0.000114441 1.6ZM0 8.00013C0 7.1148 0.714665 6.40014 1.6 6.40014C2.48533 6.40014 3.19999 7.1148 3.19999 8.00013C3.19999 8.88547 2.48533 9.60013 1.6 9.60013C0.714665 9.60013 0 8.88547 0 8.00013ZM1.6 12.8C0.714665 12.8 0 13.5254 0 14.4C0 15.2747 0.725332 16 1.6 16C2.47466 16 3.19999 15.2747 3.19999 14.4C3.19999 13.5254 2.48533 12.8 1.6 12.8ZM19.7333 15.4662H4.79999V13.3329H19.7333V15.4662ZM4.79999 9.06677H19.7333V6.93344H4.79999V9.06677ZM4.79999 2.66664V0.533307H19.7333V2.66664H4.79999Z'
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if ((type = schema.nodes.ordered_list)) {
|
|
||||||
r.wrapOrderedList = wrapListItem(type, {
|
|
||||||
title: 'Wrap in ordered list',
|
|
||||||
icon: {
|
|
||||||
width: 19,
|
|
||||||
height: 16,
|
|
||||||
path: 'M2.00002 4.00003H1.00001V1.00001H0V0H2.00002V4.00003ZM2.00002 13.5V13H0V12H3.00003V16H0V15H2.00002V14.5H1.00001V13.5H2.00002ZM0 6.99998H1.80002L0 9.1V10H3.00003V9H1.20001L3.00003 6.89998V5.99998H0V6.99998ZM4.9987 2.99967V0.999648H18.9988V2.99967H4.9987ZM4.9987 15.0001H18.9988V13.0001H4.9987V15.0001ZM18.9988 8.99987H4.9987V6.99986H18.9988V8.99987Z'
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if ((type = schema.nodes.blockquote)) {
|
|
||||||
r.wrapBlockQuote = wrapItem(type, {
|
|
||||||
title: 'Wrap in block quote',
|
|
||||||
icon: icons.blockquote
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if ((type = schema.nodes.paragraph)) {
|
|
||||||
r.makeParagraph = blockTypeItem(type, {
|
|
||||||
title: 'Change to paragraph',
|
|
||||||
label: 'P',
|
|
||||||
icon: icons.paragraph
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if ((type = schema.nodes.code_block)) {
|
|
||||||
r.makeCodeBlock = blockTypeItem(type, {
|
|
||||||
title: 'Change to code block',
|
|
||||||
label: '<>'
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const headingIcons = [
|
|
||||||
'M0 12H2.57143V7.16571H7.95429V12H10.5257V0H7.95429V4.83429H2.57143V0H0V12Z M12.6801 12H19.3315V9.78857H17.3944V0.342858H15.5087L12.6801 1.42286V3.75429L14.8744 2.93143V9.78857H12.6801V12Z',
|
|
||||||
'M0 12H2.57143V7.16571H7.95429V12H10.5257V0H7.95429V4.83429H2.57143V0H0V12Z M12.4915 12H21.2515V9.78857H15.4229C15.4229 9.05143 16.6229 8.43429 17.9944 7.59429C19.5372 6.68571 21.1658 5.52 21.1658 3.54857C21.1658 1.16571 19.2458 0.102858 16.8972 0.102858C15.4744 0.102858 14.0858 0.48 12.8858 1.33714V3.73714C14.1201 2.79429 15.4915 2.36571 16.6744 2.36571C17.8229 2.36571 18.5772 2.79429 18.5772 3.65143C18.5772 4.76571 17.5487 5.22857 16.3315 5.93143C14.6172 6.94286 12.4915 8.02286 12.4915 10.8514V12Z',
|
|
||||||
'M0 11.7647H2.52101V7.02521H7.79832V11.7647H10.3193V0H7.79832V4.7395H2.52101V0H0V11.7647Z M16.3474 12C18.7004 12 20.9189 11.042 20.9189 8.63866C20.9189 6.95798 19.8936 6.06723 18.7172 5.71429C19.7928 5.34454 20.4483 4.43697 20.4483 3.2605C20.4483 1.17647 18.6836 0.100841 16.3138 0.100841C14.9189 0.100841 13.6079 0.436975 12.5827 0.991597V3.34454C13.7088 2.63865 14.9357 2.31933 15.9609 2.31933C17.339 2.31933 18.0617 2.78992 18.0617 3.61345C18.0617 4.40336 17.3558 4.82353 16.2466 4.80672L14.6668 4.78992L14.6499 6.97479H16.5323C17.6752 6.97479 18.5155 7.31092 18.5155 8.28571C18.5155 9.36134 17.4399 9.7647 16.1457 9.78151C14.8348 9.79832 13.692 9.59664 12.381 8.87395V11.2269C13.692 11.7647 14.8852 12 16.3474 12Z'
|
|
||||||
]
|
|
||||||
|
|
||||||
if ((type = schema.nodes.heading)) {
|
|
||||||
for (let i = 1; i <= 10; i++) {
|
|
||||||
r[`makeHead${i}`] = blockTypeItem(type, {
|
|
||||||
label: `H${i}`,
|
|
||||||
attrs: { level: i },
|
|
||||||
icon: {
|
|
||||||
width: 22,
|
|
||||||
height: 12,
|
|
||||||
path: headingIcons[i - 1]
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if ((type = schema.nodes.horizontal_rule)) {
|
|
||||||
const hr = type
|
|
||||||
|
|
||||||
r.insertHorizontalRule = new MenuItem({
|
|
||||||
label: '---',
|
|
||||||
icon: icons.horizontal_rule,
|
|
||||||
enable(state) {
|
|
||||||
return canInsert(state, hr)
|
|
||||||
},
|
|
||||||
run(state, dispatch) {
|
|
||||||
dispatch(state.tr.replaceSelectionWith(hr.create()))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
r.typeMenu = new Dropdown(cut([r.makeHead1, r.makeHead2, r.makeHead3, r.typeMenu, r.wrapBlockQuote]), {
|
|
||||||
label: 'Тт',
|
|
||||||
class: 'editor-dropdown' // TODO: use this class
|
|
||||||
// FIXME: icon svg code shouldn't be here
|
|
||||||
// icon: {
|
|
||||||
// width: 12,
|
|
||||||
// height: 12,
|
|
||||||
// path: "M6.39999 3.19998V0H20.2666V3.19998H14.9333V15.9999H11.7333V3.19998H6.39999ZM3.19998 8.5334H0V5.33342H9.59994V8.5334H6.39996V16H3.19998V8.5334Z"
|
|
||||||
// }
|
|
||||||
}) as MenuItem
|
|
||||||
r.blockMenu = []
|
|
||||||
r.listMenu = [cut([r.wrapBulletList, r.wrapOrderedList])]
|
|
||||||
r.inlineMenu = [cut([r.toggleStrong, r.toggleEm, r.toggleMark])]
|
|
||||||
r.fullMenu = r.inlineMenu.concat([cut([r.typeMenu])], r.listMenu)
|
|
||||||
|
|
||||||
return r
|
|
||||||
}
|
|
||||||
|
|
||||||
export default (): ProseMirrorExtension => ({
|
|
||||||
plugins: (prev, schema) => [
|
|
||||||
...prev,
|
|
||||||
menuBar({
|
|
||||||
floating: false,
|
|
||||||
content: buildMenuItems(schema).fullMenu as any
|
|
||||||
})
|
|
||||||
]
|
|
||||||
})
|
|
|
@ -1,80 +0,0 @@
|
||||||
import { Plugin, Transaction } from 'prosemirror-state'
|
|
||||||
import { Fragment, Node, Schema, Slice } from 'prosemirror-model'
|
|
||||||
import type { ProseMirrorExtension } from '../helpers'
|
|
||||||
import { createMarkdownParser } from '../../markdown'
|
|
||||||
// import { openPrompt } from './prompt'
|
|
||||||
|
|
||||||
const URL_REGEX = /(ftp|http|https):\/\/(\w+:{0,1}\w*@)?(\S+)(:\d+)?(\/|\/([\w!#%&+./:=?@-]))?/g
|
|
||||||
|
|
||||||
const transform = (schema: Schema, fragment: Fragment) => {
|
|
||||||
const nodes = []
|
|
||||||
fragment.forEach((child: Node) => {
|
|
||||||
if (child.isText) {
|
|
||||||
let pos = 0
|
|
||||||
let match: RegExpExecArray
|
|
||||||
|
|
||||||
while ((match = URL_REGEX.exec(child.text)) !== null) {
|
|
||||||
const start = match.index
|
|
||||||
const end = start + match[0].length
|
|
||||||
const attrs = { href: match[0] }
|
|
||||||
|
|
||||||
if (start > 0) {
|
|
||||||
nodes.push(child.cut(pos, start))
|
|
||||||
}
|
|
||||||
|
|
||||||
const node = child.cut(start, end).mark(schema.marks.link.create(attrs).addToSet(child.marks))
|
|
||||||
nodes.push(node)
|
|
||||||
pos = end
|
|
||||||
}
|
|
||||||
|
|
||||||
if (pos < child.text.length) {
|
|
||||||
nodes.push(child.cut(pos))
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
nodes.push(child.copy(transform(schema, child.content)))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return Fragment.fromArray(nodes)
|
|
||||||
}
|
|
||||||
|
|
||||||
let shiftKey = false
|
|
||||||
|
|
||||||
const pasteMarkdown = (schema: Schema) => {
|
|
||||||
const parser = createMarkdownParser(schema)
|
|
||||||
return new Plugin({
|
|
||||||
props: {
|
|
||||||
handleDOMEvents: {
|
|
||||||
keydown: (_, event) => {
|
|
||||||
shiftKey = event.shiftKey
|
|
||||||
return false
|
|
||||||
},
|
|
||||||
keyup: () => {
|
|
||||||
shiftKey = false
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
handlePaste: (view, event) => {
|
|
||||||
if (!event.clipboardData) return false
|
|
||||||
const text = event.clipboardData.getData('text/plain')
|
|
||||||
const html = event.clipboardData.getData('text/html')
|
|
||||||
// otherwise, if we have html then fallback to the default HTML
|
|
||||||
// parser behavior that comes with Prosemirror.
|
|
||||||
if (text.length === 0 || html) return false
|
|
||||||
event.preventDefault()
|
|
||||||
const node: Node = parser.parse(text)
|
|
||||||
const fragment = shiftKey ? node.content : transform(schema, node.content)
|
|
||||||
const openStart = 0 // FIXME
|
|
||||||
const openEnd = text.length // FIXME: detect real start and end cursor position
|
|
||||||
const tr: Transaction = view.state.tr.replaceSelection(new Slice(fragment, openStart, openEnd))
|
|
||||||
|
|
||||||
view.dispatch(tr)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export default (): ProseMirrorExtension => ({
|
|
||||||
plugins: (prev, schema) => [...prev, pasteMarkdown(schema)]
|
|
||||||
})
|
|
|
@ -1,23 +0,0 @@
|
||||||
import { Plugin } from 'prosemirror-state'
|
|
||||||
import { DecorationSet, Decoration } from 'prosemirror-view'
|
|
||||||
import { ProseMirrorExtension, isEmpty } from '../helpers'
|
|
||||||
|
|
||||||
const placeholder = (text: string) =>
|
|
||||||
new Plugin({
|
|
||||||
props: {
|
|
||||||
decorations(state) {
|
|
||||||
if (isEmpty(state)) {
|
|
||||||
const div = document.createElement('div')
|
|
||||||
div.setAttribute('contenteditable', 'false')
|
|
||||||
div.classList.add('placeholder')
|
|
||||||
div.textContent = text
|
|
||||||
|
|
||||||
return DecorationSet.create(state.doc, [Decoration.widget(1, div)])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
export default (text: string): ProseMirrorExtension => ({
|
|
||||||
plugins: (prev) => [...prev, placeholder(text)]
|
|
||||||
})
|
|
|
@ -1,155 +0,0 @@
|
||||||
const prefix = 'ProseMirror-prompt'
|
|
||||||
|
|
||||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
|
||||||
export function openPrompt(options) {
|
|
||||||
const wrapper = document.body.appendChild(document.createElement('div'))
|
|
||||||
wrapper.className = prefix
|
|
||||||
|
|
||||||
const mouseOutside = (ev: MouseEvent) => {
|
|
||||||
if (!wrapper.contains(ev.target as Node)) close()
|
|
||||||
}
|
|
||||||
setTimeout(() => window.addEventListener('mousedown', mouseOutside), 50)
|
|
||||||
const close = () => {
|
|
||||||
window.removeEventListener('mousedown', mouseOutside)
|
|
||||||
if (wrapper.parentNode) wrapper.remove()
|
|
||||||
}
|
|
||||||
|
|
||||||
const domFields: HTMLElement[] = []
|
|
||||||
options.fields.forEach((name) => {
|
|
||||||
domFields.push(options.fields[name].render())
|
|
||||||
})
|
|
||||||
|
|
||||||
const submitButton = document.createElement('button')
|
|
||||||
submitButton.type = 'submit'
|
|
||||||
submitButton.className = prefix + '-submit'
|
|
||||||
submitButton.textContent = 'OK'
|
|
||||||
const cancelButton = document.createElement('button')
|
|
||||||
cancelButton.type = 'button'
|
|
||||||
cancelButton.className = prefix + '-cancel'
|
|
||||||
cancelButton.textContent = 'Cancel'
|
|
||||||
cancelButton.addEventListener('click', close)
|
|
||||||
|
|
||||||
const form = wrapper.appendChild(document.createElement('form'))
|
|
||||||
if (options.title) {
|
|
||||||
form.appendChild(document.createElement('h5')).textContent = options.title
|
|
||||||
}
|
|
||||||
domFields.forEach((field: HTMLElement) => {
|
|
||||||
form.appendChild(document.createElement('div')).appendChild(field)
|
|
||||||
})
|
|
||||||
const buttons = form.appendChild(document.createElement('div'))
|
|
||||||
buttons.className = prefix + '-buttons'
|
|
||||||
buttons.appendChild(submitButton)
|
|
||||||
buttons.appendChild(document.createTextNode(' '))
|
|
||||||
buttons.appendChild(cancelButton)
|
|
||||||
|
|
||||||
const box = wrapper.getBoundingClientRect()
|
|
||||||
wrapper.style.top = (window.innerHeight - box.height) / 2 + 'px'
|
|
||||||
wrapper.style.left = (window.innerWidth - box.width) / 2 + 'px'
|
|
||||||
|
|
||||||
const submit = () => {
|
|
||||||
const params = getValues(options.fields, domFields)
|
|
||||||
if (params) {
|
|
||||||
close()
|
|
||||||
options.callback(params)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
form.addEventListener('submit', (e) => {
|
|
||||||
e.preventDefault()
|
|
||||||
submit()
|
|
||||||
})
|
|
||||||
|
|
||||||
form.addEventListener('keydown', (e) => {
|
|
||||||
if (e.key === 'Escape') {
|
|
||||||
e.preventDefault()
|
|
||||||
close()
|
|
||||||
} else if (e.key === 'Enter' && !(e.ctrlKey || e.metaKey || e.shiftKey)) {
|
|
||||||
e.preventDefault()
|
|
||||||
submit()
|
|
||||||
} else if (e.key === 'Tab') {
|
|
||||||
window.setTimeout(() => {
|
|
||||||
if (!wrapper.contains(document.activeElement)) close()
|
|
||||||
}, 500)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const input = form.elements[0] as HTMLInputElement
|
|
||||||
if (input) input.focus()
|
|
||||||
}
|
|
||||||
|
|
||||||
function getValues(fields: any, domFields: HTMLElement[]) {
|
|
||||||
const result = Object.create(null)
|
|
||||||
let i = 0
|
|
||||||
fields.forEarch((name) => {
|
|
||||||
const field = fields[name]
|
|
||||||
const dom = domFields[i++]
|
|
||||||
const value = field.read(dom)
|
|
||||||
const bad = field.validate(value)
|
|
||||||
if (bad) {
|
|
||||||
reportInvalid(dom, bad)
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
result[name] = field.clean(value)
|
|
||||||
})
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
function reportInvalid(dom: HTMLElement, message: string) {
|
|
||||||
const msg: HTMLElement = dom.parentNode.appendChild(document.createElement('div'))
|
|
||||||
msg.style.left = dom.offsetLeft + dom.offsetWidth + 2 + 'px'
|
|
||||||
msg.style.top = dom.offsetTop - 5 + 'px'
|
|
||||||
msg.className = 'ProseMirror-invalid'
|
|
||||||
msg.textContent = message
|
|
||||||
setTimeout(msg.remove, 1500)
|
|
||||||
}
|
|
||||||
|
|
||||||
export class Field {
|
|
||||||
options: any
|
|
||||||
constructor(options: any) {
|
|
||||||
this.options = options
|
|
||||||
}
|
|
||||||
|
|
||||||
read(dom: any) {
|
|
||||||
return dom.value
|
|
||||||
}
|
|
||||||
// :: (any) → ?string
|
|
||||||
// A field-type-specific validation function.
|
|
||||||
validateType(_value) {
|
|
||||||
return typeof _value === typeof ''
|
|
||||||
}
|
|
||||||
|
|
||||||
validate(value: any) {
|
|
||||||
if (!value && this.options.required) return 'Required field'
|
|
||||||
|
|
||||||
return this.validateType(value) || (this.options.validate && this.options.validate(value))
|
|
||||||
}
|
|
||||||
|
|
||||||
clean(value: any) {
|
|
||||||
return this.options.clean ? this.options.clean(value) : value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class TextField extends Field {
|
|
||||||
render() {
|
|
||||||
const input: HTMLInputElement = document.createElement('input')
|
|
||||||
|
|
||||||
input.type = 'text'
|
|
||||||
input.placeholder = this.options.label
|
|
||||||
input.value = this.options.value || ''
|
|
||||||
input.autocomplete = 'off'
|
|
||||||
return input
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class SelectField extends Field {
|
|
||||||
render() {
|
|
||||||
const select = document.createElement('select')
|
|
||||||
this.options.options.forEach((o: { value: string; label: string }) => {
|
|
||||||
const opt = select.appendChild(document.createElement('option'))
|
|
||||||
opt.value = o.value
|
|
||||||
opt.selected = o.value === this.options.value
|
|
||||||
opt.label = o.label
|
|
||||||
})
|
|
||||||
return select
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,39 +0,0 @@
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
||||||
// @ts-nocheck
|
|
||||||
import { Plugin } from 'prosemirror-state'
|
|
||||||
import type { EditorView } from 'prosemirror-view'
|
|
||||||
import type { ProseMirrorExtension } from '../helpers'
|
|
||||||
|
|
||||||
const scroll = (view: EditorView) => {
|
|
||||||
if (!view.state.selection.empty) return false
|
|
||||||
const pos = view.state.selection.$head.start()
|
|
||||||
const resolved = view.state.doc.resolve(pos)
|
|
||||||
if (resolved.parent.type.spec.code) return false
|
|
||||||
|
|
||||||
const dom = view.domAtPos(pos)
|
|
||||||
if (dom.node !== view.dom) {
|
|
||||||
scrollToElem(dom.node as Element)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const scrollToElem = (node: Element) => {
|
|
||||||
node.scrollIntoView({
|
|
||||||
block: 'center',
|
|
||||||
behavior: 'smooth'
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const scrollIntoView = new Plugin({
|
|
||||||
props: {
|
|
||||||
handleDOMEvents: {
|
|
||||||
keyup: (view: EditorView) => {
|
|
||||||
scroll(view)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
export default (enabled: boolean): ProseMirrorExtension => ({
|
|
||||||
plugins: (prev) => (enabled ? [...prev, scrollIntoView] : prev)
|
|
||||||
})
|
|
|
@ -1,55 +0,0 @@
|
||||||
import { renderGrouped } from 'prosemirror-menu'
|
|
||||||
import { Plugin } from 'prosemirror-state'
|
|
||||||
import type { ProseMirrorExtension } from '../helpers'
|
|
||||||
import { buildMenuItems } from './menu'
|
|
||||||
import editorStyles from '../../components/Editor.module.scss'
|
|
||||||
|
|
||||||
export class SelectionTooltip {
|
|
||||||
tooltip: any
|
|
||||||
|
|
||||||
constructor(view: any, schema: any) {
|
|
||||||
this.tooltip = document.createElement('div')
|
|
||||||
this.tooltip.className = editorStyles.tooltip
|
|
||||||
view.dom.parentNode.appendChild(this.tooltip)
|
|
||||||
const { dom } = renderGrouped(view, buildMenuItems(schema).fullMenu as any)
|
|
||||||
this.tooltip.appendChild(dom)
|
|
||||||
this.update(view, null)
|
|
||||||
}
|
|
||||||
|
|
||||||
update(view: any, lastState: any) {
|
|
||||||
const state = view.state
|
|
||||||
if (lastState && lastState.doc.eq(state.doc) && lastState.selection.eq(state.selection)) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (state.selection.empty) {
|
|
||||||
this.tooltip.style.display = 'none'
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
this.tooltip.style.display = ''
|
|
||||||
const { from, to } = state.selection
|
|
||||||
const start = view.coordsAtPos(from),
|
|
||||||
end = view.coordsAtPos(to)
|
|
||||||
const box = this.tooltip.offsetParent.getBoundingClientRect()
|
|
||||||
const left = Math.max((start.left + end.left) / 2, start.left + 3)
|
|
||||||
this.tooltip.style.left = left - box.left + 'px'
|
|
||||||
this.tooltip.style.bottom = box.bottom - (start.top + 15) + 'px'
|
|
||||||
}
|
|
||||||
|
|
||||||
destroy() {
|
|
||||||
this.tooltip.remove()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function toolTip(schema: any) {
|
|
||||||
return new Plugin({
|
|
||||||
view(editorView: any) {
|
|
||||||
return new SelectionTooltip(editorView, schema)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export default (): ProseMirrorExtension => ({
|
|
||||||
plugins: (prev, schema) => [...prev, toolTip(schema)]
|
|
||||||
})
|
|
|
@ -1,25 +0,0 @@
|
||||||
import { inputRules } from 'prosemirror-inputrules'
|
|
||||||
import type { MarkSpec, MarkType } from 'prosemirror-model'
|
|
||||||
import { markInputRule } from './mark-input-rule'
|
|
||||||
import type { ProseMirrorExtension } from '../helpers'
|
|
||||||
import type OrderedMap from 'orderedmap'
|
|
||||||
|
|
||||||
const strikethroughRule = (nodeType: MarkType) => markInputRule(/~{2}(.+)~{2}$/, nodeType)
|
|
||||||
|
|
||||||
const strikethroughSchema = {
|
|
||||||
strikethrough: {
|
|
||||||
parseDOM: [{ tag: 'del' }],
|
|
||||||
toDOM: () => ['del']
|
|
||||||
}
|
|
||||||
} as MarkSpec
|
|
||||||
|
|
||||||
export default (): ProseMirrorExtension => ({
|
|
||||||
schema: (prev) => ({
|
|
||||||
...prev,
|
|
||||||
marks: (prev.marks as OrderedMap<MarkSpec>).append(strikethroughSchema)
|
|
||||||
}),
|
|
||||||
plugins: (prev, schema) => [
|
|
||||||
...prev,
|
|
||||||
inputRules({ rules: [strikethroughRule(schema.marks.strikethrough)] })
|
|
||||||
]
|
|
||||||
})
|
|
|
@ -1,313 +0,0 @@
|
||||||
import { EditorState, Selection } from 'prosemirror-state'
|
|
||||||
import type { Node, Schema, ResolvedPos, NodeSpec } from 'prosemirror-model'
|
|
||||||
import { InputRule, inputRules } from 'prosemirror-inputrules'
|
|
||||||
import { keymap } from 'prosemirror-keymap'
|
|
||||||
import type { ProseMirrorExtension } from '../helpers'
|
|
||||||
import type OrderedMap from 'orderedmap'
|
|
||||||
|
|
||||||
export const tableInputRule = (schema: Schema) =>
|
|
||||||
new InputRule(
|
|
||||||
new RegExp('^\\|{2,}\\s$'),
|
|
||||||
(state: EditorState, match: string[], start: number, end: number) => {
|
|
||||||
const tr = state.tr
|
|
||||||
const columns = Array.from({ length: match[0].trim().length - 1 })
|
|
||||||
const headers = columns.map(() => schema.node(schema.nodes.table_header, {}))
|
|
||||||
const cells = columns.map(() => schema.node(schema.nodes.table_cell, {}))
|
|
||||||
const table = schema.node(schema.nodes.table, {}, [
|
|
||||||
schema.node(schema.nodes.table_head, {}, schema.node(schema.nodes.table_row, {}, headers)),
|
|
||||||
schema.node(schema.nodes.table_body, {}, schema.node(schema.nodes.table_row, {}, cells))
|
|
||||||
])
|
|
||||||
|
|
||||||
tr.delete(start - 1, end)
|
|
||||||
tr.insert(start - 1, table)
|
|
||||||
tr.setSelection(Selection.near(tr.doc.resolve(start + 3)))
|
|
||||||
return tr
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
const tableSchema = {
|
|
||||||
table: {
|
|
||||||
content: '(table_head | table_body)*',
|
|
||||||
isolating: true,
|
|
||||||
selectable: false,
|
|
||||||
group: 'block',
|
|
||||||
parseDOM: [{ tag: 'div[data-type="table"]' }],
|
|
||||||
toDOM: () => [
|
|
||||||
'div',
|
|
||||||
{
|
|
||||||
class: 'table-container',
|
|
||||||
'data-type': 'table'
|
|
||||||
},
|
|
||||||
['table', 0]
|
|
||||||
]
|
|
||||||
},
|
|
||||||
table_head: {
|
|
||||||
content: 'table_row',
|
|
||||||
isolating: true,
|
|
||||||
group: 'table_block',
|
|
||||||
selectable: false,
|
|
||||||
parseDOM: [{ tag: 'thead' }],
|
|
||||||
toDOM: () => ['thead', 0]
|
|
||||||
},
|
|
||||||
table_body: {
|
|
||||||
content: 'table_row+',
|
|
||||||
isolating: true,
|
|
||||||
group: 'table_block',
|
|
||||||
selectable: false,
|
|
||||||
parseDOM: [{ tag: 'tbody' }],
|
|
||||||
toDOM: () => ['tbody', 0]
|
|
||||||
},
|
|
||||||
table_row: {
|
|
||||||
content: '(table_cell | table_header)*',
|
|
||||||
parseDOM: [{ tag: 'tr' }],
|
|
||||||
toDOM: () => ['tr', 0]
|
|
||||||
},
|
|
||||||
table_cell: {
|
|
||||||
content: 'inline*',
|
|
||||||
isolating: true,
|
|
||||||
group: 'table_block',
|
|
||||||
selectable: false,
|
|
||||||
attrs: { style: { default: null } },
|
|
||||||
parseDOM: [
|
|
||||||
{
|
|
||||||
tag: 'td',
|
|
||||||
getAttrs: (dom: HTMLElement) => {
|
|
||||||
const textAlign = dom.style.textAlign
|
|
||||||
return textAlign ? { style: `text-align: ${textAlign}` } : null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
toDOM: (node: Node) => ['td', node.attrs, 0]
|
|
||||||
},
|
|
||||||
table_header: {
|
|
||||||
content: 'inline*',
|
|
||||||
isolating: true,
|
|
||||||
group: 'table_block',
|
|
||||||
selectable: false,
|
|
||||||
attrs: { style: { default: null } },
|
|
||||||
parseDOM: [
|
|
||||||
{
|
|
||||||
tag: 'th',
|
|
||||||
getAttrs: (dom: HTMLElement) => {
|
|
||||||
const textAlign = dom.style.textAlign
|
|
||||||
return textAlign ? { style: `text-align: ${textAlign}` } : null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
toDOM: (node: Node) => ['th', node.attrs, 0]
|
|
||||||
}
|
|
||||||
} as NodeSpec
|
|
||||||
|
|
||||||
const findParentPos = ($pos: ResolvedPos, fn: (n: Node) => boolean) => {
|
|
||||||
for (let d = $pos.depth; d > 0; d--) {
|
|
||||||
if (fn($pos.node(d))) return $pos.doc.resolve($pos.before(d + 1))
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const findTableCellPos = ($pos: ResolvedPos, header = true) =>
|
|
||||||
findParentPos($pos, (n) => n.type.name === 'table_cell' || (header && n.type.name === 'table_header'))
|
|
||||||
|
|
||||||
const findTableRowPos = ($pos: ResolvedPos) => findParentPos($pos, (n) => n.type.name === 'table_row')
|
|
||||||
|
|
||||||
const findTableHeadPos = ($pos: ResolvedPos) => findParentPos($pos, (n) => n.type.name === 'table_head')
|
|
||||||
|
|
||||||
const findTablePos = ($pos: ResolvedPos) => findParentPos($pos, (n) => n.type.name === 'table')
|
|
||||||
|
|
||||||
const findNodePosition = (node: Node, fn: (n: Node, p: Node) => boolean) => {
|
|
||||||
let result = -1
|
|
||||||
node.descendants((n, pos, p) => {
|
|
||||||
if (result !== -1) {
|
|
||||||
return false
|
|
||||||
} else if (fn(n, p)) {
|
|
||||||
result = pos
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
const findVertTableCellPos = ($pos: ResolvedPos, dir = 'up') => {
|
|
||||||
const cellPos = findTableCellPos($pos)
|
|
||||||
const rowPos = findTableRowPos($pos)
|
|
||||||
const offset = cellPos.pos - ($pos.before() + 1)
|
|
||||||
|
|
||||||
const add = dir === 'up' ? -1 : rowPos.node().nodeSize + 1
|
|
||||||
const nodeBeforePos = $pos.doc.resolve(rowPos.before() + add)
|
|
||||||
let rowBeforePos = findTableRowPos(nodeBeforePos)
|
|
||||||
|
|
||||||
if (!rowBeforePos) {
|
|
||||||
const table = $pos.node(0)
|
|
||||||
const tablePos = findTablePos($pos)
|
|
||||||
const inTableHead = !!findTableHeadPos($pos)
|
|
||||||
|
|
||||||
if (dir === 'up' && inTableHead) {
|
|
||||||
return $pos.doc.resolve(Math.max(0, tablePos.before() - 1))
|
|
||||||
} else if (dir === 'down' && !inTableHead) {
|
|
||||||
return $pos.doc.resolve(tablePos.after())
|
|
||||||
}
|
|
||||||
|
|
||||||
const pos = findNodePosition(table, (n, p) => {
|
|
||||||
return inTableHead
|
|
||||||
? p.type.name === 'table_body' && n.type.name === 'table_row'
|
|
||||||
: p.type.name === 'table_head' && n.type.name === 'table_row'
|
|
||||||
})
|
|
||||||
|
|
||||||
rowBeforePos = $pos.doc.resolve(pos + 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
const targetCell = $pos.doc.resolve(rowBeforePos.posAtIndex(rowPos.index()) + 1)
|
|
||||||
const targetCellTextSize = getTextSize(targetCell.node())
|
|
||||||
const cellOffset = offset > targetCellTextSize ? targetCellTextSize : offset
|
|
||||||
return $pos.doc.resolve(targetCell.pos + cellOffset)
|
|
||||||
}
|
|
||||||
|
|
||||||
const getTextSize = (n: Node) => {
|
|
||||||
let size = 0
|
|
||||||
n.descendants((d: Node) => {
|
|
||||||
size += d.text?.length ?? 0
|
|
||||||
})
|
|
||||||
|
|
||||||
return size
|
|
||||||
}
|
|
||||||
|
|
||||||
export default (): ProseMirrorExtension => ({
|
|
||||||
schema: (prev) => ({
|
|
||||||
...prev,
|
|
||||||
nodes: (prev.nodes as OrderedMap<NodeSpec>).append(tableSchema)
|
|
||||||
}),
|
|
||||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
|
||||||
plugins: (prev, schema) => [
|
|
||||||
keymap({
|
|
||||||
'Ctrl-Enter': (state, dispatch) => {
|
|
||||||
const tablePos = findTablePos(state.selection.$head)
|
|
||||||
if (!tablePos) return false
|
|
||||||
const targetPos = tablePos.after()
|
|
||||||
const tr = state.tr
|
|
||||||
tr.insert(targetPos, state.schema.node('paragraph'))
|
|
||||||
tr.setSelection(Selection.near(tr.doc.resolve(targetPos)))
|
|
||||||
dispatch(tr)
|
|
||||||
return true
|
|
||||||
},
|
|
||||||
Backspace: (state, dispatch) => {
|
|
||||||
const sel = state.selection
|
|
||||||
if (!sel.empty) return false
|
|
||||||
const cellPos = findTableCellPos(sel.$head)
|
|
||||||
if (!cellPos) return false
|
|
||||||
|
|
||||||
if (getTextSize(cellPos.node()) === 0) {
|
|
||||||
const rowPos = findTableRowPos(sel.$head)
|
|
||||||
const tablePos = findTablePos(sel.$head)
|
|
||||||
const before = state.doc.resolve(cellPos.before() - 1)
|
|
||||||
|
|
||||||
const cellBeforePos = findTableCellPos(before)
|
|
||||||
const inTableHead = !!findTableHeadPos(sel.$head)
|
|
||||||
|
|
||||||
if (cellBeforePos) {
|
|
||||||
const tr = state.tr
|
|
||||||
tr.setSelection(Selection.near(before))
|
|
||||||
dispatch(tr)
|
|
||||||
return true
|
|
||||||
} else if (!inTableHead && getTextSize(rowPos.node()) === 0) {
|
|
||||||
const tr = state.tr
|
|
||||||
tr.delete(before.pos - 1, before.pos + rowPos.node().nodeSize)
|
|
||||||
tr.setSelection(Selection.near(tr.doc.resolve(before.pos - 4)))
|
|
||||||
dispatch(tr)
|
|
||||||
return true
|
|
||||||
} else if (getTextSize(tablePos.node()) === 0) {
|
|
||||||
const tr = state.tr
|
|
||||||
tr.delete(tablePos.before(), tablePos.before() + tablePos.node().nodeSize)
|
|
||||||
dispatch(tr)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false
|
|
||||||
},
|
|
||||||
Enter: (state, dispatch) => {
|
|
||||||
const sel = state.selection
|
|
||||||
if (!sel.empty) return false
|
|
||||||
const cellPos = findTableCellPos(sel.$head)
|
|
||||||
if (!cellPos) return false
|
|
||||||
|
|
||||||
const rowPos = findTableRowPos(sel.$head)
|
|
||||||
const cells = []
|
|
||||||
rowPos.node().forEach((cell) => {
|
|
||||||
cells.push(schema.nodes.table_cell.create(cell.attrs))
|
|
||||||
})
|
|
||||||
const newRow = schema.nodes.table_row.create(null, cells)
|
|
||||||
|
|
||||||
const theadPos = findTableHeadPos(sel.$head)
|
|
||||||
if (theadPos) {
|
|
||||||
const tablePos = findTablePos(sel.$head)
|
|
||||||
let tbodyPos: number
|
|
||||||
tablePos.node().descendants((node, pos) => {
|
|
||||||
if (node.type.name === 'table_body') {
|
|
||||||
tbodyPos = tablePos.pos + pos
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
if (tbodyPos) {
|
|
||||||
const tbody = state.doc.resolve(tbodyPos + 1)
|
|
||||||
const tr = state.tr.insert(tbody.pos, newRow)
|
|
||||||
tr.setSelection(Selection.near(tr.doc.resolve(tbody.pos)))
|
|
||||||
dispatch(tr)
|
|
||||||
} else {
|
|
||||||
const tbody = schema.nodes.table_body.create(null, [newRow])
|
|
||||||
const targetPos = theadPos.after()
|
|
||||||
const tr = state.tr.insert(targetPos, tbody)
|
|
||||||
tr.setSelection(Selection.near(tr.doc.resolve(targetPos)))
|
|
||||||
dispatch(tr)
|
|
||||||
}
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
const targetPos = sel.$head.after(-1)
|
|
||||||
const tr = state.tr.insert(targetPos, newRow)
|
|
||||||
tr.setSelection(Selection.near(tr.doc.resolve(targetPos)))
|
|
||||||
|
|
||||||
dispatch(tr)
|
|
||||||
return true
|
|
||||||
},
|
|
||||||
ArrowUp: (state, dispatch) => {
|
|
||||||
const sel = state.selection
|
|
||||||
if (!sel.empty) return false
|
|
||||||
const cellPos = findTableCellPos(sel.$head)
|
|
||||||
if (!cellPos) return false
|
|
||||||
const abovePos = findVertTableCellPos(sel.$head)
|
|
||||||
if (abovePos) {
|
|
||||||
const tr = state.tr
|
|
||||||
let selection = Selection.near(abovePos)
|
|
||||||
if (abovePos.pos === 0 && cellPos.parentOffset === 0) {
|
|
||||||
tr.insert(0, state.schema.node('paragraph'))
|
|
||||||
selection = Selection.near(tr.doc.resolve(0))
|
|
||||||
}
|
|
||||||
tr.setSelection(selection)
|
|
||||||
dispatch(tr)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
return false
|
|
||||||
},
|
|
||||||
ArrowDown: (state, dispatch) => {
|
|
||||||
const sel = state.selection
|
|
||||||
if (!sel.empty) return false
|
|
||||||
const cellPos = findTableCellPos(sel.$head)
|
|
||||||
if (!cellPos) return false
|
|
||||||
const belowPos = findVertTableCellPos(sel.$head, 'down')
|
|
||||||
if (belowPos) {
|
|
||||||
const tr = state.tr
|
|
||||||
tr.setSelection(Selection.near(belowPos))
|
|
||||||
dispatch(tr)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
...prev,
|
|
||||||
inputRules({ rules: [tableInputRule(schema)] })
|
|
||||||
]
|
|
||||||
})
|
|
|
@ -1,101 +0,0 @@
|
||||||
import {
|
|
||||||
DOMOutputSpec,
|
|
||||||
DOMSerializer,
|
|
||||||
Node as ProsemirrorNode,
|
|
||||||
NodeSpec,
|
|
||||||
NodeType,
|
|
||||||
Schema
|
|
||||||
} from 'prosemirror-model'
|
|
||||||
import type { EditorView } from 'prosemirror-view'
|
|
||||||
import { wrappingInputRule, inputRules } from 'prosemirror-inputrules'
|
|
||||||
import { splitListItem } from 'prosemirror-schema-list'
|
|
||||||
import { keymap } from 'prosemirror-keymap'
|
|
||||||
import type { NodeViewFn, ProseMirrorExtension } from '../helpers'
|
|
||||||
import type OrderedMap from 'orderedmap'
|
|
||||||
|
|
||||||
const todoListRule = (nodeType: NodeType) =>
|
|
||||||
wrappingInputRule(new RegExp('^\\[( |x)]\\s$'), nodeType, (match) => ({
|
|
||||||
done: match[1] === 'x'
|
|
||||||
}))
|
|
||||||
|
|
||||||
const todoListSchema = {
|
|
||||||
todo_item: {
|
|
||||||
content: 'paragraph+',
|
|
||||||
defining: true,
|
|
||||||
group: 'block',
|
|
||||||
attrs: { done: { default: false } },
|
|
||||||
parseDOM: [
|
|
||||||
{
|
|
||||||
tag: 'div[data-type="todo-item"]',
|
|
||||||
getAttrs: (dom: Element) => ({
|
|
||||||
done: dom.querySelector('input')?.checked
|
|
||||||
})
|
|
||||||
}
|
|
||||||
],
|
|
||||||
toDOM: (node: ProsemirrorNode) => [
|
|
||||||
'div',
|
|
||||||
{
|
|
||||||
class: `todo-item ${node.attrs.done ? 'done' : ''}`,
|
|
||||||
'data-type': 'todo-item'
|
|
||||||
},
|
|
||||||
[
|
|
||||||
'label',
|
|
||||||
{ contenteditable: false },
|
|
||||||
[
|
|
||||||
'input',
|
|
||||||
{
|
|
||||||
type: 'checkbox',
|
|
||||||
...(node.attrs.done ? { checked: 'checked' } : {})
|
|
||||||
}
|
|
||||||
]
|
|
||||||
],
|
|
||||||
['div', 0]
|
|
||||||
]
|
|
||||||
}
|
|
||||||
} as NodeSpec
|
|
||||||
|
|
||||||
class TodoItemView {
|
|
||||||
contentDOM: Node
|
|
||||||
dom: Node
|
|
||||||
view: EditorView
|
|
||||||
getPos: () => number
|
|
||||||
|
|
||||||
constructor(node: ProsemirrorNode, view: EditorView, getPos: () => number) {
|
|
||||||
const dom: DOMOutputSpec = node.type.spec.toDOM(node)
|
|
||||||
const res = DOMSerializer.renderSpec(document, dom)
|
|
||||||
this.dom = res.dom
|
|
||||||
this.contentDOM = res.contentDOM
|
|
||||||
this.view = view
|
|
||||||
this.getPos = getPos
|
|
||||||
;(this.dom as HTMLElement).querySelector('input').addEventListener('click', this.handleClick.bind(this))
|
|
||||||
}
|
|
||||||
|
|
||||||
handleClick(e: MouseEvent) {
|
|
||||||
const tr = this.view.state.tr
|
|
||||||
const elem = e.target as HTMLInputElement
|
|
||||||
tr.setNodeMarkup(this.getPos(), null, { done: elem.checked })
|
|
||||||
this.view.dispatch(tr)
|
|
||||||
this.view.focus()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const todoListKeymap = (schema: Schema) => ({
|
|
||||||
Enter: splitListItem(schema.nodes.todo_item)
|
|
||||||
})
|
|
||||||
|
|
||||||
export default (): ProseMirrorExtension => ({
|
|
||||||
schema: (prev) => ({
|
|
||||||
...prev,
|
|
||||||
nodes: (prev.nodes as OrderedMap<NodeSpec>).append(todoListSchema)
|
|
||||||
}),
|
|
||||||
plugins: (prev, schema) => [
|
|
||||||
keymap(todoListKeymap(schema)),
|
|
||||||
...prev,
|
|
||||||
inputRules({ rules: [todoListRule(schema.nodes.todo_item)] })
|
|
||||||
],
|
|
||||||
nodeViews: {
|
|
||||||
todo_item: (node: ProsemirrorNode, view: EditorView, getPos: () => number) => {
|
|
||||||
return new TodoItemView(node, view, getPos)
|
|
||||||
}
|
|
||||||
} as unknown as { [key: string]: NodeViewFn }
|
|
||||||
})
|
|
|
@ -1,27 +0,0 @@
|
||||||
import { Plugin, EditorState } from 'prosemirror-state'
|
|
||||||
import type { Node, Schema, SchemaSpec } from 'prosemirror-model'
|
|
||||||
import type { Decoration, EditorView, NodeView } from 'prosemirror-view'
|
|
||||||
|
|
||||||
export interface ProseMirrorExtension {
|
|
||||||
schema?: (prev: SchemaSpec) => SchemaSpec
|
|
||||||
plugins?: (prev: Plugin[], schema: Schema) => Plugin[]
|
|
||||||
nodeViews?: { [key: string]: NodeViewFn }
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ProseMirrorState = EditorState | unknown
|
|
||||||
|
|
||||||
export type NodeViewFn = (
|
|
||||||
node: Node,
|
|
||||||
view: EditorView,
|
|
||||||
getPos: () => number,
|
|
||||||
decorations: Decoration[]
|
|
||||||
) => NodeView
|
|
||||||
|
|
||||||
export const isInitialized = (state) => state !== undefined && state instanceof EditorState
|
|
||||||
|
|
||||||
export const isEmpty = (state) =>
|
|
||||||
!isInitialized(state) ||
|
|
||||||
(state.doc.childCount === 1 &&
|
|
||||||
!state.doc.firstChild.type.spec.code &&
|
|
||||||
state.doc.firstChild.isTextblock &&
|
|
||||||
state.doc.firstChild.content.size === 0)
|
|
|
@ -1,50 +0,0 @@
|
||||||
import { uniqueNamesGenerator, adjectives, animals } from 'unique-names-generator'
|
|
||||||
import { Awareness } from 'y-protocols/awareness'
|
|
||||||
import { WebrtcProvider } from 'y-webrtc'
|
|
||||||
import { Doc, XmlFragment } from 'yjs'
|
|
||||||
// import type { Reaction } from '../../../graphql/types.gen'
|
|
||||||
// import { setReactions } from '../../../stores/editor'
|
|
||||||
|
|
||||||
export const roomConnect = (
|
|
||||||
room: string,
|
|
||||||
username = '',
|
|
||||||
keyname = 'collab'
|
|
||||||
): [XmlFragment, WebrtcProvider] => {
|
|
||||||
const ydoc = new Doc()
|
|
||||||
// const yarr = ydoc.getArray(keyname + '-reactions')
|
|
||||||
// TODO: use reactions
|
|
||||||
// yarr.observeDeep(() => {
|
|
||||||
// console.debug('[p2p] yarray updated', yarr.toArray())
|
|
||||||
// setReactions(yarr.toArray() as Reaction[])
|
|
||||||
// })
|
|
||||||
const yXmlFragment = ydoc.getXmlFragment(keyname)
|
|
||||||
const webrtcOptions = {
|
|
||||||
awareness: new Awareness(ydoc),
|
|
||||||
filterBcConns: true,
|
|
||||||
maxConns: 33,
|
|
||||||
signaling: [
|
|
||||||
// 'wss://signaling.discours.io',
|
|
||||||
// 'wss://stun.l.google.com:19302',
|
|
||||||
'wss://y-webrtc-signaling-eu.herokuapp.com',
|
|
||||||
'wss://signaling.yjs.dev'
|
|
||||||
],
|
|
||||||
peerOpts: {},
|
|
||||||
password: ''
|
|
||||||
}
|
|
||||||
// connect with provider
|
|
||||||
const provider = new WebrtcProvider(room, ydoc, webrtcOptions)
|
|
||||||
console.debug('[p2p] provider', provider)
|
|
||||||
// setting username
|
|
||||||
provider.awareness.setLocalStateField('user', {
|
|
||||||
name:
|
|
||||||
username ??
|
|
||||||
uniqueNamesGenerator({
|
|
||||||
dictionaries: [adjectives, animals],
|
|
||||||
style: 'capital',
|
|
||||||
separator: ' ',
|
|
||||||
length: 2
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
return [yXmlFragment, provider]
|
|
||||||
}
|
|
|
@ -1,95 +0,0 @@
|
||||||
// import menu from './extension/menu'
|
|
||||||
// import scroll from './prosemirror/extension/scroll'
|
|
||||||
import { keymap } from 'prosemirror-keymap'
|
|
||||||
import type { ProseMirrorExtension } from './helpers'
|
|
||||||
import { Schema } from 'prosemirror-model'
|
|
||||||
import { t } from '../../../utils/intl'
|
|
||||||
import base from './extension/base'
|
|
||||||
import code from './extension/code'
|
|
||||||
import dragHandle from './extension/drag-handle'
|
|
||||||
import image from './extension/image'
|
|
||||||
import link from './extension/link'
|
|
||||||
import markdown from './extension/markdown'
|
|
||||||
import pasteMarkdown from './extension/paste-markdown'
|
|
||||||
import table from './extension/table'
|
|
||||||
import collab from './extension/collab'
|
|
||||||
import type { Collab, Config, ExtensionsProps, YOptions } from '../store/context'
|
|
||||||
import selectionMenu from './extension/selection'
|
|
||||||
import placeholder from './extension/placeholder'
|
|
||||||
import todoList from './extension/todo-list'
|
|
||||||
import strikethrough from './extension/strikethrough'
|
|
||||||
import scrollPlugin from './extension/scroll'
|
|
||||||
|
|
||||||
const customKeymap = (props: ExtensionsProps): ProseMirrorExtension => ({
|
|
||||||
plugins: (prev) => (props.keymap ? [...prev, keymap(props.keymap)] : prev)
|
|
||||||
})
|
|
||||||
|
|
||||||
export const createExtensions = (props: ExtensionsProps): ProseMirrorExtension[] => {
|
|
||||||
const extensions = [
|
|
||||||
placeholder(t('Just start typing...')),
|
|
||||||
customKeymap(props),
|
|
||||||
base(props.markdown),
|
|
||||||
selectionMenu(),
|
|
||||||
scrollPlugin(props.config?.typewriterMode)
|
|
||||||
]
|
|
||||||
|
|
||||||
if (props.markdown) {
|
|
||||||
extensions.push(
|
|
||||||
markdown(),
|
|
||||||
todoList(),
|
|
||||||
dragHandle(),
|
|
||||||
code(),
|
|
||||||
strikethrough(),
|
|
||||||
link(),
|
|
||||||
table(),
|
|
||||||
image(props.path),
|
|
||||||
pasteMarkdown()
|
|
||||||
/*
|
|
||||||
codeBlock({
|
|
||||||
theme: codeTheme(props.config),
|
|
||||||
typewriterMode: props.config.typewriterMode,
|
|
||||||
fontSize: props.config.fontSize,
|
|
||||||
prettier: props.config.prettier,
|
|
||||||
extensions: () => [codeMirrorKeymap(props)],
|
|
||||||
}),
|
|
||||||
*/
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (props.collab?.room) {
|
|
||||||
extensions.push(collab(props.y))
|
|
||||||
}
|
|
||||||
|
|
||||||
return extensions
|
|
||||||
}
|
|
||||||
|
|
||||||
export const createEmptyText = () => ({
|
|
||||||
doc: {
|
|
||||||
type: 'doc',
|
|
||||||
content: [{ type: 'paragraph' }]
|
|
||||||
},
|
|
||||||
selection: {
|
|
||||||
type: 'text',
|
|
||||||
anchor: 1,
|
|
||||||
head: 1
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
export const createSchema = (props: ExtensionsProps) => {
|
|
||||||
const extensions = createExtensions({
|
|
||||||
config: props.config,
|
|
||||||
markdown: props.markdown,
|
|
||||||
path: props.path,
|
|
||||||
keymap: props.keymap,
|
|
||||||
y: props.y
|
|
||||||
})
|
|
||||||
|
|
||||||
let schemaSpec = { nodes: {} }
|
|
||||||
for (const extension of extensions) {
|
|
||||||
if (extension.schema) {
|
|
||||||
schemaSpec = extension.schema(schemaSpec)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Schema(schemaSpec)
|
|
||||||
}
|
|
|
@ -1,11 +0,0 @@
|
||||||
import type { EditorState } from 'prosemirror-state'
|
|
||||||
import { serialize } from './markdown'
|
|
||||||
|
|
||||||
export const copy = async (text: string): Promise<void> => {
|
|
||||||
navigator.clipboard.writeText(text)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const copyAllAsMarkdown = async (state: EditorState): Promise<void> => {
|
|
||||||
const text = serialize(state)
|
|
||||||
navigator.clipboard.writeText(text)
|
|
||||||
}
|
|
|
@ -1,406 +0,0 @@
|
||||||
import { Store, createStore, unwrap } from 'solid-js/store'
|
|
||||||
import { v4 as uuidv4 } from 'uuid'
|
|
||||||
import type { EditorState } from 'prosemirror-state'
|
|
||||||
import { undo, redo } from 'prosemirror-history'
|
|
||||||
import { selectAll, deleteSelection } from 'prosemirror-commands'
|
|
||||||
import { undo as yUndo, redo as yRedo } from 'y-prosemirror'
|
|
||||||
import debounce from 'lodash/debounce'
|
|
||||||
import { createSchema, createExtensions, createEmptyText } from '../prosemirror/setup'
|
|
||||||
import { State, Draft, Config, ServiceError, newState, ExtensionsProps, EditorActions } from './context'
|
|
||||||
import { serialize, createMarkdownParser } from '../markdown'
|
|
||||||
import db from '../db'
|
|
||||||
import { isEmpty, isInitialized } from '../prosemirror/helpers'
|
|
||||||
import { useRouter } from '../../../stores/router'
|
|
||||||
|
|
||||||
const isText = (x) => x && x.doc && x.selection
|
|
||||||
const isState = (x) => typeof x.lastModified !== 'string' && Array.isArray(x.drafts || [])
|
|
||||||
const isDraft = (x): boolean => x && (x.text || x.path)
|
|
||||||
|
|
||||||
export const createCtrl = (initial: State): [Store<State>, EditorActions] => {
|
|
||||||
const [store, setState] = createStore(initial)
|
|
||||||
|
|
||||||
const onUndo = () => {
|
|
||||||
if (!isInitialized(store.text)) return false
|
|
||||||
const text = store.text as EditorState
|
|
||||||
if (store.collab?.started) {
|
|
||||||
yUndo(text)
|
|
||||||
} else {
|
|
||||||
undo(text, store.editorView.dispatch)
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
const onRedo = () => {
|
|
||||||
if (!isInitialized(store.text)) return false
|
|
||||||
const text = store.text as EditorState
|
|
||||||
if (store.collab?.started) {
|
|
||||||
yRedo(text)
|
|
||||||
} else {
|
|
||||||
redo(text, store.editorView.dispatch)
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
const discard = () => {
|
|
||||||
if (store.path) {
|
|
||||||
discardText()
|
|
||||||
} else if (store.drafts.length > 0 && isEmpty(store.text)) {
|
|
||||||
discardText()
|
|
||||||
} else {
|
|
||||||
selectAll(store.editorView.state, store.editorView.dispatch)
|
|
||||||
deleteSelection(store.editorView.state, store.editorView.dispatch)
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
const toggleMarkdown = () => {
|
|
||||||
const state = unwrap(store)
|
|
||||||
const editorState = store.text as EditorState
|
|
||||||
const markdown = !state.markdown
|
|
||||||
const selection = { type: 'text', anchor: 1, head: 1 }
|
|
||||||
let doc
|
|
||||||
|
|
||||||
if (markdown) {
|
|
||||||
const lines = serialize(editorState).split('\n')
|
|
||||||
const nodes = lines.map((text) =>
|
|
||||||
text ? { type: 'paragraph', content: [{ type: 'text', text }] } : { type: 'paragraph' }
|
|
||||||
)
|
|
||||||
|
|
||||||
doc = { type: 'doc', content: nodes }
|
|
||||||
} else {
|
|
||||||
const schema = createSchema({
|
|
||||||
config: state.config,
|
|
||||||
path: state.path,
|
|
||||||
y: state.collab?.y,
|
|
||||||
markdown,
|
|
||||||
keymap
|
|
||||||
})
|
|
||||||
|
|
||||||
const parser = createMarkdownParser(schema)
|
|
||||||
let textContent = ''
|
|
||||||
editorState.doc.forEach((node) => {
|
|
||||||
textContent += `${node.textContent}\n`
|
|
||||||
})
|
|
||||||
const text = parser.parse(textContent)
|
|
||||||
doc = text.toJSON()
|
|
||||||
}
|
|
||||||
|
|
||||||
const extensions = createExtensions({
|
|
||||||
config: state.config,
|
|
||||||
markdown,
|
|
||||||
path: state.path,
|
|
||||||
keymap,
|
|
||||||
y: state.collab?.y
|
|
||||||
})
|
|
||||||
|
|
||||||
setState({
|
|
||||||
text: { selection, doc },
|
|
||||||
extensions,
|
|
||||||
markdown
|
|
||||||
})
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
const keymap: ExtensionsProps['keymap'] = {
|
|
||||||
[`Mod-w`]: discard,
|
|
||||||
[`Mod-z`]: onUndo,
|
|
||||||
[`Shift-Mod-z`]: onRedo,
|
|
||||||
[`Mod-y`]: onRedo,
|
|
||||||
[`Mod-m`]: toggleMarkdown
|
|
||||||
}
|
|
||||||
|
|
||||||
const createTextFromDraft = async (draft: Draft) => {
|
|
||||||
const state = unwrap(store)
|
|
||||||
|
|
||||||
const extensions = createExtensions({
|
|
||||||
config: state.config,
|
|
||||||
markdown: draft.markdown,
|
|
||||||
path: draft.path,
|
|
||||||
keymap
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
|
||||||
text: draft.text,
|
|
||||||
extensions,
|
|
||||||
lastModified: draft.lastModified ? new Date(draft.lastModified) : undefined,
|
|
||||||
path: draft.path,
|
|
||||||
markdown: draft.markdown
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const addToDrafts = (drafts: Draft[], prev: State) => {
|
|
||||||
const text = prev.path ? undefined : (prev.text as EditorState).toJSON()
|
|
||||||
return [
|
|
||||||
...drafts,
|
|
||||||
{
|
|
||||||
text,
|
|
||||||
lastModified: prev.lastModified,
|
|
||||||
path: prev.path,
|
|
||||||
markdown: prev.markdown
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
const discardText = async () => {
|
|
||||||
const state = unwrap(store)
|
|
||||||
const index = state.drafts.length - 1
|
|
||||||
const draft = index !== -1 ? state.drafts[index] : undefined
|
|
||||||
|
|
||||||
let next: Partial<State>
|
|
||||||
if (draft) {
|
|
||||||
next = await createTextFromDraft(draft)
|
|
||||||
} else {
|
|
||||||
const extensions = createExtensions({
|
|
||||||
config: state.config ?? store.config,
|
|
||||||
markdown: state.markdown ?? store.markdown,
|
|
||||||
keymap
|
|
||||||
})
|
|
||||||
|
|
||||||
next = {
|
|
||||||
text: createEmptyText(),
|
|
||||||
extensions,
|
|
||||||
lastModified: undefined,
|
|
||||||
path: undefined,
|
|
||||||
markdown: state.markdown
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const drafts = state.drafts.filter((f: Draft) => f !== draft)
|
|
||||||
|
|
||||||
setState({
|
|
||||||
drafts,
|
|
||||||
...next,
|
|
||||||
collab: draft ? undefined : state.collab,
|
|
||||||
error: undefined
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const fetchData = async (): Promise<State> => {
|
|
||||||
const state: State = unwrap(store)
|
|
||||||
const { searchParams } = useRouter<{ room: string }>()
|
|
||||||
const room = searchParams().room
|
|
||||||
const args = { room: room ?? undefined }
|
|
||||||
const data = await db.get('state')
|
|
||||||
let parsed: State
|
|
||||||
if (data !== undefined) {
|
|
||||||
try {
|
|
||||||
parsed = JSON.parse(data)
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error)
|
|
||||||
throw new ServiceError('invalid_state', data)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!parsed) {
|
|
||||||
return { ...state, args }
|
|
||||||
}
|
|
||||||
|
|
||||||
let text = state.text
|
|
||||||
if (parsed.text) {
|
|
||||||
if (!isText(parsed.text)) {
|
|
||||||
throw new ServiceError('invalid_state', parsed.text)
|
|
||||||
}
|
|
||||||
|
|
||||||
text = parsed.text
|
|
||||||
}
|
|
||||||
|
|
||||||
const extensions = createExtensions({
|
|
||||||
path: parsed.path,
|
|
||||||
markdown: parsed.markdown,
|
|
||||||
keymap,
|
|
||||||
config: undefined
|
|
||||||
})
|
|
||||||
|
|
||||||
const newst = {
|
|
||||||
...parsed,
|
|
||||||
text,
|
|
||||||
extensions,
|
|
||||||
// config,
|
|
||||||
args
|
|
||||||
}
|
|
||||||
|
|
||||||
if (newst.lastModified) {
|
|
||||||
newst.lastModified = new Date(newst.lastModified)
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const draft of parsed.drafts || []) {
|
|
||||||
if (!isDraft(draft)) {
|
|
||||||
throw new ServiceError('invalid_draft', draft)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isState(newst)) {
|
|
||||||
throw new ServiceError('invalid_state', newst)
|
|
||||||
}
|
|
||||||
|
|
||||||
return newst
|
|
||||||
}
|
|
||||||
|
|
||||||
const getTheme = (state: State) => ({ theme: state.config.theme })
|
|
||||||
|
|
||||||
const clean = () => {
|
|
||||||
setState({
|
|
||||||
...newState(),
|
|
||||||
loading: 'initialized',
|
|
||||||
drafts: [],
|
|
||||||
fullscreen: store.fullscreen,
|
|
||||||
lastModified: new Date(),
|
|
||||||
error: undefined,
|
|
||||||
text: undefined
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const init = async () => {
|
|
||||||
let data = await fetchData()
|
|
||||||
try {
|
|
||||||
if (data.args.room) {
|
|
||||||
data = await doStartCollab(data)
|
|
||||||
} else if (!data.text) {
|
|
||||||
const text = createEmptyText()
|
|
||||||
const extensions = createExtensions({
|
|
||||||
config: data.config ?? store.config,
|
|
||||||
markdown: data.markdown ?? store.markdown,
|
|
||||||
keymap
|
|
||||||
})
|
|
||||||
data = { ...data, text, extensions }
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
data = { ...data, error: error.errorObject }
|
|
||||||
}
|
|
||||||
|
|
||||||
setState({
|
|
||||||
...data,
|
|
||||||
config: { ...data.config, ...getTheme(data) },
|
|
||||||
loading: 'initialized'
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const saveState = () =>
|
|
||||||
debounce(async (state: State) => {
|
|
||||||
const data: State = {
|
|
||||||
lastModified: state.lastModified,
|
|
||||||
drafts: state.drafts,
|
|
||||||
config: state.config,
|
|
||||||
path: state.path,
|
|
||||||
markdown: state.markdown,
|
|
||||||
collab: {
|
|
||||||
room: state.collab?.room
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isInitialized(state.text)) {
|
|
||||||
data.text = store.editorView.state.toJSON()
|
|
||||||
} else if (state.text) {
|
|
||||||
data.text = state.text
|
|
||||||
}
|
|
||||||
|
|
||||||
db.set('state', JSON.stringify(data))
|
|
||||||
}, 200)
|
|
||||||
|
|
||||||
const setFullscreen = (fullscreen: boolean) => {
|
|
||||||
setState({ fullscreen })
|
|
||||||
}
|
|
||||||
|
|
||||||
const startCollab = async () => {
|
|
||||||
const state: State = unwrap(store)
|
|
||||||
const update = await doStartCollab(state)
|
|
||||||
setState(update)
|
|
||||||
}
|
|
||||||
|
|
||||||
const doStartCollab = async (state: State): Promise<State> => {
|
|
||||||
const backup = state.args?.room && state.collab?.room !== state.args.room
|
|
||||||
const room = state.args?.room ?? uuidv4()
|
|
||||||
const { changeSearchParam } = useRouter<{ room: string }>()
|
|
||||||
changeSearchParam('room', room, true)
|
|
||||||
|
|
||||||
const { roomConnect } = await import('../prosemirror/p2p')
|
|
||||||
const [type, provider] = roomConnect(room)
|
|
||||||
|
|
||||||
const extensions = createExtensions({
|
|
||||||
config: state.config,
|
|
||||||
markdown: state.markdown,
|
|
||||||
path: state.path,
|
|
||||||
keymap,
|
|
||||||
y: { type, provider }
|
|
||||||
})
|
|
||||||
|
|
||||||
let newst = state
|
|
||||||
if ((backup && !isEmpty(state.text)) || state.path) {
|
|
||||||
let drafts = state.drafts
|
|
||||||
if (!state.error) {
|
|
||||||
drafts = addToDrafts(drafts, state)
|
|
||||||
}
|
|
||||||
|
|
||||||
newst = {
|
|
||||||
...state,
|
|
||||||
drafts,
|
|
||||||
lastModified: undefined,
|
|
||||||
path: undefined,
|
|
||||||
error: undefined
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
...newst,
|
|
||||||
extensions,
|
|
||||||
collab: { started: true, room, y: { type, provider } }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const stopCollab = (state: State) => {
|
|
||||||
state.collab.y?.provider.destroy()
|
|
||||||
const extensions = createExtensions({
|
|
||||||
config: state.config,
|
|
||||||
markdown: state.markdown,
|
|
||||||
path: state.path,
|
|
||||||
keymap
|
|
||||||
})
|
|
||||||
|
|
||||||
setState({ collab: undefined, extensions })
|
|
||||||
window.history.replaceState(null, '', '/')
|
|
||||||
}
|
|
||||||
|
|
||||||
const updateConfig = (config: Partial<Config>) => {
|
|
||||||
const state = unwrap(store)
|
|
||||||
const extensions = createExtensions({
|
|
||||||
config: { ...state.config, ...config },
|
|
||||||
markdown: state.markdown,
|
|
||||||
path: state.path,
|
|
||||||
keymap,
|
|
||||||
y: state.collab?.y
|
|
||||||
})
|
|
||||||
|
|
||||||
setState({
|
|
||||||
config: { ...state.config, ...config },
|
|
||||||
extensions,
|
|
||||||
lastModified: new Date()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const updatePath = (path: string) => {
|
|
||||||
setState({ path, lastModified: new Date() })
|
|
||||||
}
|
|
||||||
|
|
||||||
const updateTheme = () => {
|
|
||||||
const { theme } = getTheme(unwrap(store))
|
|
||||||
setState('config', { theme })
|
|
||||||
}
|
|
||||||
|
|
||||||
const ctrl = {
|
|
||||||
clean,
|
|
||||||
discard,
|
|
||||||
getTheme,
|
|
||||||
init,
|
|
||||||
saveState,
|
|
||||||
setFullscreen,
|
|
||||||
setState,
|
|
||||||
startCollab,
|
|
||||||
stopCollab,
|
|
||||||
toggleMarkdown,
|
|
||||||
updateConfig,
|
|
||||||
updatePath,
|
|
||||||
updateTheme
|
|
||||||
}
|
|
||||||
|
|
||||||
return [store, ctrl]
|
|
||||||
}
|
|
|
@ -1,130 +0,0 @@
|
||||||
import { createContext, useContext } from 'solid-js'
|
|
||||||
import type { Store } from 'solid-js/store'
|
|
||||||
import type { XmlFragment } from 'yjs'
|
|
||||||
import type { WebrtcProvider } from 'y-webrtc'
|
|
||||||
import type { ProseMirrorExtension, ProseMirrorState } from '../prosemirror/helpers'
|
|
||||||
import type { Command, EditorState } from 'prosemirror-state'
|
|
||||||
import type { EditorView } from 'prosemirror-view'
|
|
||||||
import type { Schema } from 'prosemirror-model'
|
|
||||||
|
|
||||||
export interface ExtensionsProps {
|
|
||||||
data?: unknown
|
|
||||||
keymap?: { [key: string]: Command }
|
|
||||||
config: Config
|
|
||||||
markdown: boolean
|
|
||||||
path?: string
|
|
||||||
y?: YOptions
|
|
||||||
schema?: Schema
|
|
||||||
collab?: Collab
|
|
||||||
typewriterMode?: boolean
|
|
||||||
}
|
|
||||||
export interface Args {
|
|
||||||
cwd?: string
|
|
||||||
draft?: string
|
|
||||||
room?: string
|
|
||||||
text?: any
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PrettierConfig {
|
|
||||||
printWidth: number
|
|
||||||
tabWidth: number
|
|
||||||
useTabs: boolean
|
|
||||||
semi: boolean
|
|
||||||
singleQuote: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Config {
|
|
||||||
theme: string
|
|
||||||
// codeTheme: string;
|
|
||||||
font: string
|
|
||||||
fontSize: number
|
|
||||||
contentWidth: number
|
|
||||||
typewriterMode: boolean
|
|
||||||
prettier: PrettierConfig
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ErrorObject {
|
|
||||||
id: string
|
|
||||||
props?: unknown
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface YOptions {
|
|
||||||
type: XmlFragment
|
|
||||||
provider: WebrtcProvider
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Collab {
|
|
||||||
started?: boolean
|
|
||||||
error?: boolean
|
|
||||||
room?: string
|
|
||||||
y?: YOptions
|
|
||||||
}
|
|
||||||
|
|
||||||
export type LoadingType = 'loading' | 'initialized'
|
|
||||||
|
|
||||||
export interface State {
|
|
||||||
text?: ProseMirrorState
|
|
||||||
editorView?: EditorView
|
|
||||||
extensions?: ProseMirrorExtension[]
|
|
||||||
markdown?: boolean
|
|
||||||
lastModified?: Date
|
|
||||||
drafts: Draft[]
|
|
||||||
config: Config
|
|
||||||
error?: ErrorObject
|
|
||||||
loading?: LoadingType
|
|
||||||
fullscreen?: boolean
|
|
||||||
collab?: Collab
|
|
||||||
path?: string
|
|
||||||
args?: Args
|
|
||||||
keymap?: { [key: string]: Command }
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Draft {
|
|
||||||
body?: string
|
|
||||||
lastModified?: Date
|
|
||||||
text?: { doc: EditorState['doc']; selection: { type: string; anchor: number; head: number } }
|
|
||||||
path?: string
|
|
||||||
markdown?: boolean
|
|
||||||
extensions?: ProseMirrorExtension[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface EditorActions {
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
[key: string]: any
|
|
||||||
}
|
|
||||||
|
|
||||||
export class ServiceError extends Error {
|
|
||||||
public errorObject: ErrorObject
|
|
||||||
constructor(id: string, props: unknown) {
|
|
||||||
super(id)
|
|
||||||
this.errorObject = { id, props }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const StateContext = createContext<[Store<State>, EditorActions]>([undefined, undefined])
|
|
||||||
|
|
||||||
export const useState = () => useContext(StateContext)
|
|
||||||
|
|
||||||
export const newState = (props: Partial<State> = {}): State => ({
|
|
||||||
extensions: [],
|
|
||||||
drafts: [],
|
|
||||||
loading: 'loading',
|
|
||||||
fullscreen: false,
|
|
||||||
markdown: false,
|
|
||||||
config: {
|
|
||||||
theme: undefined,
|
|
||||||
// codeTheme: 'material-light',
|
|
||||||
font: 'muller',
|
|
||||||
fontSize: 24,
|
|
||||||
contentWidth: 800,
|
|
||||||
typewriterMode: true,
|
|
||||||
prettier: {
|
|
||||||
printWidth: 80,
|
|
||||||
tabWidth: 2,
|
|
||||||
useTabs: false,
|
|
||||||
semi: false,
|
|
||||||
singleQuote: true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
...props
|
|
||||||
})
|
|
|
@ -1,49 +0,0 @@
|
||||||
.articles-list {
|
|
||||||
padding-right: 2em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.articles-list__item {
|
|
||||||
border-bottom: 1px solid #e8e8e8;
|
|
||||||
margin-bottom: 1.5em;
|
|
||||||
padding-bottom: 1.5em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.article__title {
|
|
||||||
@include font-size(2.4rem);
|
|
||||||
|
|
||||||
line-height: 1.25;
|
|
||||||
}
|
|
||||||
|
|
||||||
.article__status {
|
|
||||||
@include font-size(1.4rem);
|
|
||||||
}
|
|
||||||
|
|
||||||
.article__status--draft {
|
|
||||||
color: #2638d9;
|
|
||||||
}
|
|
||||||
|
|
||||||
.article__status--published {
|
|
||||||
color: #2bb452;
|
|
||||||
}
|
|
||||||
|
|
||||||
.article__date {
|
|
||||||
color: #696969;
|
|
||||||
@include font-size(1.2rem);
|
|
||||||
}
|
|
||||||
|
|
||||||
.article__controls {
|
|
||||||
@include font-size(1.4rem);
|
|
||||||
|
|
||||||
align-content: baseline;
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
padding-top: 2em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.article-control {
|
|
||||||
margin-right: 2em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.article-control--remove {
|
|
||||||
color: #d00820;
|
|
||||||
}
|
|
|
@ -1,329 +0,0 @@
|
||||||
.ProseMirror {
|
|
||||||
color: var(--foreground);
|
|
||||||
background-color: var(--background);
|
|
||||||
position: relative;
|
|
||||||
word-wrap: break-word;
|
|
||||||
white-space: pre-wrap;
|
|
||||||
font-variant-ligatures: none;
|
|
||||||
outline: none;
|
|
||||||
margin: 1em 1em 1em 0;
|
|
||||||
|
|
||||||
.dark & {
|
|
||||||
color: var(--background);
|
|
||||||
background-color: var(--foreground);
|
|
||||||
}
|
|
||||||
|
|
||||||
.draggable {
|
|
||||||
position: relative;
|
|
||||||
margin-left: -30px;
|
|
||||||
padding-left: 30px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.handle {
|
|
||||||
position: absolute;
|
|
||||||
left: 0;
|
|
||||||
top: 0;
|
|
||||||
height: calc(var(--font-fize) * 1.6px);
|
|
||||||
opacity: 0;
|
|
||||||
cursor: move;
|
|
||||||
transition: opacity 0.3s;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
|
|
||||||
> span {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
border-radius: 3px;
|
|
||||||
padding: 6px;
|
|
||||||
fill: var(--foreground);
|
|
||||||
pointer-events: none;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:hover > span {
|
|
||||||
background: var(--foreground);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
h1 .handle {
|
|
||||||
height: calc(var(--font-size) * 2.3px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.draggable:hover .handle {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
blockquote {
|
|
||||||
border-left: 2px solid;
|
|
||||||
@include font-size(1.6rem);
|
|
||||||
|
|
||||||
margin: 1.5em 0;
|
|
||||||
padding-left: 1.6em;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.ProseMirror-menuitem {
|
|
||||||
display: flex;
|
|
||||||
font-size: small;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
> * {
|
|
||||||
background: #eee;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ProseMirror-menu-disabled {
|
|
||||||
background: inherit;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
> * {
|
|
||||||
cursor: pointer;
|
|
||||||
align-items: center;
|
|
||||||
display: flex;
|
|
||||||
padding: 0.8rem 1em;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.ProseMirror-textblock-dropdown {
|
|
||||||
min-width: 3em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ProseMirror-menu {
|
|
||||||
margin: 0 -4px;
|
|
||||||
line-height: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ProseMirror-tooltip .ProseMirror-menu {
|
|
||||||
width: fit-content;
|
|
||||||
white-space: pre;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ProseMirror-menuseparator {
|
|
||||||
border-right: 1px solid #ddd;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ProseMirror-menu-dropdown,
|
|
||||||
.ProseMirror-menu-dropdown-menu {
|
|
||||||
padding: 4px;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ProseMirror-menu-dropdown {
|
|
||||||
vertical-align: 1px;
|
|
||||||
cursor: pointer;
|
|
||||||
position: relative;
|
|
||||||
padding-right: 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ProseMirror-menu-dropdown-wrap {
|
|
||||||
padding: 1px 0 1px 4px;
|
|
||||||
display: inline-block;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ProseMirror-menu-dropdown::after {
|
|
||||||
content: '';
|
|
||||||
border-left: 4px solid transparent;
|
|
||||||
border-right: 4px solid transparent;
|
|
||||||
border-top: 4px solid currentcolor;
|
|
||||||
opacity: 0.6;
|
|
||||||
position: absolute;
|
|
||||||
right: 4px;
|
|
||||||
top: calc(50% - 2px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.ProseMirror-menu-dropdown-menu,
|
|
||||||
.ProseMirror-menu-submenu {
|
|
||||||
position: absolute;
|
|
||||||
background: white;
|
|
||||||
color: #666;
|
|
||||||
border: 1px solid #aaa;
|
|
||||||
padding: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ProseMirror-menu-dropdown-menu {
|
|
||||||
z-index: 15;
|
|
||||||
|
|
||||||
/* min-width: 6em; */
|
|
||||||
}
|
|
||||||
|
|
||||||
.ProseMirror-menu-dropdown-item {
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 2px 8px 2px 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ProseMirror-menu-dropdown-item:hover {
|
|
||||||
background: #f2f2f2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ProseMirror-menu-submenu-wrap {
|
|
||||||
position: relative;
|
|
||||||
margin-right: -4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ProseMirror-menu-submenu-label::after {
|
|
||||||
content: '';
|
|
||||||
border-top: 4px solid transparent;
|
|
||||||
border-bottom: 4px solid transparent;
|
|
||||||
border-left: 4px solid currentcolor;
|
|
||||||
opacity: 0.6;
|
|
||||||
position: absolute;
|
|
||||||
right: 4px;
|
|
||||||
top: calc(50% - 4px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.ProseMirror-menu-submenu {
|
|
||||||
display: none;
|
|
||||||
left: 100%;
|
|
||||||
top: -3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ProseMirror-menu-active {
|
|
||||||
background: #eee;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ProseMirror-menu-disabled {
|
|
||||||
cursor: default;
|
|
||||||
opacity: 0.3;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ProseMirror-menu-submenu-wrap:hover .ProseMirror-menu-submenu,
|
|
||||||
.ProseMirror-menu-submenu-wrap-active .ProseMirror-menu-submenu {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ProseMirror-menubar {
|
|
||||||
border-top-left-radius: inherit;
|
|
||||||
border-top-right-radius: inherit;
|
|
||||||
display: flex;
|
|
||||||
position: relative;
|
|
||||||
min-height: 1em;
|
|
||||||
color: #666;
|
|
||||||
padding: 0 1.5em;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
border-bottom: 1px solid silver;
|
|
||||||
background: white;
|
|
||||||
z-index: 10;
|
|
||||||
box-sizing: border-box;
|
|
||||||
overflow: visible;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ProseMirror-icon {
|
|
||||||
cursor: pointer;
|
|
||||||
line-height: 0.8;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ProseMirror-menu-disabled.ProseMirror-icon {
|
|
||||||
cursor: default;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ProseMirror-icon svg {
|
|
||||||
fill: currentcolor;
|
|
||||||
height: 1em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ProseMirror-icon span {
|
|
||||||
vertical-align: text-top;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ProseMirror pre {
|
|
||||||
white-space: pre-wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ProseMirror li {
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ProseMirror-hideselection *::selection {
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ProseMirror-hideselection {
|
|
||||||
caret-color: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ProseMirror-selectednode {
|
|
||||||
outline: 2px solid #8cf;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Make sure li selections wrap around markers */
|
|
||||||
li.ProseMirror-selectednode {
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
li.ProseMirror-selectednode::after {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
left: -32px;
|
|
||||||
right: -2px;
|
|
||||||
top: -2px;
|
|
||||||
bottom: -2px;
|
|
||||||
border: 2px solid #8cf;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ProseMirror .empty-node::before {
|
|
||||||
position: absolute;
|
|
||||||
color: #aaa;
|
|
||||||
cursor: text;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ProseMirror .empty-node:hover::before {
|
|
||||||
color: #777;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ProseMirror.editor_empty::before {
|
|
||||||
position: absolute;
|
|
||||||
content: attr(data-placeholder);
|
|
||||||
pointer-events: none;
|
|
||||||
color: var(--ui-color-placeholder);
|
|
||||||
}
|
|
||||||
|
|
||||||
.ProseMirror-prompt {
|
|
||||||
background: #fff;
|
|
||||||
box-shadow: 0 4px 10px rgb(0 0 0 / 25%);
|
|
||||||
font-size: 0.7em;
|
|
||||||
position: absolute;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ProseMirror-prompt input[type='text'] {
|
|
||||||
border: none;
|
|
||||||
font-size: 100%;
|
|
||||||
margin-bottom: 0;
|
|
||||||
padding: 0.5em 7.5em 0.5em 0.5em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ProseMirror-prompt-buttons {
|
|
||||||
position: absolute;
|
|
||||||
right: 0;
|
|
||||||
top: 50%;
|
|
||||||
transform: translateY(-50%);
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ProseMirror-prompt-buttons button {
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
display: inline-block;
|
|
||||||
font-size: 90%;
|
|
||||||
height: 100%;
|
|
||||||
line-height: 10em;
|
|
||||||
margin-bottom: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
vertical-align: top;
|
|
||||||
width: 2.5em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ProseMirror-prompt-submit {
|
|
||||||
background: url("data:image/svg+xml,%3Csvg width='19' height='15' viewBox='0 0 19 15' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M19 2.73787L16.2621 0L6.78964 9.47248L2.73787 5.42071L0 8.15858L6.78964 14.9482L19 2.73787Z' fill='%23393840'/%3E%3C/svg%3E")
|
|
||||||
center no-repeat;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ProseMirror-prompt-cancel {
|
|
||||||
background: url("data:image/svg+xml,%3Csvg width='16' height='16' viewBox='0 0 16 16' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M13.1512 0.423856L0.423263 13.1518L2.84763 15.5761L15.5756 2.84822L13.1512 0.423856Z M15.5755 13.1518L2.84763 0.423855L0.423263 2.84822L13.1512 15.5761L15.5755 13.1518Z' fill='%23393840'/%3E%3C/svg%3E%0A")
|
|
||||||
center no-repeat;
|
|
||||||
}
|
|
|
@ -12,8 +12,8 @@ import { createArticle } from '../../stores/zine/articles'
|
||||||
import type { ShoutInput } from '../../graphql/types.gen'
|
import type { ShoutInput } from '../../graphql/types.gen'
|
||||||
import { Sidebar } from './Sidebar'
|
import { Sidebar } from './Sidebar'
|
||||||
import styles from './Sidebar.module.scss'
|
import styles from './Sidebar.module.scss'
|
||||||
import Button from '../_shared/Button'
|
import { Button } from '../_shared/Button'
|
||||||
import { t } from '../../utils/intl'
|
import { useLocalize } from '../../context/localize'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
initialContent?: string
|
initialContent?: string
|
||||||
|
@ -28,6 +28,7 @@ const getHtml = (state: EditorState) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Editor = (props: Props) => {
|
export const Editor = (props: Props) => {
|
||||||
|
const { t } = useLocalize()
|
||||||
const editorElRef: {
|
const editorElRef: {
|
||||||
current: HTMLDivElement
|
current: HTMLDivElement
|
||||||
} = {
|
} = {
|
||||||
|
|
|
@ -10,7 +10,6 @@ import type { EditorView } from 'prosemirror-view'
|
||||||
const Off = (props) => <div class={styles.sidebarOff}>{props.children}</div>
|
const Off = (props) => <div class={styles.sidebarOff}>{props.children}</div>
|
||||||
|
|
||||||
const Link = (props: {
|
const Link = (props: {
|
||||||
withMargin?: boolean
|
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
title?: string
|
title?: string
|
||||||
className?: string
|
className?: string
|
||||||
|
@ -18,9 +17,7 @@ const Link = (props: {
|
||||||
onClick?: () => void
|
onClick?: () => void
|
||||||
}) => (
|
}) => (
|
||||||
<button
|
<button
|
||||||
class={clsx(styles.sidebarLink, props.className, {
|
class={clsx(styles.sidebarLink, props.className)}
|
||||||
[styles.withMargin]: props.withMargin
|
|
||||||
})}
|
|
||||||
onClick={props.onClick}
|
onClick={props.onClick}
|
||||||
disabled={props.disabled}
|
disabled={props.disabled}
|
||||||
title={props.title}
|
title={props.title}
|
||||||
|
|
|
@ -1,15 +1,16 @@
|
||||||
import { history } from 'prosemirror-history'
|
import { history } from 'prosemirror-history'
|
||||||
import { dropCursor } from 'prosemirror-dropcursor'
|
import { dropCursor } from 'prosemirror-dropcursor'
|
||||||
import { placeholder } from './placeholder'
|
import { placeholder } from './placeholder'
|
||||||
import { t } from '../../../../utils/intl'
|
|
||||||
import styles from '../styles/ProseMirror.module.scss'
|
import styles from '../styles/ProseMirror.module.scss'
|
||||||
import type { DiscoursSchema } from '../schema'
|
import type { DiscoursSchema } from '../schema'
|
||||||
import { dragHandle } from './dragHandle'
|
import { dragHandle } from './dragHandle'
|
||||||
import { selectionMenu } from './selectionMenu'
|
import { selectionMenu } from './selectionMenu'
|
||||||
import { imageInput } from './image'
|
import { imageInput } from './image'
|
||||||
import { customKeymap } from './customKeymap'
|
import { customKeymap } from './customKeymap'
|
||||||
|
import { useLocalize } from '../../../../context/localize'
|
||||||
|
|
||||||
export const createPlugins = ({ schema }: { schema: DiscoursSchema }) => {
|
export const createPlugins = ({ schema }: { schema: DiscoursSchema }) => {
|
||||||
|
const { t } = useLocalize()
|
||||||
return [
|
return [
|
||||||
placeholder(t('Just start typing...')),
|
placeholder(t('Just start typing...')),
|
||||||
customKeymap(),
|
customKeymap(),
|
||||||
|
|
|
@ -7,35 +7,36 @@
|
||||||
font-variant-ligatures: none;
|
font-variant-ligatures: none;
|
||||||
outline: none;
|
outline: none;
|
||||||
|
|
||||||
//font styles
|
// font styles
|
||||||
|
|
||||||
h1 {
|
h1 {
|
||||||
margin: 0 0 16px 0;
|
margin: 0 0 16px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
font-size: 44px;
|
font-size: 44px;
|
||||||
line-height: 50px;
|
line-height: 50px;
|
||||||
}
|
}
|
||||||
|
|
||||||
h2 {
|
h2 {
|
||||||
margin: 0 0 16px 0;
|
margin: 0 0 16px;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
font-size: 44px;
|
font-size: 44px;
|
||||||
line-height: 50px;
|
line-height: 50px;
|
||||||
}
|
}
|
||||||
|
|
||||||
h3 {
|
h3 {
|
||||||
margin: 0 0 16px 0;
|
margin: 0 0 16px;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
font-size: 34px;
|
font-size: 34px;
|
||||||
line-height: 40px;
|
line-height: 40px;
|
||||||
}
|
}
|
||||||
|
|
||||||
p {
|
p {
|
||||||
margin: 0 0 16px 0;
|
margin: 0 0 16px;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
line-height: 28px;
|
line-height: 28px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark & {
|
.dark & {
|
||||||
color: var(--background);
|
color: var(--background);
|
||||||
background-color: var(--foreground);
|
background-color: var(--foreground);
|
||||||
|
|
|
@ -7,8 +7,9 @@ import { TopicCard } from '../Topic/Card'
|
||||||
import styles from './Beside.module.scss'
|
import styles from './Beside.module.scss'
|
||||||
import type { Author, Shout, Topic, User } from '../../graphql/types.gen'
|
import type { Author, Shout, Topic, User } from '../../graphql/types.gen'
|
||||||
import { Icon } from '../_shared/Icon'
|
import { Icon } from '../_shared/Icon'
|
||||||
import { t } from '../../utils/intl'
|
|
||||||
import { clsx } from 'clsx'
|
import { clsx } from 'clsx'
|
||||||
|
import { useLocalize } from '../../context/localize'
|
||||||
|
|
||||||
interface BesideProps {
|
interface BesideProps {
|
||||||
title?: string
|
title?: string
|
||||||
|
@ -24,6 +25,7 @@ interface BesideProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Beside = (props: BesideProps) => {
|
export const Beside = (props: BesideProps) => {
|
||||||
|
const { t } = useLocalize()
|
||||||
return (
|
return (
|
||||||
<Show when={!!props.beside?.slug && props.values?.length > 0}>
|
<Show when={!!props.beside?.slug && props.values?.length > 0}>
|
||||||
<div class="floor floor--9">
|
<div class="floor floor--9">
|
||||||
|
|
|
@ -1,11 +1,9 @@
|
||||||
import { t } from '../../utils/intl'
|
|
||||||
import { createMemo, For, Show } from 'solid-js'
|
import { createMemo, For, Show } from 'solid-js'
|
||||||
import type { Shout } from '../../graphql/types.gen'
|
import type { Shout } from '../../graphql/types.gen'
|
||||||
import { capitalize } from '../../utils'
|
import { capitalize } from '../../utils'
|
||||||
import { translit } from '../../utils/ru2en'
|
import { translit } from '../../utils/ru2en'
|
||||||
import { Icon } from '../_shared/Icon'
|
import { Icon } from '../_shared/Icon'
|
||||||
import styles from './Card.module.scss'
|
import styles from './Card.module.scss'
|
||||||
import { locale } from '../../stores/ui'
|
|
||||||
import { clsx } from 'clsx'
|
import { clsx } from 'clsx'
|
||||||
import { CardTopic } from './CardTopic'
|
import { CardTopic } from './CardTopic'
|
||||||
import { RatingControl } from '../Article/RatingControl'
|
import { RatingControl } from '../Article/RatingControl'
|
||||||
|
@ -13,6 +11,7 @@ import { getShareUrl, SharePopup } from '../Article/SharePopup'
|
||||||
import stylesHeader from '../Nav/Header.module.scss'
|
import stylesHeader from '../Nav/Header.module.scss'
|
||||||
import { getDescription } from '../../utils/meta'
|
import { getDescription } from '../../utils/meta'
|
||||||
import { FeedArticlePopup } from './FeedArticlePopup'
|
import { FeedArticlePopup } from './FeedArticlePopup'
|
||||||
|
import { useLocalize } from '../../context/localize'
|
||||||
|
|
||||||
interface ArticleCardProps {
|
interface ArticleCardProps {
|
||||||
settings?: {
|
settings?: {
|
||||||
|
@ -60,13 +59,15 @@ const getTitleAndSubtitle = (article: Shout): { title: string; subtitle: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ArticleCard = (props: ArticleCardProps) => {
|
export const ArticleCard = (props: ArticleCardProps) => {
|
||||||
|
const { t, lang } = useLocalize()
|
||||||
|
|
||||||
const mainTopic =
|
const mainTopic =
|
||||||
props.article.topics.find((articleTopic) => articleTopic.slug === props.article.mainTopic) ||
|
props.article.topics.find((articleTopic) => articleTopic.slug === props.article.mainTopic) ||
|
||||||
props.article.topics[0]
|
props.article.topics[0]
|
||||||
|
|
||||||
const formattedDate = createMemo<string>(() => {
|
const formattedDate = createMemo<string>(() => {
|
||||||
return new Date(props.article.createdAt)
|
return new Date(props.article.createdAt)
|
||||||
.toLocaleDateString(locale(), { month: 'long', day: 'numeric', year: 'numeric' })
|
.toLocaleDateString(lang(), { month: 'long', day: 'numeric', year: 'numeric' })
|
||||||
.replace(' г.', '')
|
.replace(' г.', '')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -111,7 +112,7 @@ export const ArticleCard = (props: ArticleCardProps) => {
|
||||||
<Show when={!props.settings?.isGroup}>
|
<Show when={!props.settings?.isGroup}>
|
||||||
<CardTopic
|
<CardTopic
|
||||||
title={
|
title={
|
||||||
locale() === 'ru' && mainTopic.title ? mainTopic.title : mainTopic?.slug?.replace('-', ' ')
|
lang() === 'ru' && mainTopic.title ? mainTopic.title : mainTopic?.slug?.replace('-', ' ')
|
||||||
}
|
}
|
||||||
slug={mainTopic.slug}
|
slug={mainTopic.slug}
|
||||||
isFloorImportant={props.settings?.isFloorImportant}
|
isFloorImportant={props.settings?.isFloorImportant}
|
||||||
|
@ -140,7 +141,7 @@ export const ArticleCard = (props: ArticleCardProps) => {
|
||||||
{(author, index) => {
|
{(author, index) => {
|
||||||
let name = author.name
|
let name = author.name
|
||||||
|
|
||||||
if (locale() !== 'ru') {
|
if (lang() !== 'ru') {
|
||||||
name = name === 'Дискурс' ? 'Discours' : translit(name)
|
name = name === 'Дискурс' ? 'Discours' : translit(name)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import styles from './FeedArticlePopup.module.scss'
|
import styles from './FeedArticlePopup.module.scss'
|
||||||
import type { PopupProps } from '../_shared/Popup'
|
import type { PopupProps } from '../_shared/Popup'
|
||||||
import { Popup } from '../_shared/Popup'
|
import { Popup } from '../_shared/Popup'
|
||||||
import { t } from '../../utils/intl'
|
import { useLocalize } from '../../context/localize'
|
||||||
|
|
||||||
type FeedArticlePopupProps = {
|
type FeedArticlePopupProps = {
|
||||||
title: string
|
title: string
|
||||||
|
@ -17,6 +17,7 @@ export const getShareUrl = (params: { pathname?: string } = {}) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const FeedArticlePopup = (props: FeedArticlePopupProps) => {
|
export const FeedArticlePopup = (props: FeedArticlePopupProps) => {
|
||||||
|
const { t } = useLocalize()
|
||||||
return (
|
return (
|
||||||
<Popup {...props} variant="tiny" popupCssClass={styles.feedArticlePopup}>
|
<Popup {...props} variant="tiny" popupCssClass={styles.feedArticlePopup}>
|
||||||
<ul class="nodash">
|
<ul class="nodash">
|
||||||
|
|
|
@ -6,7 +6,7 @@ import { createMemo, createSignal, For, Suspense } from 'solid-js'
|
||||||
import type { JSX } from 'solid-js'
|
import type { JSX } from 'solid-js'
|
||||||
import type { Shout } from '../../graphql/types.gen'
|
import type { Shout } from '../../graphql/types.gen'
|
||||||
import './List.scss'
|
import './List.scss'
|
||||||
import { t } from '../../utils/intl'
|
import { useLocalize } from '../../context/localize'
|
||||||
|
|
||||||
export const Block6 = (props: { articles: Shout[] }) => {
|
export const Block6 = (props: { articles: Shout[] }) => {
|
||||||
const dice = createMemo(() => shuffle([Row1, Row2, Row3]))
|
const dice = createMemo(() => shuffle([Row1, Row2, Row3]))
|
||||||
|
@ -25,6 +25,7 @@ interface ArticleListProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
export default (props: ArticleListProps) => {
|
export default (props: ArticleListProps) => {
|
||||||
|
const { t } = useLocalize()
|
||||||
const [articles, setArticles] = createSignal(
|
const [articles, setArticles] = createSignal(
|
||||||
props.articles.slice(props.page * props.size, props.size * (props.page + 1)) || []
|
props.articles.slice(props.page * props.size, props.size * (props.page + 1)) || []
|
||||||
)
|
)
|
||||||
|
|
|
@ -1,19 +1,21 @@
|
||||||
import { For } from 'solid-js'
|
import { For } from 'solid-js'
|
||||||
import type { Author } from '../../graphql/types.gen'
|
import type { Author } from '../../graphql/types.gen'
|
||||||
import { useAuthorsStore } from '../../stores/zine/authors'
|
import { useAuthorsStore } from '../../stores/zine/authors'
|
||||||
import { t } from '../../utils/intl'
|
|
||||||
import { Icon } from '../_shared/Icon'
|
import { Icon } from '../_shared/Icon'
|
||||||
import { useTopicsStore } from '../../stores/zine/topics'
|
import { useTopicsStore } from '../../stores/zine/topics'
|
||||||
import { useArticlesStore } from '../../stores/zine/articles'
|
import { useArticlesStore } from '../../stores/zine/articles'
|
||||||
import { useSeenStore } from '../../stores/zine/seen'
|
import { useSeenStore } from '../../stores/zine/seen'
|
||||||
import { useSession } from '../../context/session'
|
import { useSession } from '../../context/session'
|
||||||
import styles from './Sidebar.module.scss'
|
import styles from './Sidebar.module.scss'
|
||||||
|
import { useLocalize } from '../../context/localize'
|
||||||
|
|
||||||
type FeedSidebarProps = {
|
type FeedSidebarProps = {
|
||||||
authors: Author[]
|
authors: Author[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export const FeedSidebar = (props: FeedSidebarProps) => {
|
export const FeedSidebar = (props: FeedSidebarProps) => {
|
||||||
|
const { t } = useLocalize()
|
||||||
const { seen } = useSeenStore()
|
const { seen } = useSeenStore()
|
||||||
const { session } = useSession()
|
const { session } = useSession()
|
||||||
const { authorEntities } = useAuthorsStore({ authors: props.authors })
|
const { authorEntities } = useAuthorsStore({ authors: props.authors })
|
||||||
|
|
|
@ -1,10 +1,11 @@
|
||||||
import { createSignal, For, createEffect } from 'solid-js'
|
import { createSignal, For, createEffect } from 'solid-js'
|
||||||
import styles from './CreateModalContent.module.scss'
|
import styles from './CreateModalContent.module.scss'
|
||||||
import { t } from '../../utils/intl'
|
|
||||||
import InviteUser from './InviteUser'
|
import InviteUser from './InviteUser'
|
||||||
import type { Author } from '../../graphql/types.gen'
|
import type { Author } from '../../graphql/types.gen'
|
||||||
import { hideModal } from '../../stores/ui'
|
import { hideModal } from '../../stores/ui'
|
||||||
import { useInbox } from '../../context/inbox'
|
import { useInbox } from '../../context/inbox'
|
||||||
|
import { useLocalize } from '../../context/localize'
|
||||||
|
|
||||||
type inviteUser = Author & { selected: boolean }
|
type inviteUser = Author & { selected: boolean }
|
||||||
type Props = {
|
type Props = {
|
||||||
|
@ -12,6 +13,7 @@ type Props = {
|
||||||
}
|
}
|
||||||
|
|
||||||
const CreateModalContent = (props: Props) => {
|
const CreateModalContent = (props: Props) => {
|
||||||
|
const { t } = useLocalize()
|
||||||
const inviteUsers: inviteUser[] = props.users.map((user) => ({ ...user, selected: false }))
|
const inviteUsers: inviteUser[] = props.users.map((user) => ({ ...user, selected: false }))
|
||||||
const [theme, setTheme] = createSignal<string>(' ')
|
const [theme, setTheme] = createSignal<string>(' ')
|
||||||
const [usersId, setUsersId] = createSignal<number[]>([])
|
const [usersId, setUsersId] = createSignal<number[]>([])
|
||||||
|
@ -66,7 +68,7 @@ const CreateModalContent = (props: Props) => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class={styles.CreateModalContent}>
|
<div class={styles.CreateModalContent}>
|
||||||
<h4>{t('create_chat')}</h4>
|
<h4>{t('Create Chat')}</h4>
|
||||||
{usersId().length > 1 && (
|
{usersId().length > 1 && (
|
||||||
<input
|
<input
|
||||||
ref={textInput}
|
ref={textInput}
|
||||||
|
@ -74,7 +76,7 @@ const CreateModalContent = (props: Props) => {
|
||||||
type="text"
|
type="text"
|
||||||
required={true}
|
required={true}
|
||||||
class="form-control form-control-lg fs-3"
|
class="form-control form-control-lg fs-3"
|
||||||
placeholder={t('discourse_theme')}
|
placeholder={t('Chat Title')}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
@ -96,7 +98,7 @@ const CreateModalContent = (props: Props) => {
|
||||||
onClick={handleCreate}
|
onClick={handleCreate}
|
||||||
disabled={usersId().length === 0}
|
disabled={usersId().length === 0}
|
||||||
>
|
>
|
||||||
{usersId().length > 1 ? t('create_group') : t('create_chat')}
|
{usersId().length > 1 ? t('Create Group') : t('Create Chat')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -9,7 +9,7 @@ type Props = {
|
||||||
online?: boolean
|
online?: boolean
|
||||||
size?: 'small'
|
size?: 'small'
|
||||||
bordered?: boolean
|
bordered?: boolean
|
||||||
className?: string
|
class?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const colors = [
|
const colors = [
|
||||||
|
@ -38,7 +38,7 @@ const DialogAvatar = (props: Props) => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
class={clsx(styles.DialogAvatar, props.className, {
|
class={clsx(styles.DialogAvatar, props.class, {
|
||||||
[styles.online]: props.online,
|
[styles.online]: props.online,
|
||||||
[styles.bordered]: props.bordered,
|
[styles.bordered]: props.bordered,
|
||||||
[styles.small]: props.size === 'small'
|
[styles.small]: props.size === 'small'
|
||||||
|
|
|
@ -5,7 +5,7 @@ import GroupDialogAvatar from './GroupDialogAvatar'
|
||||||
import formattedTime from '../../utils/formatDateTime'
|
import formattedTime from '../../utils/formatDateTime'
|
||||||
import { clsx } from 'clsx'
|
import { clsx } from 'clsx'
|
||||||
import styles from './DialogCard.module.scss'
|
import styles from './DialogCard.module.scss'
|
||||||
import { t } from '../../utils/intl'
|
import { useLocalize } from '../../context/localize'
|
||||||
|
|
||||||
type DialogProps = {
|
type DialogProps = {
|
||||||
online?: boolean
|
online?: boolean
|
||||||
|
@ -20,6 +20,7 @@ type DialogProps = {
|
||||||
}
|
}
|
||||||
|
|
||||||
const DialogCard = (props: DialogProps) => {
|
const DialogCard = (props: DialogProps) => {
|
||||||
|
const { t } = useLocalize()
|
||||||
const companions = createMemo(
|
const companions = createMemo(
|
||||||
() => props.members && props.members.filter((member) => member.id !== props.ownId)
|
() => props.members && props.members.filter((member) => member.id !== props.ownId)
|
||||||
)
|
)
|
||||||
|
@ -34,7 +35,6 @@ const DialogCard = (props: DialogProps) => {
|
||||||
<Show when={props.members}>
|
<Show when={props.members}>
|
||||||
<div
|
<div
|
||||||
class={clsx(styles.DialogCard, {
|
class={clsx(styles.DialogCard, {
|
||||||
[styles.header]: props.isChatHeader,
|
|
||||||
[styles.opened]: props.isOpened,
|
[styles.opened]: props.isOpened,
|
||||||
[styles.hovered]: !props.isChatHeader
|
[styles.hovered]: !props.isChatHeader
|
||||||
})}
|
})}
|
||||||
|
|
|
@ -21,7 +21,7 @@ const GroupDialogAvatar = (props: Props) => {
|
||||||
<For each={slicedUsers()}>
|
<For each={slicedUsers()}>
|
||||||
{(user) => (
|
{(user) => (
|
||||||
<DialogAvatar
|
<DialogAvatar
|
||||||
className={styles.grouped}
|
class={styles.grouped}
|
||||||
bordered={true}
|
bordered={true}
|
||||||
size="small"
|
size="small"
|
||||||
name={user.name}
|
name={user.name}
|
||||||
|
|
|
@ -1,5 +1,3 @@
|
||||||
$actionsWidth: 32px * 2;
|
|
||||||
|
|
||||||
.Message {
|
.Message {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
@ -41,13 +39,13 @@ $actionsWidth: 32px * 2;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
width: $actionsWidth;
|
width: 64px;
|
||||||
height: 32px;
|
height: 32px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
top: 50%;
|
top: 50%;
|
||||||
transform: translateY(-50%);
|
transform: translateY(-50%);
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
right: -$actionsWidth/2;
|
right: -32px;
|
||||||
z-index: -1;
|
z-index: -1;
|
||||||
transition: 0.3s ease-in-out;
|
transition: 0.3s ease-in-out;
|
||||||
}
|
}
|
||||||
|
@ -62,7 +60,7 @@ $actionsWidth: 32px * 2;
|
||||||
.actions {
|
.actions {
|
||||||
z-index: 10000;
|
z-index: 10000;
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
right: -$actionsWidth;
|
right: -64px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -101,7 +99,7 @@ $actionsWidth: 32px * 2;
|
||||||
|
|
||||||
.actions {
|
.actions {
|
||||||
right: unset;
|
right: unset;
|
||||||
left: -$actionsWidth/2;
|
left: -32px;
|
||||||
flex-direction: row-reverse;
|
flex-direction: row-reverse;
|
||||||
|
|
||||||
.reply {
|
.reply {
|
||||||
|
@ -112,7 +110,7 @@ $actionsWidth: 32px * 2;
|
||||||
&.popupVisible,
|
&.popupVisible,
|
||||||
&:hover {
|
&:hover {
|
||||||
.actions {
|
.actions {
|
||||||
left: -$actionsWidth;
|
left: -64px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -23,7 +23,7 @@ const md = new MarkdownIt({
|
||||||
breaks: true
|
breaks: true
|
||||||
})
|
})
|
||||||
|
|
||||||
const Message = (props: Props) => {
|
export const Message = (props: Props) => {
|
||||||
const isOwn = props.ownId === Number(props.content.author)
|
const isOwn = props.ownId === Number(props.content.author)
|
||||||
const user = props.members?.find((m) => m.id === Number(props.content.author))
|
const user = props.members?.find((m) => m.id === Number(props.content.author))
|
||||||
const [isPopupVisible, setIsPopupVisible] = createSignal<boolean>(false)
|
const [isPopupVisible, setIsPopupVisible] = createSignal<boolean>(false)
|
||||||
|
@ -57,5 +57,3 @@ const Message = (props: Props) => {
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Message
|
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import { createEffect, createSignal, For } from 'solid-js'
|
import { createEffect, createSignal, For } from 'solid-js'
|
||||||
import type { PopupProps } from '../_shared/Popup'
|
import type { PopupProps } from '../_shared/Popup'
|
||||||
import { Popup } from '../_shared/Popup'
|
import { Popup } from '../_shared/Popup'
|
||||||
import { t } from '../../utils/intl'
|
import { useLocalize } from '../../context/localize'
|
||||||
|
|
||||||
export type MessageActionType = 'reply' | 'copy' | 'pin' | 'forward' | 'select' | 'delete'
|
export type MessageActionType = 'reply' | 'copy' | 'pin' | 'forward' | 'select' | 'delete'
|
||||||
|
|
||||||
|
@ -9,18 +9,17 @@ type MessageActionsPopup = {
|
||||||
actionSelect?: (selectedAction) => void
|
actionSelect?: (selectedAction) => void
|
||||||
} & Omit<PopupProps, 'children'>
|
} & Omit<PopupProps, 'children'>
|
||||||
|
|
||||||
const actions: { name: string; action: MessageActionType }[] = [
|
|
||||||
{ name: t('Reply'), action: 'reply' },
|
|
||||||
{ name: t('Copy'), action: 'copy' },
|
|
||||||
{ name: t('Pin'), action: 'pin' },
|
|
||||||
{ name: t('Forward'), action: 'forward' },
|
|
||||||
{ name: t('Select'), action: 'select' },
|
|
||||||
{ name: t('Delete'), action: 'delete' }
|
|
||||||
]
|
|
||||||
|
|
||||||
export const MessageActionsPopup = (props: MessageActionsPopup) => {
|
export const MessageActionsPopup = (props: MessageActionsPopup) => {
|
||||||
const [selectedAction, setSelectedAction] = createSignal<MessageActionType | null>(null)
|
const [selectedAction, setSelectedAction] = createSignal<MessageActionType | null>(null)
|
||||||
|
const { t } = useLocalize()
|
||||||
|
const actions: { name: string; action: MessageActionType }[] = [
|
||||||
|
{ name: t('Reply'), action: 'reply' },
|
||||||
|
{ name: t('Copy'), action: 'copy' },
|
||||||
|
{ name: t('Pin'), action: 'pin' },
|
||||||
|
{ name: t('Forward'), action: 'forward' },
|
||||||
|
{ name: t('Select'), action: 'select' },
|
||||||
|
{ name: t('Delete'), action: 'delete' }
|
||||||
|
]
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (props.actionSelect) props.actionSelect(selectedAction())
|
if (props.actionSelect) props.actionSelect(selectedAction())
|
||||||
})
|
})
|
||||||
|
|
|
@ -1,14 +1,15 @@
|
||||||
import styles from './AuthModal.module.scss'
|
import styles from './AuthModal.module.scss'
|
||||||
import { clsx } from 'clsx'
|
import { clsx } from 'clsx'
|
||||||
import { t } from '../../../utils/intl'
|
|
||||||
import { hideModal } from '../../../stores/ui'
|
import { hideModal } from '../../../stores/ui'
|
||||||
import { createMemo, createSignal, onMount, Show } from 'solid-js'
|
import { createMemo, createSignal, onMount, Show } from 'solid-js'
|
||||||
import { useRouter } from '../../../stores/router'
|
import { useRouter } from '../../../stores/router'
|
||||||
import type { ConfirmEmailSearchParams } from './types'
|
import type { ConfirmEmailSearchParams } from './types'
|
||||||
import { ApiError } from '../../../utils/apiClient'
|
import { ApiError } from '../../../utils/apiClient'
|
||||||
import { useSession } from '../../../context/session'
|
import { useSession } from '../../../context/session'
|
||||||
|
import { useLocalize } from '../../../context/localize'
|
||||||
|
|
||||||
export const EmailConfirm = () => {
|
export const EmailConfirm = () => {
|
||||||
|
const { t } = useLocalize()
|
||||||
const {
|
const {
|
||||||
session,
|
session,
|
||||||
actions: { confirmEmail }
|
actions: { confirmEmail }
|
||||||
|
@ -48,7 +49,7 @@ export const EmailConfirm = () => {
|
||||||
<Show when={isTokenExpired()}>
|
<Show when={isTokenExpired()}>
|
||||||
<div class={styles.title}>Ссылка больше не действительна</div>
|
<div class={styles.title}>Ссылка больше не действительна</div>
|
||||||
<div class={styles.text}>
|
<div class={styles.text}>
|
||||||
<a href="/?modal=auth&mode=login" class={styles.sendLink}>
|
<a href="/?modal=auth&mode=login">
|
||||||
{/*TODO: temp solution, should be send link again, but we don't have email here*/}
|
{/*TODO: temp solution, should be send link again, but we don't have email here*/}
|
||||||
Вход
|
Вход
|
||||||
</a>
|
</a>
|
||||||
|
@ -57,7 +58,7 @@ export const EmailConfirm = () => {
|
||||||
<Show when={isTokenInvalid()}>
|
<Show when={isTokenInvalid()}>
|
||||||
<div class={styles.title}>Неправильная ссылка</div>
|
<div class={styles.title}>Неправильная ссылка</div>
|
||||||
<div class={styles.text}>
|
<div class={styles.text}>
|
||||||
<a href="/?modal=auth&mode=login" class={styles.sendLink}>
|
<a href="/?modal=auth&mode=login">
|
||||||
{/*TODO: temp solution, should be send link again, but we don't have email here*/}
|
{/*TODO: temp solution, should be send link again, but we don't have email here*/}
|
||||||
Вход
|
Вход
|
||||||
</a>
|
</a>
|
||||||
|
|
|
@ -1,4 +1,3 @@
|
||||||
import { t } from '../../../utils/intl'
|
|
||||||
import styles from './AuthModal.module.scss'
|
import styles from './AuthModal.module.scss'
|
||||||
import { clsx } from 'clsx'
|
import { clsx } from 'clsx'
|
||||||
import { createSignal, JSX, Show } from 'solid-js'
|
import { createSignal, JSX, Show } from 'solid-js'
|
||||||
|
@ -6,9 +5,9 @@ import { useRouter } from '../../../stores/router'
|
||||||
import { email, setEmail } from './sharedLogic'
|
import { email, setEmail } from './sharedLogic'
|
||||||
import type { AuthModalSearchParams } from './types'
|
import type { AuthModalSearchParams } from './types'
|
||||||
import { isValidEmail } from './validators'
|
import { isValidEmail } from './validators'
|
||||||
import { locale } from '../../../stores/ui'
|
|
||||||
import { ApiError } from '../../../utils/apiClient'
|
import { ApiError } from '../../../utils/apiClient'
|
||||||
import { signSendLink } from '../../../stores/auth'
|
import { signSendLink } from '../../../stores/auth'
|
||||||
|
import { useLocalize } from '../../../context/localize'
|
||||||
|
|
||||||
type FormFields = {
|
type FormFields = {
|
||||||
email: string
|
email: string
|
||||||
|
@ -18,7 +17,7 @@ type ValidationErrors = Partial<Record<keyof FormFields, string | JSX.Element>>
|
||||||
|
|
||||||
export const ForgotPasswordForm = () => {
|
export const ForgotPasswordForm = () => {
|
||||||
const { changeSearchParam } = useRouter<AuthModalSearchParams>()
|
const { changeSearchParam } = useRouter<AuthModalSearchParams>()
|
||||||
|
const { t, lang } = useLocalize()
|
||||||
const handleEmailInput = (newEmail: string) => {
|
const handleEmailInput = (newEmail: string) => {
|
||||||
setValidationErrors(({ email: _notNeeded, ...rest }) => rest)
|
setValidationErrors(({ email: _notNeeded, ...rest }) => rest)
|
||||||
setEmail(newEmail)
|
setEmail(newEmail)
|
||||||
|
@ -54,7 +53,7 @@ export const ForgotPasswordForm = () => {
|
||||||
setIsSubmitting(true)
|
setIsSubmitting(true)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await signSendLink({ email: email(), lang: locale(), template: 'forgot_password' })
|
await signSendLink({ email: email(), lang: lang(), template: 'forgot_password' })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof ApiError && error.code === 'user_not_found') {
|
if (error instanceof ApiError && error.code === 'user_not_found') {
|
||||||
setIsUserNotFound(true)
|
setIsUserNotFound(true)
|
||||||
|
|
|
@ -1,4 +1,3 @@
|
||||||
import { t } from '../../../utils/intl'
|
|
||||||
import styles from './AuthModal.module.scss'
|
import styles from './AuthModal.module.scss'
|
||||||
import { clsx } from 'clsx'
|
import { clsx } from 'clsx'
|
||||||
import { SocialProviders } from './SocialProviders'
|
import { SocialProviders } from './SocialProviders'
|
||||||
|
@ -8,10 +7,12 @@ import { isValidEmail } from './validators'
|
||||||
import { email, setEmail } from './sharedLogic'
|
import { email, setEmail } from './sharedLogic'
|
||||||
import { useRouter } from '../../../stores/router'
|
import { useRouter } from '../../../stores/router'
|
||||||
import type { AuthModalSearchParams } from './types'
|
import type { AuthModalSearchParams } from './types'
|
||||||
import { hideModal, locale } from '../../../stores/ui'
|
import { hideModal } from '../../../stores/ui'
|
||||||
import { useSession } from '../../../context/session'
|
import { useSession } from '../../../context/session'
|
||||||
import { signSendLink } from '../../../stores/auth'
|
import { signSendLink } from '../../../stores/auth'
|
||||||
|
|
||||||
import { useSnackbar } from '../../../context/snackbar'
|
import { useSnackbar } from '../../../context/snackbar'
|
||||||
|
import { useLocalize } from '../../../context/localize'
|
||||||
|
|
||||||
type FormFields = {
|
type FormFields = {
|
||||||
email: string
|
email: string
|
||||||
|
@ -21,6 +22,8 @@ type FormFields = {
|
||||||
type ValidationErrors = Partial<Record<keyof FormFields, string>>
|
type ValidationErrors = Partial<Record<keyof FormFields, string>>
|
||||||
|
|
||||||
export const LoginForm = () => {
|
export const LoginForm = () => {
|
||||||
|
const { t, lang } = useLocalize()
|
||||||
|
|
||||||
const [submitError, setSubmitError] = createSignal('')
|
const [submitError, setSubmitError] = createSignal('')
|
||||||
const [isSubmitting, setIsSubmitting] = createSignal(false)
|
const [isSubmitting, setIsSubmitting] = createSignal(false)
|
||||||
const [validationErrors, setValidationErrors] = createSignal<ValidationErrors>({})
|
const [validationErrors, setValidationErrors] = createSignal<ValidationErrors>({})
|
||||||
|
@ -55,7 +58,7 @@ export const LoginForm = () => {
|
||||||
setIsEmailNotConfirmed(false)
|
setIsEmailNotConfirmed(false)
|
||||||
setSubmitError('')
|
setSubmitError('')
|
||||||
setIsLinkSent(true)
|
setIsLinkSent(true)
|
||||||
const result = await signSendLink({ email: email(), lang: locale(), template: 'email_confirmation' })
|
const result = await signSendLink({ email: email(), lang: lang(), template: 'email_confirmation' })
|
||||||
if (result.error) setSubmitError(result.error)
|
if (result.error) setSubmitError(result.error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -116,7 +119,7 @@ export const LoginForm = () => {
|
||||||
<div class={styles.authInfo}>
|
<div class={styles.authInfo}>
|
||||||
<div class={styles.warn}>{submitError()}</div>
|
<div class={styles.warn}>{submitError()}</div>
|
||||||
<Show when={isEmailNotConfirmed()}>
|
<Show when={isEmailNotConfirmed()}>
|
||||||
<a href="#" class={styles.sendLink} onClick={handleSendLinkAgainClick}>
|
<a href="#" onClick={handleSendLinkAgainClick}>
|
||||||
{t('Send link again')}
|
{t('Send link again')}
|
||||||
</a>
|
</a>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
|
@ -1,6 +1,5 @@
|
||||||
import { Show, createSignal } from 'solid-js'
|
import { Show, createSignal } from 'solid-js'
|
||||||
import type { JSX } from 'solid-js'
|
import type { JSX } from 'solid-js'
|
||||||
import { t } from '../../../utils/intl'
|
|
||||||
import styles from './AuthModal.module.scss'
|
import styles from './AuthModal.module.scss'
|
||||||
import { clsx } from 'clsx'
|
import { clsx } from 'clsx'
|
||||||
import { SocialProviders } from './SocialProviders'
|
import { SocialProviders } from './SocialProviders'
|
||||||
|
@ -12,6 +11,7 @@ import type { AuthModalSearchParams } from './types'
|
||||||
import { hideModal } from '../../../stores/ui'
|
import { hideModal } from '../../../stores/ui'
|
||||||
import { checkEmail, useEmailChecks } from '../../../stores/emailChecks'
|
import { checkEmail, useEmailChecks } from '../../../stores/emailChecks'
|
||||||
import { register } from '../../../stores/auth'
|
import { register } from '../../../stores/auth'
|
||||||
|
import { useLocalize } from '../../../context/localize'
|
||||||
|
|
||||||
type FormFields = {
|
type FormFields = {
|
||||||
name: string
|
name: string
|
||||||
|
@ -23,7 +23,7 @@ type ValidationErrors = Partial<Record<keyof FormFields, string | JSX.Element>>
|
||||||
|
|
||||||
export const RegisterForm = () => {
|
export const RegisterForm = () => {
|
||||||
const { changeSearchParam } = useRouter<AuthModalSearchParams>()
|
const { changeSearchParam } = useRouter<AuthModalSearchParams>()
|
||||||
|
const { t } = useLocalize()
|
||||||
const { emailChecks } = useEmailChecks()
|
const { emailChecks } = useEmailChecks()
|
||||||
|
|
||||||
const [submitError, setSubmitError] = createSignal('')
|
const [submitError, setSubmitError] = createSignal('')
|
||||||
|
|
|
@ -1,9 +1,9 @@
|
||||||
import { t } from '../../../utils/intl'
|
|
||||||
import { Icon } from '../../_shared/Icon'
|
import { Icon } from '../../_shared/Icon'
|
||||||
import { hideModal } from '../../../stores/ui'
|
import { hideModal } from '../../../stores/ui'
|
||||||
|
|
||||||
import styles from './SocialProviders.module.scss'
|
import styles from './SocialProviders.module.scss'
|
||||||
import { apiBaseUrl } from '../../../utils/config'
|
import { apiBaseUrl } from '../../../utils/config'
|
||||||
|
import { useLocalize } from '../../../context/localize'
|
||||||
|
|
||||||
type Provider = 'facebook' | 'google' | 'vk' | 'github'
|
type Provider = 'facebook' | 'google' | 'vk' | 'github'
|
||||||
|
|
||||||
|
@ -16,6 +16,7 @@ const handleSocialAuthLinkClick = (event: MouseEvent, provider: Provider): void
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SocialProviders = () => {
|
export const SocialProviders = () => {
|
||||||
|
const { t } = useLocalize()
|
||||||
return (
|
return (
|
||||||
<div class={styles.container}>
|
<div class={styles.container}>
|
||||||
<div class={styles.text}>{t('Or continue with social network')}</div>
|
<div class={styles.text}>{t('Or continue with social network')}</div>
|
||||||
|
|
|
@ -1,6 +1,5 @@
|
||||||
import { Dynamic } from 'solid-js/web'
|
import { Dynamic } from 'solid-js/web'
|
||||||
import { Component, createEffect, createMemo } from 'solid-js'
|
import { Component, createEffect, createMemo } from 'solid-js'
|
||||||
import { t } from '../../../utils/intl'
|
|
||||||
import { hideModal } from '../../../stores/ui'
|
import { hideModal } from '../../../stores/ui'
|
||||||
import { useRouter } from '../../../stores/router'
|
import { useRouter } from '../../../stores/router'
|
||||||
import { clsx } from 'clsx'
|
import { clsx } from 'clsx'
|
||||||
|
@ -10,6 +9,7 @@ import { RegisterForm } from './RegisterForm'
|
||||||
import { ForgotPasswordForm } from './ForgotPasswordForm'
|
import { ForgotPasswordForm } from './ForgotPasswordForm'
|
||||||
import { EmailConfirm } from './EmailConfirm'
|
import { EmailConfirm } from './EmailConfirm'
|
||||||
import type { AuthModalMode, AuthModalSearchParams } from './types'
|
import type { AuthModalMode, AuthModalSearchParams } from './types'
|
||||||
|
import { useLocalize } from '../../../context/localize'
|
||||||
|
|
||||||
const AUTH_MODAL_MODES: Record<AuthModalMode, Component> = {
|
const AUTH_MODAL_MODES: Record<AuthModalMode, Component> = {
|
||||||
login: LoginForm,
|
login: LoginForm,
|
||||||
|
@ -20,7 +20,7 @@ const AUTH_MODAL_MODES: Record<AuthModalMode, Component> = {
|
||||||
|
|
||||||
export const AuthModal = () => {
|
export const AuthModal = () => {
|
||||||
let rootRef: HTMLDivElement
|
let rootRef: HTMLDivElement
|
||||||
|
const { t } = useLocalize()
|
||||||
const { searchParams } = useRouter<AuthModalSearchParams>()
|
const { searchParams } = useRouter<AuthModalSearchParams>()
|
||||||
|
|
||||||
const mode = createMemo<AuthModalMode>(() => {
|
const mode = createMemo<AuthModalMode>(() => {
|
||||||
|
|
|
@ -1,8 +1,9 @@
|
||||||
import './Confirmed.scss'
|
import './Confirmed.scss'
|
||||||
import { onMount } from 'solid-js'
|
import { onMount } from 'solid-js'
|
||||||
import { t } from '../../utils/intl'
|
import { useLocalize } from '../../context/localize'
|
||||||
|
|
||||||
export const Confirmed = (props: { token?: string }) => {
|
export const Confirmed = (props: { token?: string }) => {
|
||||||
|
const { t } = useLocalize()
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
const token = props.token ?? document.cookie.split(';').at(0).replace('token=', '')
|
const token = props.token ?? document.cookie.split(';').at(0).replace('token=', '')
|
||||||
window.addEventListener('mousemove', () => window.close())
|
window.addEventListener('mousemove', () => window.close())
|
||||||
|
|
|
@ -110,8 +110,10 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
a {
|
a,
|
||||||
border: none;
|
a:link,
|
||||||
|
a:visited {
|
||||||
|
border-bottom: none;
|
||||||
color: #000;
|
color: #000;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
|
@ -127,9 +129,11 @@
|
||||||
.usernav {
|
.usernav {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
padding-right: 0;
|
|
||||||
position: relative;
|
position: relative;
|
||||||
width: auto;
|
|
||||||
|
// replace row > * selector to remove !important
|
||||||
|
padding-right: 0 !important;
|
||||||
|
width: auto !important;
|
||||||
|
|
||||||
@include media-breakpoint-down(md) {
|
@include media-breakpoint-down(md) {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
@ -195,7 +199,8 @@
|
||||||
margin-right: 2.4rem;
|
margin-right: 2.4rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
a {
|
a,
|
||||||
|
a:link {
|
||||||
border: none;
|
border: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -443,7 +448,8 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.button,
|
.button,
|
||||||
a {
|
a,
|
||||||
|
a:link {
|
||||||
border: none;
|
border: none;
|
||||||
height: auto;
|
height: auto;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
|
|
@ -2,9 +2,8 @@ import { For, Show, createSignal, createEffect, onMount, onCleanup } from 'solid
|
||||||
import { Icon } from '../_shared/Icon'
|
import { Icon } from '../_shared/Icon'
|
||||||
import { Modal } from './Modal'
|
import { Modal } from './Modal'
|
||||||
import { AuthModal } from './AuthModal'
|
import { AuthModal } from './AuthModal'
|
||||||
import { t } from '../../utils/intl'
|
|
||||||
import { useModalStore } from '../../stores/ui'
|
import { useModalStore } from '../../stores/ui'
|
||||||
import { router, useRouter } from '../../stores/router'
|
import { router, ROUTES, useRouter } from '../../stores/router'
|
||||||
import styles from './Header.module.scss'
|
import styles from './Header.module.scss'
|
||||||
import { getPagePath } from '@nanostores/router'
|
import { getPagePath } from '@nanostores/router'
|
||||||
import { clsx } from 'clsx'
|
import { clsx } from 'clsx'
|
||||||
|
@ -12,12 +11,7 @@ import { HeaderAuth } from './HeaderAuth'
|
||||||
import { getShareUrl, SharePopup } from '../Article/SharePopup'
|
import { getShareUrl, SharePopup } from '../Article/SharePopup'
|
||||||
import { getDescription } from '../../utils/meta'
|
import { getDescription } from '../../utils/meta'
|
||||||
import { Snackbar } from './Snackbar'
|
import { Snackbar } from './Snackbar'
|
||||||
|
import { useLocalize } from '../../context/localize'
|
||||||
const resources: { name: string; route: 'home' | 'feed' | 'topics' }[] = [
|
|
||||||
{ name: t('zine'), route: 'home' },
|
|
||||||
{ name: t('feed'), route: 'feed' },
|
|
||||||
{ name: t('topics'), route: 'topics' }
|
|
||||||
]
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
title?: string
|
title?: string
|
||||||
|
@ -27,6 +21,13 @@ type Props = {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Header = (props: Props) => {
|
export const Header = (props: Props) => {
|
||||||
|
const { t } = useLocalize()
|
||||||
|
|
||||||
|
const resources: { name: string; route: keyof typeof ROUTES }[] = [
|
||||||
|
{ name: t('zine'), route: 'home' },
|
||||||
|
{ name: t('feed'), route: 'feed' },
|
||||||
|
{ name: t('topics'), route: 'topics' }
|
||||||
|
]
|
||||||
// signals
|
// signals
|
||||||
const [getIsScrollingBottom, setIsScrollingBottom] = createSignal(false)
|
const [getIsScrollingBottom, setIsScrollingBottom] = createSignal(false)
|
||||||
const [getIsScrolled, setIsScrolled] = createSignal(false)
|
const [getIsScrolled, setIsScrolled] = createSignal(false)
|
||||||
|
@ -117,7 +118,7 @@ export const Header = (props: Props) => {
|
||||||
</For>
|
</For>
|
||||||
<li class={styles.headerSearch}>
|
<li class={styles.headerSearch}>
|
||||||
<a href="#">
|
<a href="#">
|
||||||
<Icon name="search" class={styles.icon} iconClassName={styles.searchIcon} />
|
<Icon name="search" class={styles.icon} />
|
||||||
{t('Search')}
|
{t('Search')}
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import styles from './Header.module.scss'
|
import styles from './Header.module.scss'
|
||||||
import { clsx } from 'clsx'
|
import { clsx } from 'clsx'
|
||||||
import { useRouter } from '../../stores/router'
|
import { useRouter } from '../../stores/router'
|
||||||
import { t } from '../../utils/intl'
|
|
||||||
import { Icon } from '../_shared/Icon'
|
import { Icon } from '../_shared/Icon'
|
||||||
import { createSignal, Show } from 'solid-js'
|
import { createSignal, Show } from 'solid-js'
|
||||||
import Notifications from './Notifications'
|
import Notifications from './Notifications'
|
||||||
|
@ -11,12 +11,14 @@ import type { Author } from '../../graphql/types.gen'
|
||||||
import { showModal, useWarningsStore } from '../../stores/ui'
|
import { showModal, useWarningsStore } from '../../stores/ui'
|
||||||
import { ShowOnlyOnClient } from '../_shared/ShowOnlyOnClient'
|
import { ShowOnlyOnClient } from '../_shared/ShowOnlyOnClient'
|
||||||
import { useSession } from '../../context/session'
|
import { useSession } from '../../context/session'
|
||||||
|
import { useLocalize } from '../../context/localize'
|
||||||
|
|
||||||
type HeaderAuthProps = {
|
type HeaderAuthProps = {
|
||||||
setIsProfilePopupVisible: (value: boolean) => void
|
setIsProfilePopupVisible: (value: boolean) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export const HeaderAuth = (props: HeaderAuthProps) => {
|
export const HeaderAuth = (props: HeaderAuthProps) => {
|
||||||
|
const { t } = useLocalize()
|
||||||
const { page } = useRouter()
|
const { page } = useRouter()
|
||||||
const [visibleWarnings, setVisibleWarnings] = createSignal(false)
|
const [visibleWarnings, setVisibleWarnings] = createSignal(false)
|
||||||
const { warnings } = useWarningsStore()
|
const { warnings } = useWarningsStore()
|
||||||
|
@ -38,7 +40,7 @@ export const HeaderAuth = (props: HeaderAuthProps) => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ShowOnlyOnClient>
|
<ShowOnlyOnClient>
|
||||||
<Show when={isSessionLoaded()}>
|
<Show when={isSessionLoaded()} keyed={true}>
|
||||||
<div class={styles.usernav}>
|
<div class={styles.usernav}>
|
||||||
<div class={clsx(styles.userControl, styles.userControl, 'col')}>
|
<div class={clsx(styles.userControl, styles.userControl, 'col')}>
|
||||||
<div class={clsx(styles.userControlItem, styles.userControlItemVerbose)}>
|
<div class={clsx(styles.userControlItem, styles.userControlItemVerbose)}>
|
||||||
|
|
|
@ -1,13 +1,10 @@
|
||||||
import { createEffect, createSignal, Show } from 'solid-js'
|
import { createEffect, createSignal, Show } from 'solid-js'
|
||||||
import type { JSX } from 'solid-js'
|
import type { JSX } from 'solid-js'
|
||||||
import { getLogger } from '../../utils/logger'
|
|
||||||
import { hideModal, useModalStore } from '../../stores/ui'
|
import { hideModal, useModalStore } from '../../stores/ui'
|
||||||
import { useEscKeyDownHandler } from '../../utils/useEscKeyDownHandler'
|
import { useEscKeyDownHandler } from '../../utils/useEscKeyDownHandler'
|
||||||
import { clsx } from 'clsx'
|
import { clsx } from 'clsx'
|
||||||
import styles from './Modal.module.scss'
|
import styles from './Modal.module.scss'
|
||||||
|
|
||||||
const log = getLogger('modal')
|
|
||||||
|
|
||||||
interface ModalProps {
|
interface ModalProps {
|
||||||
name: string
|
name: string
|
||||||
variant: 'narrow' | 'wide'
|
variant: 'narrow' | 'wide'
|
||||||
|
@ -27,7 +24,6 @@ export const Modal = (props: ModalProps) => {
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
setVisible(modal() === props.name)
|
setVisible(modal() === props.name)
|
||||||
log.debug(`${props.name} is ${modal() === props.name ? 'visible' : 'hidden'}`)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -35,7 +31,6 @@ export const Modal = (props: ModalProps) => {
|
||||||
<div class={styles.backdrop} onClick={backdropClick}>
|
<div class={styles.backdrop} onClick={backdropClick}>
|
||||||
<div
|
<div
|
||||||
class={clsx(styles.modal, {
|
class={clsx(styles.modal, {
|
||||||
[styles.wide]: props.variant === 'wide',
|
|
||||||
[styles.narrow]: props.variant === 'narrow'
|
[styles.narrow]: props.variant === 'narrow'
|
||||||
})}
|
})}
|
||||||
onClick={(event) => event.stopPropagation()}
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
|
|
@ -1,9 +1,11 @@
|
||||||
import { AuthorCard } from '../Author/Card'
|
import { AuthorCard } from '../Author/Card'
|
||||||
import type { Author } from '../../graphql/types.gen'
|
import type { Author } from '../../graphql/types.gen'
|
||||||
import { t } from '../../utils/intl'
|
|
||||||
|
import { translit } from '../../utils/ru2en'
|
||||||
import { hideModal } from '../../stores/ui'
|
import { hideModal } from '../../stores/ui'
|
||||||
import { createMemo, For } from 'solid-js'
|
import { createMemo, For } from 'solid-js'
|
||||||
import { useSession } from '../../context/session'
|
import { useSession } from '../../context/session'
|
||||||
|
import { useLocalize } from '../../context/localize'
|
||||||
|
|
||||||
export const ProfileModal = () => {
|
export const ProfileModal = () => {
|
||||||
const {
|
const {
|
||||||
|
@ -15,6 +17,7 @@ export const ProfileModal = () => {
|
||||||
signOut()
|
signOut()
|
||||||
hideModal()
|
hideModal()
|
||||||
}
|
}
|
||||||
|
const { t, lang } = useLocalize()
|
||||||
|
|
||||||
const author = createMemo<Author>(() => {
|
const author = createMemo<Author>(() => {
|
||||||
const a: Author = {
|
const a: Author = {
|
||||||
|
@ -26,7 +29,7 @@ export const ProfileModal = () => {
|
||||||
|
|
||||||
if (session()?.user?.slug) {
|
if (session()?.user?.slug) {
|
||||||
const u = session().user
|
const u = session().user
|
||||||
a.name = u.name
|
a.name = lang() === 'ru' ? u.name : translit(u.name)
|
||||||
a.slug = u.slug
|
a.slug = u.slug
|
||||||
a.userpic = u.userpic
|
a.userpic = u.userpic
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,6 +4,7 @@ import { Popup } from '../_shared/Popup'
|
||||||
import styles from '../_shared/Popup/Popup.module.scss'
|
import styles from '../_shared/Popup/Popup.module.scss'
|
||||||
import { getPagePath } from '@nanostores/router'
|
import { getPagePath } from '@nanostores/router'
|
||||||
import { router } from '../../stores/router'
|
import { router } from '../../stores/router'
|
||||||
|
import { useLocalize } from '../../context/localize'
|
||||||
|
|
||||||
type ProfilePopupProps = Omit<PopupProps, 'children'>
|
type ProfilePopupProps = Omit<PopupProps, 'children'>
|
||||||
|
|
||||||
|
@ -13,27 +14,30 @@ export const ProfilePopup = (props: ProfilePopupProps) => {
|
||||||
actions: { signOut }
|
actions: { signOut }
|
||||||
} = useSession()
|
} = useSession()
|
||||||
|
|
||||||
|
const { t, lang } = useLocalize()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Popup {...props} horizontalAnchor="right" variant="bordered">
|
<Popup {...props} horizontalAnchor="right" variant="bordered">
|
||||||
{/*TODO: l10n*/}
|
|
||||||
<ul class="nodash">
|
<ul class="nodash">
|
||||||
<li>
|
<li>
|
||||||
<a href={getPagePath(router, 'author', { slug: userSlug() })}>Профиль</a>
|
<a href={getPagePath(router, 'author', { slug: userSlug(), lang: lang() } as never)}>
|
||||||
|
{t('Profile')}
|
||||||
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a href="#">Черновики</a>
|
<a href="#">{t('Drafts')}</a>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a href="#">Подписки</a>
|
<a href="#">{t('Subscriptions')}</a>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a href="#">Комментарии</a>
|
<a href="#">{t('Comments')}</a>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a href="#">Закладки</a>
|
<a href="#">{t('Bookmarks')}</a>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a href={getPagePath(router, 'profileSettings')}>Настройки</a>
|
<a href={getPagePath(router, 'profileSettings')}>{t('Settings')}</a>
|
||||||
</li>
|
</li>
|
||||||
<li class={styles.topBorderItem}>
|
<li class={styles.topBorderItem}>
|
||||||
<a
|
<a
|
||||||
|
@ -43,7 +47,7 @@ export const ProfilePopup = (props: ProfilePopupProps) => {
|
||||||
signOut()
|
signOut()
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Выйти из аккаунта
|
{t('Logout')}
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
|
@ -1,14 +1,14 @@
|
||||||
import { For, Show } from 'solid-js'
|
import { createMemo, For, Show } from 'solid-js'
|
||||||
import type { Topic } from '../../graphql/types.gen'
|
import type { Topic } from '../../graphql/types.gen'
|
||||||
import { Icon } from '../_shared/Icon'
|
import { Icon } from '../_shared/Icon'
|
||||||
import './Topics.scss'
|
import './Topics.scss'
|
||||||
import { t } from '../../utils/intl'
|
|
||||||
import { locale } from '../../stores/ui'
|
import { useLocalize } from '../../context/localize'
|
||||||
|
|
||||||
export const NavTopics = (props: { topics: Topic[] }) => {
|
export const NavTopics = (props: { topics: Topic[] }) => {
|
||||||
|
const { t, lang } = useLocalize()
|
||||||
const tag = (topic: Topic) =>
|
const tag = (topic: Topic) =>
|
||||||
/[ЁА-яё]/.test(topic.title || '') && locale() !== 'ru' ? topic.slug : topic.title
|
/[ЁА-яё]/.test(topic.title || '') && lang() !== 'ru' ? topic.slug : topic.title
|
||||||
|
|
||||||
// TODO: something about subtopics
|
// TODO: something about subtopics
|
||||||
return (
|
return (
|
||||||
<nav class="subnavigation wide-container text-2xl">
|
<nav class="subnavigation wide-container text-2xl">
|
||||||
|
|
|
@ -1,49 +0,0 @@
|
||||||
import { PageWrap } from '../_shared/PageWrap'
|
|
||||||
import { ArticleView } from '../Views/Article'
|
|
||||||
import type { PageProps } from '../types'
|
|
||||||
import { loadShout, useArticlesStore } from '../../stores/zine/articles'
|
|
||||||
import { createMemo, onMount, Show } from 'solid-js'
|
|
||||||
import type { Shout } from '../../graphql/types.gen'
|
|
||||||
import { useRouter } from '../../stores/router'
|
|
||||||
import { Loading } from '../Loading'
|
|
||||||
|
|
||||||
export const ArticlePage = (props: PageProps) => {
|
|
||||||
const shouts = props.article ? [props.article] : []
|
|
||||||
|
|
||||||
const slug = createMemo(() => {
|
|
||||||
const { page: getPage } = useRouter()
|
|
||||||
|
|
||||||
const page = getPage()
|
|
||||||
|
|
||||||
if (page.route !== 'article') {
|
|
||||||
throw new Error('ts guard')
|
|
||||||
}
|
|
||||||
|
|
||||||
return page.params.slug
|
|
||||||
})
|
|
||||||
|
|
||||||
const { articleEntities } = useArticlesStore({
|
|
||||||
shouts
|
|
||||||
})
|
|
||||||
|
|
||||||
const article = createMemo<Shout>(() => articleEntities()[slug()])
|
|
||||||
|
|
||||||
onMount(async () => {
|
|
||||||
const articleValue = articleEntities()[slug()]
|
|
||||||
|
|
||||||
if (!articleValue || !articleValue.body) {
|
|
||||||
await loadShout(slug())
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PageWrap headerTitle={article()?.title || ''} articleBody={article()?.body} cover={article()?.cover}>
|
|
||||||
<Show when={Boolean(article())} fallback={<Loading />}>
|
|
||||||
<ArticleView article={article()} />
|
|
||||||
</Show>
|
|
||||||
</PageWrap>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// for lazy loading
|
|
||||||
export default ArticlePage
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user