goodbye astro, i18n

This commit is contained in:
bniwredyc 2023-02-17 10:21:02 +01:00
parent 124b43d667
commit f5272383c2
251 changed files with 3657 additions and 13386 deletions

View File

@ -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
View File

@ -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
View File

@ -1,2 +0,0 @@
# Expose Astro dependencies for `pnpm` users
shamefully-hoist=true

View File

@ -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

View File

@ -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

View File

@ -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) {

View File

@ -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
View 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)
}

View File

@ -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)

View File

@ -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
View 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
View 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

File diff suppressed because it is too large Load Diff

View File

@ -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",

View 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"
}

View File

@ -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": "Правила сообществ самиздата в&nbsp;соцсетях", "Discussion rules": "Правила сообществ самиздата в&nbsp;соцсетях",
"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
View 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>
)
}

View File

@ -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;
}

View File

@ -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

View File

@ -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}>&nbsp;+{newReactions()}</span> <span class={styles.newReactions}>&nbsp;+{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')}&nbsp;
<a href="?modal=auth&mode=register" class={styles.link}>
{t('sign up')}
</a>
&nbsp;{t('or')}&nbsp;
<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>
) )

View File

@ -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}>

View File

@ -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}>&minus;</button> <button class={styles.ratingControl} onClick={props.onDownvote}>
&minus;
</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>
) )
} }

View File

@ -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,

View File

@ -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 {

View File

@ -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'
} }

View File

@ -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">

View File

@ -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 = {

View File

@ -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

View File

@ -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">
Независимый журнал с&nbsp;открытой горизонтальной редакцией о&nbsp;культуре, науке {t(
и&nbsp;обществе. Дискурс&nbsp;&copy; 2015&ndash;2022{' '} 'Independant magazine with an open horizontal cooperation about culture, science and society'
)}
. {t('Discours')} &copy; 2015&ndash;{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}>

View File

@ -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">

View File

@ -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">Вход и&nbsp;безопасность</a> <a href="/profile/security">{t('Security')}</a>
</li> </li>
</ul> </ul>
</> </>

View File

@ -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 () => {

View File

@ -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;
}

View File

@ -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}
/>
)
}

View File

@ -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;
}
}

View File

@ -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>
)
}

View File

@ -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;
}
}

View File

@ -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>
)
}

View File

@ -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 }
}

View File

@ -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;
}
}
}
}

View File

@ -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}>
Советы и&nbsp;предложения
</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>
)
}

View File

@ -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')
}
}

View File

@ -1,2 +0,0 @@
export const isDark = () =>
typeof window !== undefined && window.matchMedia('(prefers-color-scheme: dark)').matches

View File

@ -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(`![${alt}](${src}${title ? ' ' + title : ''})\n`)
/* ![<alt-text>](<src-url> "<title>") */
},
code_block(state, node) {
const src = node.attrs.params.src
if (src) {
const title = state.esc(node.attrs.params.title || '')
state.write(`![${title}](${src})\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 }
})

View File

@ -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 })
]
})

View File

@ -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)
]
})

View File

@ -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
})

View File

@ -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]
})

View File

@ -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 }
})

View File

@ -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)]
})

View File

@ -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
})

View File

@ -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) })]
})

View File

@ -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
})
]
})

View File

@ -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)]
})

View File

@ -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)]
})

View File

@ -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
}
}

View File

@ -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)
})

View File

@ -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)]
})

View File

@ -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)] })
]
})

View File

@ -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)] })
]
})

View File

@ -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 }
})

View File

@ -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)

View File

@ -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]
}

View File

@ -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)
}

View File

@ -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)
}

View File

@ -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]
}

View File

@ -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
})

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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
} = { } = {

View File

@ -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}

View File

@ -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(),

View File

@ -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);

View File

@ -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">

View File

@ -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)
} }

View File

@ -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">

View File

@ -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)) || []
) )

View File

@ -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 })

View File

@ -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>

View File

@ -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'

View File

@ -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
})} })}

View File

@ -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}

View File

@ -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;
} }
} }
} }

View File

@ -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

View File

@ -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())
}) })

View File

@ -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>

View File

@ -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)

View File

@ -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>

View File

@ -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('')

View File

@ -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>

View File

@ -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>(() => {

View File

@ -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())

View File

@ -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;

View File

@ -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>

View File

@ -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)}>

View File

@ -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()}

View File

@ -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
} }

View File

@ -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()
}} }}
> >
Выйти из&nbsp;аккаунта {t('Logout')}
</a> </a>
</li> </li>
</ul> </ul>

View File

@ -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">

View File

@ -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