merged-dev

This commit is contained in:
tonyrewin 2022-11-02 01:29:08 +03:00
commit 12f30b29a0
106 changed files with 3042 additions and 1733 deletions

View File

@ -27,7 +27,6 @@ module.exports = {
// 'plugin:@typescript-eslint/recommended-requiring-type-checking'
],
rules: {
'no-nested-ternary': 'off',
'@typescript-eslint/no-unused-vars': [
'warn',
{
@ -35,8 +34,9 @@ module.exports = {
varsIgnorePattern: '^log$'
}
],
'@typescript-eslint/no-explicit-any': 'warn',
'@typescript-eslint/no-non-null-assertion': 'warn',
// TODO: Remove any usage and enable
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-non-null-assertion': 'error',
// solid-js fix
'import/no-unresolved': [2, { ignore: ['solid-js/'] }]

View File

@ -1,6 +1,4 @@
{
"*.{js,ts,tsx,json,scss,css,html}": "prettier --write",
"package.json": "sort-package-json",
"*.{scss,css}": "stylelint",
"*.{ts,tsx,js}": "eslint --fix"
"package.json": "sort-package-json"
}

6
.lintstagedrc.bak Normal file
View File

@ -0,0 +1,6 @@
{
"*.{js,ts,tsx,json,scss,css,html}": "prettier --write",
"package.json": "sort-package-json",
"*.{scss,css}": "stylelint",
"*.{ts,tsx,js}": "eslint --fix"
}

View File

@ -17,9 +17,9 @@
"lint:code:fix": "eslint . --fix",
"lint:styles": "stylelint **/*.{scss,css}",
"lint:styles:fix": "stylelint **/*.{scss,css} --fix",
"pre-commit": "",
"pre-push": "",
"pre-commit": "lint-staged",
"pre-commit-old": "lint-staged",
"pre-push": "",
"pre-push-old": "npm run typecheck",
"prepare": "husky install",
"preview": "astro preview",
@ -30,7 +30,7 @@
"vercel-build": "astro build"
},
"dependencies": {
"aws-sdk": "^2.0.0",
"@aws-sdk/client-s3": "^3.178.0",
"mailgun.js": "^8.0.1"
},
"devDependencies": {
@ -60,17 +60,17 @@
"@urql/devtools": "^2.0.3",
"@urql/exchange-auth": "^1.0.0",
"@urql/exchange-graphcache": "^5.0.0",
"astro": "^1.4.6",
"astro-eslint-parser": "^0.7.3",
"astro": "^1.1.1",
"astro-eslint-parser": "^0.9.0",
"bcryptjs": "^2.4.3",
"bootstrap": "5.1.3",
"clsx": "^1.2.1",
"cookie": "^0.5.0",
"cookie-signature": "^1.2.0",
"eslint": "^8.22.0",
"eslint": "^8.26.0",
"eslint-config-stylelint": "^17.0.0",
"eslint-import-resolver-typescript": "^3.5.0",
"eslint-plugin-astro": "^0.20.0",
"eslint-plugin-astro": "^0.21.0",
"eslint-plugin-import": "^2.26.0",
"eslint-plugin-jsx-a11y": "^6.6.1",
"eslint-plugin-promise": "^6.0.1",
@ -85,6 +85,8 @@
"idb": "^7.1.0",
"jest": "^29.2.1",
"lint-staged": "^13.0.3",
"loglevel": "^1.8.0",
"loglevel-plugin-prefix": "^0.8.4",
"markdown-it": "^13.0.1",
"markdown-it-container": "^3.0.0",
"markdown-it-implicit-figures": "^0.10.0",
@ -123,7 +125,7 @@
"stylelint": "^14.12.1",
"stylelint-config-css-modules": "^4.1.0",
"stylelint-config-prettier-scss": "^0.0.1",
"stylelint-config-standard-scss": "^5.0.0",
"stylelint-config-standard-scss": "^6.0.0",
"stylelint-order": "^5.0.0",
"stylelint-scss": "^4.3.0",
"swiper": "^8.4.2",

View File

@ -1,10 +1,9 @@
import './Comment.scss'
import { Icon } from '../Nav/Icon'
import { AuthorCard } from '../Author/Card'
import { Show } from 'solid-js/web'
import { Show, createMemo } from 'solid-js'
import { clsx } from 'clsx'
import type { Author, Reaction as Point } from '../../graphql/types.gen'
import { createMemo } from 'solid-js'
import { t } from '../../utils/intl'
// import { createReaction, updateReaction, deleteReaction } from '../../stores/zine/reactions'
import MD from './MD'

View File

@ -10,6 +10,7 @@ import { showModal } from '../../stores/ui'
import { useAuthStore } from '../../stores/auth'
import { incrementView } from '../../stores/zine/articles'
import MD from './MD'
import { SharePopup } from './SharePopup'
const MAX_COMMENT_LEVEL = 6
@ -126,9 +127,13 @@ export const FullArticle = (props: ArticleProps) => {
{/* </a>*/}
{/*</div>*/}
<div class="shout-stats__item">
<a href="#share" onClick={() => showModal('share')}>
<Icon name="share" />
</a>
<SharePopup
trigger={
<a href="#" onClick={(event) => event.preventDefault()}>
<Icon name="share" />
</a>
}
/>
</div>
{/*FIXME*/}
{/*<Show when={canEdit()}>*/}

View File

@ -0,0 +1,45 @@
import { Icon } from '../Nav/Icon'
import styles from '../Nav/Popup.module.scss'
import { t } from '../../utils/intl'
import { Popup, PopupProps } from '../Nav/Popup'
type SharePopupProps = Omit<PopupProps, 'children'>
export const SharePopup = (props: SharePopupProps) => {
return (
<Popup {...props}>
<ul class="nodash">
<li>
<a href="#">
<Icon name="vk-white" class={styles.icon} />
VK
</a>
</li>
<li>
<a href="#">
<Icon name="facebook-white" class={styles.icon} />
Facebook
</a>
</li>
<li>
<a href="#">
<Icon name="twitter-white" class={styles.icon} />
Twitter
</a>
</li>
<li>
<a href="#">
<Icon name="telegram-white" class={styles.icon} />
Telegram
</a>
</li>
<li>
<a href="#">
<Icon name="link-white" class={styles.icon} />
{t('Copy link')}
</a>
</li>
</ul>
</Popup>
)
}

View File

@ -1,9 +1,8 @@
import { For, Show } from 'solid-js/web'
import type { Author } from '../../graphql/types.gen'
import Userpic from './Userpic'
import { Icon } from '../Nav/Icon'
import style from './Card.module.scss'
import { createMemo } from 'solid-js'
import { createMemo, For, Show } from 'solid-js'
import { translit } from '../../utils/ru2en'
import { t } from '../../utils/intl'
import { useAuthStore } from '../../stores/auth'

View File

@ -2,7 +2,7 @@ import type { Author } from '../../graphql/types.gen'
import { AuthorCard } from './Card'
import './Full.scss'
export default (props: { author: Author }) => {
export const AuthorFull = (props: { author: Author }) => {
return (
<div class="container">
<div class="row">

View File

@ -1,11 +1,13 @@
import { Show } from 'solid-js/web'
import { Show } from 'solid-js'
import type { Author } from '../../graphql/types.gen'
import style from './Userpic.module.scss'
import { clsx } from 'clsx'
interface UserpicProps {
user: Author
hasLink?: boolean
isBig?: boolean
class?: string
}
export default (props: UserpicProps) => {
@ -16,7 +18,7 @@ export default (props: UserpicProps) => {
}
return (
<div class={style.circlewrap} classList={{ [style.big]: props.isBig }}>
<div class={clsx(style.circlewrap, props.class)} classList={{ [style.big]: props.isBig }}>
<Show when={props.hasLink}>
<a href={`/author/${props.user.slug}`}>
<Show

View File

@ -1,7 +1,7 @@
import styles from './Banner.module.scss'
import { t } from '../../utils/intl'
import { showModal } from '../../stores/ui'
import {clsx} from "clsx";
import { clsx } from 'clsx'
export default () => {
return (

View File

@ -4,7 +4,7 @@ import { Icon } from '../Nav/Icon'
import Subscribe from './Subscribe'
import { t } from '../../utils/intl'
import { locale } from '../../stores/ui'
import {clsx} from "clsx";
import { clsx } from 'clsx'
export const Footer = () => {
const locale_title = createMemo(() => (locale() === 'ru' ? 'English' : 'Русский'))

View File

@ -0,0 +1,7 @@
.error {
display: none;
}
.markdown {
white-space: pre-wrap;
}

View File

@ -3,18 +3,21 @@ import type { EditorState } from 'prosemirror-state'
import { useState } from '../store/context'
import { ProseMirror } from './ProseMirror'
import '../styles/Editor.scss'
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() })
// const editorCss = (config) => css``
const style = () => (store.error ? `display: none;` : (store.markdown ? `white-space: pre-wrap;` : ''))
return (
<ProseMirror
className='editor col-md-6 shift-content'
style={style()}
cssClass={clsx('editor', 'col-md-6', 'shift-content', {
[styles.error]: store.error,
[styles.markdown]: store.markdown
})}
editorView={store.editorView}
text={store.text}
extensions={store.extensions}

View File

@ -7,13 +7,13 @@ export default () => {
return (
<Switch fallback={<Other />}>
<Match when={store.error.id === 'invalid_state'}>
<InvalidState title='Invalid State' />
<InvalidState title="Invalid State" />
</Match>
<Match when={store.error.id === 'invalid_config'}>
<InvalidState title='Invalid Config' />
<InvalidState title="Invalid Config" />
</Match>
<Match when={store.error.id === 'invalid_draft'}>
<InvalidState title='Invalid Draft' />
<InvalidState title="Invalid Draft" />
</Match>
</Switch>
)
@ -24,8 +24,8 @@ const InvalidState = (props: { title: string }) => {
const onClick = () => ctrl.clean()
return (
<div class='error'>
<div class='container'>
<div class="error">
<div class="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
@ -35,7 +35,7 @@ const InvalidState = (props: { title: string }) => {
<pre>
<code>{JSON.stringify(store.error.props)}</code>
</pre>
<button class='primary' onClick={onClick}>
<button class="primary" onClick={onClick}>
Clean
</button>
</div>
@ -53,13 +53,13 @@ const Other = () => {
}
return (
<div class='error'>
<div class='container'>
<div class="error">
<div class="container">
<h1>An error occurred.</h1>
<pre>
<code>{getMessage()}</code>
</pre>
<button class='primary' onClick={onClick}>
<button class="primary" onClick={onClick}>
Close
</button>
</div>

View File

@ -1,17 +1,19 @@
import type { JSX } from 'solid-js/jsx-runtime';
import type { JSX } from 'solid-js/jsx-runtime'
import type { Config } from '../store/context'
import '../styles/Layout.scss'
export type Styled = {
children: JSX.Element;
config?: Config;
'data-testid'?: string;
onClick?: () => void;
onMouseEnter?: (e: MouseEvent) => void;
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='layout container' data-testid={props['data-testid']}>
{props.children}
</div>)
return (
<div onMouseEnter={props.onMouseEnter} class="layout container" data-testid={props['data-testid']}>
{props.children}
</div>
)
}

View File

@ -6,14 +6,13 @@ import { Schema } from 'prosemirror-model'
import type { NodeViewFn, ProseMirrorExtension, ProseMirrorState } from '../prosemirror/helpers'
interface ProseMirrorProps {
style?: string;
className?: string;
text?: Store<ProseMirrorState>;
editorView?: Store<EditorView>;
extensions?: Store<ProseMirrorExtension[]>;
onInit: (s: EditorState, v: EditorView) => void;
onReconfigure: (s: EditorState) => void;
onChange: (s: EditorState) => void;
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) => {
@ -28,45 +27,39 @@ export const ProseMirror = (props: ProseMirrorProps) => {
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) {
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]
}
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]
},
[props.text, props.extensions]
)
return (
<div
style={props.style}
ref={editorRef}
class={props.className}
spell-check={false}
/>
)
return <div ref={editorRef} class={props.cssClass} spell-check={false} />
}
const createEditorState = (
@ -74,8 +67,8 @@ const createEditorState = (
extensions: ProseMirrorExtension[],
prevText?: EditorState
): {
editorState: EditorState;
nodeViews: { [key: string]: NodeViewFn };
editorState: EditorState
nodeViews: { [key: string]: NodeViewFn }
} => {
const reconfigure = text instanceof EditorState && prevText?.schema
let schemaSpec = { nodes: {} }
@ -104,7 +97,7 @@ const createEditorState = (
editorState = text.reconfigure({ schema, plugins } as EditorStateConfig)
} else if (text instanceof EditorState) {
editorState = EditorState.fromJSON({ schema, plugins }, text.toJSON())
} else if (text){
} else if (text) {
console.debug(text)
editorState = EditorState.fromJSON({ schema, plugins }, text)
}

View File

@ -0,0 +1,3 @@
.withMargin {
margin-bottom: 10px;
}

View File

@ -7,17 +7,20 @@ import * as remote from '../remote'
import { isEmpty } from '../prosemirror/helpers'
import type { Styled } from './Layout'
import '../styles/Sidebar.scss'
import { clsx } from 'clsx'
import styles from './Sidebar.module.scss'
const Off = (props) => <div class='sidebar-off'>{props.children}</div>
const Off = (props) => <div class="sidebar-off">{props.children}</div>
const Label = (props: Styled) => <h3 class='sidebar-label'>{props.children}</h3>
const Label = (props: Styled) => <h3 class="sidebar-label">{props.children}</h3>
const Link = (
props: Styled & { withMargin?: boolean; disabled?: boolean; title?: string; className?: string }
) => (
<button
class={`sidebar-link${props.className ? ' ' + props.className : ''}`}
style={{ "margin-bottom": props.withMargin ? '10px' : '' }}
class={clsx('sidebar-link', props.className, {
[styles.withMargin]: props.withMargin
})}
onClick={props.onClick}
disabled={props.disabled}
title={props.title}
@ -34,22 +37,46 @@ export const Sidebar = () => {
document.body.classList.toggle('dark')
ctrl.updateConfig({ theme: document.body.className })
}
const collabText = () => (store.collab?.started ? 'Stop' : (store.collab?.error ? 'Restart 🚨' : 'Start'))
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 onCopyAllAsMd = () =>
remote.copyAllAsMarkdown(editorView().state).then(() => setLastAction('copy-md'))
const onDiscard = () => ctrl.discard()
const [isHidden, setIsHidden] = createSignal<boolean | false>()
const toggleSidebar = () => {
setIsHidden(!isHidden());
setIsHidden(!isHidden())
}
toggleSidebar();
toggleSidebar()
const onCollab = () => {
const state = unwrap(store)
@ -84,11 +111,13 @@ export const Sidebar = () => {
}
const text = () =>
p.draft.path ? p.draft.path.slice(Math.max(0, p.draft.path.length - length)) : getContent(p.draft.text?.doc)
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='draft' onClick={() => onOpenDraft(p.draft)} data-testid='open'>
<Link className="draft" onClick={() => onOpenDraft(p.draft)} data-testid="open">
{text()} {p.draft.path && '📎'}
</Link>
)
@ -96,9 +125,7 @@ export const Sidebar = () => {
const Keys = (props) => (
<span>
<For each={props.keys}>{(k: Element) => (
<i>{k}</i>
)}</For>
<For each={props.keys}>{(k: Element) => <i>{k}</i>}</For>
</span>
)
@ -116,10 +143,12 @@ export const Sidebar = () => {
return (
<div class={'sidebar-container' + (isHidden() ? ' sidebar-container--hidden' : '')}>
<span class='sidebar-opener' onClick={toggleSidebar}>Советы и&nbsp;предложения</span>
<span class="sidebar-opener" onClick={toggleSidebar}>
Советы и&nbsp;предложения
</span>
<Off onClick={() => editorView().focus()}>
<div class='sidebar-closer' onClick={toggleSidebar}/>
<div class="sidebar-closer" onClick={toggleSidebar} />
<Show when={true}>
<div>
{store.path && (
@ -127,28 +156,21 @@ export const Sidebar = () => {
<i>({store.path.slice(Math.max(0, store.path.length - 24))})</i>
</Label>
)}
<Link>
Пригласить соавторов
</Link>
<Link>
Настройки публикации
</Link>
<Link>
История правок
</Link>
<Link>Пригласить соавторов</Link>
<Link>Настройки публикации</Link>
<Link>История правок</Link>
<div class='theme-switcher'>
<div class="theme-switcher">
Ночная тема
<input type='checkbox' name='theme' id='theme' onClick={toggleTheme} />
<label for='theme'>Ночная тема</label>
<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'
data-testid="discard"
>
{store.path ? 'Close' : (store.drafts.length > 0 && isEmpty(store.text) ? 'Delete ⚠️' : 'Clear')}{' '}
<Keys keys={[mod, 'w']} />
{discardText()} <Keys keys={[mod, 'w']} />
</Link>
<Link onClick={onUndo}>
Undo <Keys keys={[mod, 'z']} />
@ -156,7 +178,7 @@ export const Sidebar = () => {
<Link onClick={onRedo}>
Redo <Keys keys={[mod, 'Shift', 'z']} />
</Link>
<Link onClick={onToggleMarkdown} data-testid='markdown'>
<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>

View File

@ -1,5 +1,10 @@
import markdownit from 'markdown-it'
import { MarkdownSerializer, MarkdownParser, defaultMarkdownSerializer, MarkdownSerializerState } from 'prosemirror-markdown'
import {
MarkdownSerializer,
MarkdownParser,
defaultMarkdownSerializer,
MarkdownSerializerState
} from 'prosemirror-markdown'
import type { Node, Schema } from 'prosemirror-model'
import type { EditorState } from 'prosemirror-state'
@ -12,7 +17,6 @@ export const serialize = (state: EditorState) => {
return text
}
function findAlignment(cell: Node): string | null {
const alignment = cell.attrs.style as string
if (!alignment) {

View File

@ -36,13 +36,13 @@ export default (plain = false): ProseMirrorExtension => ({
schema: () =>
plain
? {
nodes: plainSchema.spec.nodes,
marks: plainSchema.spec.marks
}
nodes: plainSchema.spec.nodes,
marks: plainSchema.spec.marks
}
: {
nodes: (markdownSchema.spec.nodes as OrderedMap<NodeSpec>).update('blockquote', blockquoteSchema),
marks: markdownSchema.spec.marks
},
nodes: (markdownSchema.spec.nodes as OrderedMap<NodeSpec>).update('blockquote', blockquoteSchema),
marks: markdownSchema.spec.marks
},
plugins: (prev, schema) => [
...prev,
keymap({

View File

@ -10,26 +10,26 @@ 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
(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)
}
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'),

View File

@ -2,7 +2,11 @@ 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 }
interface YUser {
background: string
foreground: string
name: string
}
export const cursorBuilder = (user: YUser): HTMLElement => {
const cursor = document.createElement('span')
@ -19,10 +23,10 @@ export default (y: YOptions): ProseMirrorExtension => ({
plugins: (prev) =>
y
? [
...prev,
ySyncPlugin(y.type),
yCursorPlugin(y.provider.awareness, { cursorBuilder }),
yUndoPlugin()
]
...prev,
ySyncPlugin(y.type),
yCursorPlugin(y.provider.awareness, { cursorBuilder }),
yUndoPlugin()
]
: prev
})

View File

@ -14,7 +14,7 @@ import {
} from 'prosemirror-menu'
import { wrapInList } from 'prosemirror-schema-list'
import type{ NodeSelection } from 'prosemirror-state'
import type { NodeSelection } from 'prosemirror-state'
import { TextField, openPrompt } from './prompt'
import type { ProseMirrorExtension } from '../helpers'
@ -22,7 +22,6 @@ import type { Schema } from 'prosemirror-model'
// Helpers to create specific types of items
const cut = (something) => something.filter(Boolean)
function canInsert(state, nodeType) {
@ -45,7 +44,11 @@ function insertImageItem(nodeType) {
return canInsert(state, nodeType)
},
run(state, _, view) {
const { from, to, node: { attrs } } = state.selection as NodeSelection
const {
from,
to,
node: { attrs }
} = state.selection as NodeSelection
openPrompt({
title: 'Insert image',
@ -78,7 +81,9 @@ function cmdItem(cmd, options) {
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) }
if ((!options.enable || options.enable === true) && !options.select) {
passedOptions[options.enable ? 'enable' : 'select'] = (state) => cmd(state)
}
return new MenuItem(passedOptions)
}
@ -130,7 +135,7 @@ function linkItem(markType) {
href: new TextField({
label: 'Link target',
required: true
}),
})
},
callback(attrs) {
toggleMark(markType, attrs)(view.state, view.dispatch)
@ -214,7 +219,7 @@ export function buildMenuItems(schema: Schema<any, any>) {
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"
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'
}
})
}
@ -225,7 +230,7 @@ export function buildMenuItems(schema: Schema<any, any>) {
icon: {
width: 14,
height: 16,
path: "M4.39216 0V3.42857H6.81882L3.06353 12.5714H0V16H8.78431V12.5714H6.35765L10.1129 3.42857H13.1765V0H4.39216Z"
path: 'M4.39216 0V3.42857H6.81882L3.06353 12.5714H0V16H8.78431V12.5714H6.35765L10.1129 3.42857H13.1765V0H4.39216Z'
}
})
}
@ -320,17 +325,16 @@ export function buildMenuItems(schema: Schema<any, any>) {
})
}
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.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])]

View File

@ -1,60 +1,54 @@
import { renderGrouped } from "prosemirror-menu";
import { Plugin } from "prosemirror-state";
import type { ProseMirrorExtension } from "../helpers";
import { buildMenuItems } from "./menu";
import { renderGrouped } from 'prosemirror-menu'
import { Plugin } from 'prosemirror-state'
import type { ProseMirrorExtension } from '../helpers'
import { buildMenuItems } from './menu'
export class SelectionTooltip {
tooltip: any;
tooltip: any
constructor(view: any, schema: any) {
this.tooltip = document.createElement("div");
this.tooltip.className = "tooltip";
view.dom.parentNode.appendChild(this.tooltip);
const { dom } = renderGrouped(view, buildMenuItems(schema).fullMenu as any);
this.tooltip.appendChild(dom);
this.update(view, null);
this.tooltip = document.createElement('div')
this.tooltip.className = '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;
const state = view.state
if (lastState && lastState.doc.eq(state.doc) && lastState.selection.eq(state.selection)) {
return
}
this.tooltip.style.display = "";
const { from, to } = state.selection;
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";
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();
this.tooltip.remove()
}
}
export function toolTip(schema: any) {
return new Plugin({
view(editorView: any) {
return new SelectionTooltip(editorView, schema);
},
});
return new SelectionTooltip(editorView, schema)
}
})
}
export default (): ProseMirrorExtension => ({
plugins: (prev, schema) => [
...prev,
toolTip(schema)
]
plugins: (prev, schema) => [...prev, toolTip(schema)]
})

View File

@ -10,7 +10,7 @@ export const tableInputRule = (schema: Schema) =>
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 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, {}, [

View File

@ -1,6 +1,13 @@
import { DOMOutputSpec, DOMSerializer, Node as ProsemirrorNode, NodeSpec, NodeType, Schema } from 'prosemirror-model'
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 { wrappingInputRule, inputRules } from 'prosemirror-inputrules'
import { splitListItem } from 'prosemirror-schema-list'
import { keymap } from 'prosemirror-keymap'
import type { NodeViewFn, ProseMirrorExtension } from '../helpers'
@ -59,8 +66,8 @@ class TodoItemView {
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))
this.getPos = getPos
;(this.dom as HTMLElement).querySelector('input').addEventListener('click', this.handleClick.bind(this))
}
handleClick(e: MouseEvent) {
@ -90,5 +97,5 @@ export default (): ProseMirrorExtension => ({
todo_item: (node: ProsemirrorNode, view: EditorView, getPos: () => number) => {
return new TodoItemView(node, view, getPos)
}
} as unknown as { [key:string]: NodeViewFn }
} as unknown as { [key: string]: NodeViewFn }
})

View File

@ -3,9 +3,9 @@ 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 };
schema?: (prev: SchemaSpec) => SchemaSpec
plugins?: (prev: Plugin[], schema: Schema) => Plugin[]
nodeViews?: { [key: string]: NodeViewFn }
}
export type ProseMirrorState = EditorState | unknown

View File

@ -5,7 +5,11 @@ 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] => {
export const roomConnect = (
room: string,
username = '',
keyname = 'collab'
): [XmlFragment, WebrtcProvider] => {
const ydoc = new Doc()
// const yarr = ydoc.getArray(keyname + '-reactions')
// TODO: use reactions

View File

@ -25,15 +25,16 @@ const customKeymap = (props: ExtensionsProps): ProseMirrorExtension => ({
})
export const createExtensions = (props: ExtensionsProps): ProseMirrorExtension[] => {
const eee = [
const extensions = [
placeholder(t('Just start typing...')),
customKeymap(props),
base(props.markdown),
selectionMenu(),
scrollPlugin(props.config?.typewriterMode)
]
if (props.markdown) {
eee.push(
extensions.push(
markdown(),
todoList(),
dragHandle(),
@ -54,8 +55,12 @@ export const createExtensions = (props: ExtensionsProps): ProseMirrorExtension[]
*/
)
}
if (props.collab?.room) eee.push(collab(props.y))
return eee
if (props.collab?.room) {
extensions.push(collab(props.y))
}
return extensions
}
export const createEmptyText = () => ({

View File

@ -11,12 +11,12 @@ import { mod } from '../env'
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)
@ -54,8 +54,6 @@ export const createCtrl = (initial: State): [Store<State>, EditorActions] => {
return true
}
const toggleMarkdown = () => {
const state = unwrap(store)
const editorState = store.text as EditorState
@ -65,7 +63,9 @@ export const createCtrl = (initial: State): [Store<State>, EditorActions] => {
if (markdown) {
const lines = serialize(editorState).split('\n')
const nodes = lines.map((text) => text ? { type: 'paragraph', content: [{ type: 'text', text }] } : { type: 'paragraph' })
const nodes = lines.map((text) =>
text ? { type: 'paragraph', content: [{ type: 'text', text }] } : { type: 'paragraph' }
)
doc = { type: 'doc', content: nodes }
} else {
@ -178,7 +178,8 @@ export const createCtrl = (initial: State): [Store<State>, EditorActions] => {
const fetchData = async (): Promise<State> => {
const state: State = unwrap(store)
const room = window.location.pathname?.slice(1).trim()
const { searchParams } = useRouter<{ room: string }>()
const room = searchParams().room
const args = { room: room ?? undefined }
const data = await db.get('state')
let parsed: State
@ -275,26 +276,27 @@ export const createCtrl = (initial: State): [Store<State>, EditorActions] => {
})
}
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
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
}
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)
db.set('state', JSON.stringify(data))
}, 200)
const setFullscreen = (fullscreen: boolean) => {
setState({ fullscreen })
@ -309,7 +311,8 @@ export const createCtrl = (initial: State): [Store<State>, EditorActions] => {
const doStartCollab = async (state: State): Promise<State> => {
const backup = state.args?.room && state.collab?.room !== state.args.room
const room = state.args?.room ?? uuidv4()
window.history.replaceState(null, '', `/${room}`)
const { changeSearchParam } = useRouter<{ room: string }>()
changeSearchParam('room', room, true)
const { roomConnect } = await import('../prosemirror/p2p')
const [type, provider] = roomConnect(room)
@ -380,7 +383,7 @@ export const createCtrl = (initial: State): [Store<State>, EditorActions] => {
}
const updateTheme = () => {
const { theme } = getTheme(unwrap(store))
const { theme } = getTheme(unwrap(store))
setState('config', { theme })
}

View File

@ -19,65 +19,65 @@ export interface ExtensionsProps {
typewriterMode?: boolean
}
export interface Args {
cwd?: string;
draft?: string;
room?: string;
text?: any;
cwd?: string
draft?: string
room?: string
text?: any
}
export interface PrettierConfig {
printWidth: number;
tabWidth: number;
useTabs: boolean;
semi: boolean;
singleQuote: boolean;
printWidth: number
tabWidth: number
useTabs: boolean
semi: boolean
singleQuote: boolean
}
export interface Config {
theme: string;
theme: string
// codeTheme: string;
font: string;
fontSize: number;
contentWidth: number;
typewriterMode: boolean;
prettier: PrettierConfig;
font: string
fontSize: number
contentWidth: number
typewriterMode: boolean
prettier: PrettierConfig
}
export interface ErrorObject {
id: string;
props?: unknown;
id: string
props?: unknown
}
export interface YOptions {
type: XmlFragment;
provider: WebrtcProvider;
type: XmlFragment
provider: WebrtcProvider
}
export interface Collab {
started?: boolean;
error?: boolean;
room?: string;
y?: YOptions;
started?: boolean
error?: boolean
room?: string
y?: YOptions
}
export type LoadingType = 'loading' | 'initialized'
export interface State {
isMac?: boolean
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; }
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 {
@ -91,7 +91,7 @@ export interface Draft {
export interface EditorActions {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
[key:string]: any
[key: string]: any
}
export class ServiceError extends Error {

View File

@ -10,6 +10,7 @@
.article__title {
@include font-size(2.4rem);
line-height: 1.25;
}
@ -31,10 +32,11 @@
}
.article__controls {
@include font-size(1.4rem);
align-content: baseline;
display: flex;
flex-wrap: wrap;
@include font-size(1.4rem);
padding-top: 2em;
}

View File

@ -8,14 +8,14 @@ button {
align-items: center;
outline: none;
text-decoration: none;
font-family: 'JetBrains Mono';
background: none;
font-family: inherit;
color: var(--foreground);
border: 1px solid var(--foreground);
&:hover {
opacity: 0.8;
}
background: none;
font-family: 'Muller';
color: var(--foreground);
border: 1px solid var(--foreground);
}
button.primary {

View File

@ -1,13 +1,9 @@
.main-content {
padding-top: 80px;
}
.editor {
flex: 1;
padding-top: 1em;
a {
color: rgb(0, 100, 200);
color: rgb(0 100 200);
text-decoration: none;
}
@ -16,7 +12,7 @@
}
a:visited {
color: rgb(0, 80, 160);
color: rgb(0 80 160);
}
}
@ -32,7 +28,7 @@ textarea {
font-size: inherit;
-webkit-padding: 0.4em 0;
padding: 0.4em;
margin: 0 0 0.5em 0;
margin: 0 0 0.5em;
box-sizing: border-box;
border: 1px solid #ccc;
border-radius: 2px;
@ -66,7 +62,6 @@ button:focus {
position: relative;
word-wrap: break-word;
white-space: pre-wrap;
-webkit-font-variant-ligatures: none;
font-variant-ligatures: none;
outline: none;
margin: 1em 1em 1em 0;
@ -121,12 +116,14 @@ button:focus {
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 {
@ -157,15 +154,10 @@ button:focus {
}
.ProseMirror-tooltip .ProseMirror-menu {
width: -webkit-fit-content;
width: fit-content;
white-space: pre;
}
.ProseMirror-menuitem {
display: flex;
}
.ProseMirror-menuseparator {
border-right: 1px solid #ddd;
}
@ -189,11 +181,11 @@ button:focus {
position: relative;
}
.ProseMirror-menu-dropdown:after {
.ProseMirror-menu-dropdown::after {
content: '';
border-left: 4px solid transparent;
border-right: 4px solid transparent;
border-top: 4px solid currentColor;
border-top: 4px solid currentcolor;
opacity: 0.6;
position: absolute;
right: 4px;
@ -211,6 +203,7 @@ button:focus {
.ProseMirror-menu-dropdown-menu {
z-index: 15;
/* min-width: 6em; */
}
@ -228,11 +221,11 @@ button:focus {
margin-right: -4px;
}
.ProseMirror-menu-submenu-label:after {
.ProseMirror-menu-submenu-label::after {
content: '';
border-top: 4px solid transparent;
border-bottom: 4px solid transparent;
border-left: 4px solid currentColor;
border-left: 4px solid currentcolor;
opacity: 0.6;
position: absolute;
right: 4px;
@ -273,7 +266,6 @@ button:focus {
border-bottom: 1px solid silver;
background: white;
z-index: 10;
-moz-box-sizing: border-box;
box-sizing: border-box;
overflow: visible;
}
@ -288,7 +280,7 @@ button:focus {
}
.ProseMirror-icon svg {
fill: currentColor;
fill: currentcolor;
height: 1em;
}
@ -308,10 +300,6 @@ button:focus {
background: transparent;
}
.ProseMirror-hideselection *::-moz-selection {
background: transparent;
}
.ProseMirror-hideselection {
caret-color: transparent;
}
@ -325,7 +313,7 @@ li.ProseMirror-selectednode {
outline: none;
}
li.ProseMirror-selectednode:after {
li.ProseMirror-selectednode::after {
content: '';
position: absolute;
left: -32px;
@ -355,7 +343,7 @@ li.ProseMirror-selectednode:after {
.ProseMirror-prompt {
background: #fff;
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.25);
box-shadow: 0 4px 10px rgb(0 0 0 / 25%);
font-size: 0.7em;
position: absolute;
}
@ -400,7 +388,7 @@ li.ProseMirror-selectednode:after {
.tooltip {
background: #fff;
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.25);
box-shadow: 0 4px 10px rgb(0 0 0 / 25%);
color: #000;
display: flex;
position: absolute;

View File

@ -3,8 +3,8 @@
overflow: y-auto;
padding: 50px;
display: flex;
font-family: 'JetBrains Mono';
justify-content: center;
::-webkit-scrollbar {
display: none;
}
@ -17,7 +17,7 @@
}
.error pre {
background: var(--foreground) 19;
background: var(--foreground);
border: 1px solid var(--foreground);
white-space: pre-wrap;
word-wrap: break-word;

View File

@ -1,6 +1,5 @@
.layout {
display: flex;
font-family: 'Muller';
font-size: 18px;
background: var(--background);
color: var(--foreground);
@ -12,7 +11,7 @@
color: var(--background);
border-color: var(--foreground);
}
.drop-cursor {
height: 2px !important;
opacity: 0.5;

View File

@ -1,7 +1,7 @@
.sidebar-container {
color: rgba(255,255,255,0.5);
font-family: 'Muller';
color: rgb(255 255 255 / 50%);
@include font-size(1.6rem);
overflow: hidden;
position: relative;
top: 0;
@ -9,6 +9,19 @@
p {
color: var(--foreground);
}
h4 {
@include font-size(120%);
margin-left: 1rem;
}
button {
height: auto;
min-height: 50px;
padding: 0 1rem;
width: 100%;
}
}
.sidebar-off {
@ -44,7 +57,7 @@
opacity: 0.5;
}
&:after {
&::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;
@ -73,7 +86,8 @@
}
.sidebar-label {
color: var(--foreground) #7f7f7f;
color: var(--foreground);
> i {
text-transform: none;
}
@ -83,16 +97,15 @@
margin: 10px 0;
margin-bottom: 30px;
}
.sidebar-container button,
.sidebar-container a,
.sidebar-item {
text-align: left;
margin: 0;
outline: none;
display: flex;
align-items: center;
line-height: 24px;
font-family: 'Muller';
text-align: left;
}
@ -103,20 +116,6 @@
width: 100%;
}
.sidebar-container {
h4 {
@include font-size(120%);
margin-left: 1rem;
}
button {
height: auto;
min-height: 50px;
padding: 0 1rem;
width: 100%;
}
}
.sidebar-link {
background: none;
border: 0;
@ -138,12 +137,12 @@
}
&[disabled] {
color: var(--foreground) 99;
color: var(--foreground);
cursor: not-allowed;
}
&.draft {
color: rgba(255,255,255,0.5);
color: rgb(255 255 255 / 50%);
line-height: 1.4;
margin: 0 0 1em 1.5em;
width: calc(100% - 2rem);
@ -176,20 +175,22 @@
}
.theme-switcher {
border-bottom: 1px solid rgba(255,255,255,0.3);
border-top: 1px solid rgba(255,255,255,0.3);
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] {
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;
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;
@ -200,7 +201,7 @@
transition: background-color 0.3s;
width: 46px;
&:before {
&::before {
background-color: #fff;
border-radius: 100%;
content: '';
@ -216,7 +217,7 @@
&:checked + label {
background-color: #fff;
&:before {
&::before {
background-color: #1f1f1f;
left: 24px;
}

View File

@ -1,6 +1,6 @@
// TODO: additional entities list column + article
import { For, Show } from 'solid-js/web'
import { For, Show } from 'solid-js'
import { ArticleCard } from './Card'
import { AuthorCard } from '../Author/Card'
import { TopicCard } from '../Topic/Card'
@ -21,7 +21,7 @@ interface BesideProps {
iconButton?: boolean
}
export default (props: BesideProps) => {
export const Beside = (props: BesideProps) => {
return (
<Show when={!!props.beside?.slug && props.values?.length > 0}>
<div class="floor floor--9">

View File

@ -418,7 +418,7 @@
display: flex;
}
.shoutCardDetailsTtem {
.shoutCardDetailsItem {
align-items: center;
display: flex;
margin-right: 1.7em;
@ -454,6 +454,12 @@
}
}
.shoutCardDetailsViewed {
.icon {
margin-top: -0.1em;
}
}
.rating {
align-items: center;
display: flex;

View File

@ -1,11 +1,10 @@
import { t } from '../../utils/intl'
import { createMemo } from 'solid-js'
import { For, Show } from 'solid-js/web'
import { createMemo, For, Show } from 'solid-js'
import type { Shout } from '../../graphql/types.gen'
import { capitalize } from '../../utils'
import { translit } from '../../utils/ru2en'
import { Icon } from '../Nav/Icon'
import style from './Card.module.scss'
import styles from './Card.module.scss'
import { locale } from '../../stores/ui'
import { handleClientRouteLinkClick } from '../../stores/router'
import { clsx } from 'clsx'
@ -72,33 +71,33 @@ export const ArticleCard = (props: ArticleCardProps) => {
return (
<section
class={clsx(style.shoutCard, `${props.settings?.additionalClass || ''}`)}
class={clsx(styles.shoutCard, `${props.settings?.additionalClass || ''}`)}
classList={{
[style.shoutCardShort]: props.settings?.isShort,
[style.shoutCardPhotoBottom]: props.settings?.noimage && props.settings?.photoBottom,
[style.shoutCardFeed]: props.settings?.isFeedMode,
[style.shoutCardFloorImportant]: props.settings?.isFloorImportant,
[style.shoutCardWithCover]: props.settings?.isWithCover,
[style.shoutCardBigTitle]: props.settings?.isBigTitle,
[style.shoutCardVertical]: props.settings?.isVertical,
[style.shoutCardWithBorder]: props.settings?.withBorder,
[style.shoutCardCompact]: props.settings?.isCompact,
[style.shoutCardSingle]: props.settings?.isSingle
[styles.shoutCardShort]: props.settings?.isShort,
[styles.shoutCardPhotoBottom]: props.settings?.noimage && props.settings?.photoBottom,
[styles.shoutCardFeed]: props.settings?.isFeedMode,
[styles.shoutCardFloorImportant]: props.settings?.isFloorImportant,
[styles.shoutCardWithCover]: props.settings?.isWithCover,
[styles.shoutCardBigTitle]: props.settings?.isBigTitle,
[styles.shoutCardVertical]: props.settings?.isVertical,
[styles.shoutCardWithBorder]: props.settings?.withBorder,
[styles.shoutCardCompact]: props.settings?.isCompact,
[styles.shoutCardSingle]: props.settings?.isSingle
}}
>
<Show when={!props.settings?.noimage && cover}>
<div class={style.shoutCardCoverContainer}>
<div class={style.shoutCardCover}>
<div class={styles.shoutCardCoverContainer}>
<div class={styles.shoutCardCover}>
<img src={cover || ''} alt={title || ''} loading="lazy" />
</div>
</div>
</Show>
<div class={style.shoutCardContent}>
<div class={styles.shoutCardContent}>
<Show when={layout && layout !== 'article' && !(props.settings?.noicon || props.settings?.noimage)}>
<div class={style.shoutCardType}>
<div class={styles.shoutCardType}>
<a href={`/topic/${mainTopic.slug}`}>
<Icon name={layout} class={style.icon} />
<Icon name={layout} class={styles.icon} />
</a>
</div>
</Show>
@ -113,24 +112,24 @@ export const ArticleCard = (props: ArticleCardProps) => {
/>
</Show>
<div class={style.shoutCardTitlesContainer}>
<div class={styles.shoutCardTitlesContainer}>
<a href={`/${slug || ''}`} onClick={handleClientRouteLinkClick}>
<div class={style.shoutCardTitle}>
<span class={style.shoutCardLinkContainer}>{title}</span>
<div class={styles.shoutCardTitle}>
<span class={styles.shoutCardLinkContainer}>{title}</span>
</div>
<Show when={!props.settings?.nosubtitle && subtitle}>
<div class={style.shoutCardSubtitle}>
<span class={style.shoutCardLinkContainer}>{subtitle}</span>
<div class={styles.shoutCardSubtitle}>
<span class={styles.shoutCardLinkContainer}>{subtitle}</span>
</div>
</Show>
</a>
</div>
<Show when={!props.settings?.noauthor || !props.settings?.nodate}>
<div class={style.shoutDetails}>
<div class={styles.shoutDetails}>
<Show when={!props.settings?.noauthor}>
<div class={style.shoutAuthor}>
<div class={styles.shoutAuthor}>
<For each={authors}>
{(author, index) => {
const name =
@ -150,44 +149,50 @@ export const ArticleCard = (props: ArticleCardProps) => {
</Show>
<Show when={!props.settings?.nodate}>
<div class={style.shoutDate}>{formattedDate()}</div>
<div class={styles.shoutDate}>{formattedDate()}</div>
</Show>
</div>
</Show>
<Show when={props.settings?.isFeedMode}>
<section class={style.shoutCardDetails}>
<div class={style.shoutCardDetailsContent}>
<div class={clsx(style.shoutCardDetailsItem, 'rating')}>
<button class="rating__control">&minus;</button>
<span class="rating__value">{stat?.rating || ''}</span>
<button class="rating__control">+</button>
<section class={styles.shoutCardDetails}>
<div class={styles.shoutCardDetailsContent}>
<div class={clsx(styles.shoutCardDetailsItem, styles.rating)}>
<button class={styles.ratingControl}>&minus;</button>
<span class={styles.ratingValue}>{stat?.rating || ''}</span>
<button class={styles.ratingControl}>+</button>
</div>
<div class={clsx(style.shoutCardDetailsItem, style.shoutCardComments)}>
<Icon name="eye" class={style.icon} />
<div
class={clsx(
styles.shoutCardDetailsItem,
styles.shoutCardDetailsViewed,
styles.shoutCardComments
)}
>
<Icon name="eye" class={styles.icon} />
{stat?.viewed}
</div>
<div class={clsx(style.shoutCardDetailsTtem, style.shoutCardComments)}>
<div class={clsx(styles.shoutCardDetailsItem, styles.shoutCardComments)}>
<a href={`/${slug + '#comments' || ''}`}>
<Icon name="comment" class={style.icon} />
<Icon name="comment" class={styles.icon} />
{stat?.commented || ''}
</a>
</div>
<div class={style.shoutCardDetailsItem}>
<div class={styles.shoutCardDetailsItem}>
<button>
<Icon name="bookmark" class={style.icon} />
<Icon name="bookmark" class={styles.icon} />
</button>
</div>
<div class={style.shoutCardDetailsItem}>
<div class={styles.shoutCardDetailsItem}>
<button>
<Icon name="ellipsis" class={style.icon} />
<Icon name="ellipsis" class={styles.icon} />
</button>
</div>
</div>
<button class="button--light shout-card__edit-control">{t('Collaborate')}</button>
<button class={clsx('button--light', styles.shoutCardEditControl)}>{t('Collaborate')}</button>
</section>
</Show>
</div>

View File

@ -1,5 +1,5 @@
import type { JSX } from 'solid-js/jsx-runtime'
import { For, Show } from 'solid-js/web'
import { For, Show } from 'solid-js'
import type { Shout } from '../../graphql/types.gen'
import { ArticleCard } from './Card'
import './Group.scss'

View File

@ -1,16 +1,15 @@
import { For, Suspense } from 'solid-js/web'
import OneWide from './Row1'
import Row2 from './Row2'
import Row3 from './Row3'
import { Row1 } from './Row1'
import { Row2 } from './Row2'
import { Row3 } from './Row3'
import { shuffle } from '../../utils'
import { createMemo, createSignal } from 'solid-js'
import { createMemo, createSignal, For, Suspense } from 'solid-js'
import type { JSX } from 'solid-js'
import type { Shout } from '../../graphql/types.gen'
import './List.scss'
import { t } from '../../utils/intl'
export const Block6 = (props: { articles: Shout[] }) => {
const dice = createMemo(() => shuffle([OneWide, Row2, Row3]))
const dice = createMemo(() => shuffle([Row1, Row2, Row3]))
return (
<>

View File

@ -2,12 +2,12 @@ import { Show } from 'solid-js'
import type { Shout } from '../../graphql/types.gen'
import { ArticleCard } from './Card'
export default (props: { article: Shout }) => (
export const Row1 = (props: { article: Shout }) => (
<Show when={!!props.article}>
<div class="floor floor--one-article">
<div class="wide-container row">
<div class="col-12">
<ArticleCard article={props.article} settings={{isSingle: true}} />
<ArticleCard article={props.article} settings={{ isSingle: true }} />
</div>
</div>
</div>

View File

@ -1,14 +1,14 @@
import { createComputed, createSignal, Show } from 'solid-js'
import { For } from 'solid-js/web'
import { createComputed, createSignal, Show, For } from 'solid-js'
import type { Shout } from '../../graphql/types.gen'
import { ArticleCard } from './Card'
const x = [
['6', '6'],
['4', '8'],
['8', '4']
]
export default (props: { articles: Shout[] }) => {
export const Row2 = (props: { articles: Shout[] }) => {
const [y, setY] = createSignal(0)
createComputed(() => setY(Math.floor(Math.random() * x.length)))

View File

@ -1,9 +1,9 @@
import type { JSX } from 'solid-js/jsx-runtime'
import { For } from 'solid-js/web'
import { For } from 'solid-js'
import type { Shout } from '../../graphql/types.gen'
import { ArticleCard } from './Card'
export default (props: { articles: Shout[]; header?: JSX.Element }) => {
export const Row3 = (props: { articles: Shout[]; header?: JSX.Element }) => {
return (
<div class="floor">
<div class="wide-container row">

View File

@ -85,12 +85,12 @@ export const FeedSidebar = (props: FeedSidebarProps) => {
</For>
</ul>
<p class="settings">
<div class="settings">
<a href="/feed/settings">
<strong>{t('Feed settings')}</strong>
<Icon name="settings" />
</a>
<Icon name="settings" />
</p>
</div>
</>
)
}

View File

@ -1,4 +1,3 @@
import { For } from 'solid-js/web'
import { ArticleCard } from './Card'
import { Swiper, Navigation, Pagination } from 'swiper'
import type { SwiperOptions } from 'swiper'
@ -7,7 +6,7 @@ import 'swiper/scss/navigation'
import 'swiper/scss/pagination'
import './Slider.scss'
import type { Shout } from '../../graphql/types.gen'
import { createEffect, createMemo, createSignal, Show } from 'solid-js'
import { createEffect, createMemo, createSignal, Show, For } from 'solid-js'
import { Icon } from '../Nav/Icon'
interface SliderProps {

View File

@ -148,11 +148,11 @@
margin-bottom: 8px;
/* Red/500 */
color: #D00820;
color: #d00820;
a {
color: #D00820;
border-color: #D00820;
color: #d00820;
border-color: #d00820;
&:hover {
color: white;
@ -160,3 +160,17 @@
}
}
}
.title {
font-size: 26px;
line-height: 32px;
font-weight: 700;
color: #141414;
margin-bottom: 16px;
}
.text {
font-size: 15px;
line-height: 24px;
margin-bottom: 52px;
}

View File

@ -1,41 +0,0 @@
import styles from './ConfirmEmail.module.scss'
import authModalStyles from './AuthModal.module.scss'
import { clsx } from 'clsx'
import { t } from '../../../utils/intl'
import { hideModal } from '../../../stores/ui'
import { onMount } from 'solid-js'
import { useRouter } from '../../../stores/router'
import { confirmEmail } from '../../../stores/auth'
type ConfirmEmailSearchParams = {
token: string
}
export const ConfirmEmail = () => {
const confirmedEmail = 'test@test.com'
const { searchParams } = useRouter<ConfirmEmailSearchParams>()
onMount(async () => {
const token = searchParams().token
try {
await confirmEmail(token)
} catch (error) {
console.log(error)
}
})
return (
<div>
<div class={styles.title}>{t('Hooray! Welcome!')}</div>
<div class={styles.text}>
{t("You've confirmed email")} {confirmedEmail}
</div>
<div>
<button class={clsx('button', authModalStyles.submitButton)} onClick={() => hideModal()}>
Перейти на главную
</button>
</div>
</div>
)
}

View File

@ -1,38 +0,0 @@
import styles from './ConfirmEmail.module.scss'
import authModalStyles from './AuthModal.module.scss'
import { clsx } from 'clsx'
import { t } from '../../../utils/intl'
import { hideModal } from '../../../stores/ui'
import { onMount } from 'solid-js'
import { useRouter } from '../../../stores/router'
type ConfirmOAuthSearchParams = {
token: string
}
export const ConfirmOAuth = () => {
const { searchParams } = useRouter<ConfirmOAuthSearchParams>()
onMount(async () => {
console.debug('[confirm-oauth] params', searchParams())
const token = searchParams().token
localStorage.setItem('token', token)
window.addEventListener('mousemove', () => window.close())
window.addEventListener('keydown', () => window.close())
window.addEventListener('click', () => window.close())
})
return (
<div>
<div class={styles.title}>{t('Hooray! Welcome!')}</div>
<div class={styles.text}>
{t("You've confirmed your account")} { /* TODO: get '%username%' */ }
</div>
<div>
<button class={clsx('button', authModalStyles.submitButton)} onClick={() => hideModal()}>
{t('Back to mainpage')}
</button>
</div>
</div>
)
}

View File

@ -0,0 +1,44 @@
import styles from './AuthModal.module.scss'
import { clsx } from 'clsx'
import { t } from '../../../utils/intl'
import { hideModal } from '../../../stores/ui'
import { createMemo, onMount, Show } from 'solid-js'
import { useRouter } from '../../../stores/router'
import { confirmEmail, useAuthStore } from '../../../stores/auth'
type ConfirmEmailSearchParams = {
token: string
}
export const EmailConfirm = () => {
const { session } = useAuthStore()
const confirmedEmail = createMemo(() => session()?.user?.email || '')
const { searchParams } = useRouter<ConfirmEmailSearchParams>()
onMount(async () => {
const token = searchParams().token
try {
await confirmEmail(token)
} catch (error) {
console.log(error)
}
})
return (
<div>
<div class={styles.title}>{t('Hooray! Welcome!')}</div>
<Show when={Boolean(confirmedEmail())}>
<div class={styles.text}>
{t("You've confirmed email")} {confirmedEmail()}
</div>
</Show>
<div>
<button class={clsx('button', styles.submitButton)} onClick={() => hideModal()}>
{t('Go to main page')}
</button>
</div>
</div>
)
}

View File

@ -1,8 +1,7 @@
import { Show } from 'solid-js/web'
import { t } from '../../../utils/intl'
import styles from './AuthModal.module.scss'
import { clsx } from 'clsx'
import { createSignal, JSX } from 'solid-js'
import { createSignal, JSX, Show } from 'solid-js'
import { useRouter } from '../../../stores/router'
import { email, setEmail } from './sharedLogic'
import type { AuthModalSearchParams } from './types'
@ -64,7 +63,7 @@ export const ForgotPasswordForm = () => {
return (
<form onSubmit={handleSubmit}>
<h4>{t('Forgot password?')}</h4>
{t('Everything is ok, please give us your email address')}
<div class={styles.authSubtitle}>{t('Everything is ok, please give us your email address')}</div>
<Show when={submitError()}>
<div class={styles.authInfo}>
<ul>

View File

@ -1,11 +1,10 @@
import { Show } from 'solid-js/web'
import { t } from '../../../utils/intl'
import styles from './AuthModal.module.scss'
import { clsx } from 'clsx'
import { SocialProviders } from './SocialProviders'
import { signIn } from '../../../stores/auth'
import { signIn, signSendLink } from '../../../stores/auth'
import { ApiError } from '../../../utils/apiClient'
import { createSignal } from 'solid-js'
import { createSignal, Show } from 'solid-js'
import { isValidEmail } from './validators'
import { email, setEmail } from './sharedLogic'
import { useRouter } from '../../../stores/router'
@ -23,6 +22,9 @@ export const LoginForm = () => {
const [submitError, setSubmitError] = createSignal('')
const [isSubmitting, setIsSubmitting] = createSignal(false)
const [validationErrors, setValidationErrors] = createSignal<ValidationErrors>({})
// TODO: better solution for interactive error messages
const [isEmailNotConfirmed, setIsEmailNotConfirmed] = createSignal(false)
const [isLinkSent, setIsLinkSent] = createSignal(false)
const { changeSearchParam } = useRouter<AuthModalSearchParams>()
@ -49,6 +51,7 @@ export const LoginForm = () => {
const handleSubmit = async (event: Event) => {
event.preventDefault()
setIsLinkSent(false)
setSubmitError('')
const newValidationErrors: ValidationErrors = {}
@ -72,10 +75,12 @@ export const LoginForm = () => {
try {
await signIn({ email: email(), password: password() })
hideModal()
} catch (error) {
if (error instanceof ApiError) {
if (error.code === 'email_not_confirmed') {
setSubmitError(t('Please, confirm email'))
setIsEmailNotConfirmed(true)
return
}
@ -96,11 +101,17 @@ export const LoginForm = () => {
<h4>{t('Enter the Discours')}</h4>
<Show when={submitError()}>
<div class={styles.authInfo}>
<ul>
<li class={styles.warn}>{submitError()}</li>
</ul>
<div class={styles.warn}>{submitError()}</div>
<Show when={isEmailNotConfirmed()}>
<a href="#" class={styles.sendLink} onClick={handleSendLinkAgainClick}>
{t('Send link again')}
</a>
</Show>
</div>
</Show>
<Show when={isLinkSent()}>
<div class={styles.authInfo}>{t('Link sent, check your email')}</div>
</Show>
<div class="pretty-form__item">
<input
id="email"

View File

@ -1,16 +1,16 @@
import { Show } from 'solid-js/web'
import { Show, createSignal } from 'solid-js'
import type { JSX } from 'solid-js'
import { t } from '../../../utils/intl'
import styles from './AuthModal.module.scss'
import { clsx } from 'clsx'
import { SocialProviders } from './SocialProviders'
import { checkEmail, register, useAuthStore } from '../../../stores/auth'
import { createSignal } from 'solid-js'
import { isValidEmail } from './validators'
import { ApiError } from '../../../utils/apiClient'
import { email, setEmail } from './sharedLogic'
import { useRouter } from '../../../stores/router'
import type { AuthModalSearchParams } from './types'
import { hideModal } from '../../../stores/ui'
type FormFields = {
name: string
@ -29,6 +29,7 @@ export const RegisterForm = () => {
const [name, setName] = createSignal('')
const [password, setPassword] = createSignal('')
const [isSubmitting, setIsSubmitting] = createSignal(false)
const [isSuccess, setIsSuccess] = createSignal(false)
const [validationErrors, setValidationErrors] = createSignal<ValidationErrors>({})
const handleEmailInput = (newEmail: string) => {
@ -91,6 +92,8 @@ export const RegisterForm = () => {
email: email(),
password: password()
})
setIsSuccess(true)
} catch (error) {
if (error instanceof ApiError && error.code === 'user_already_exists') {
return
@ -103,87 +106,100 @@ export const RegisterForm = () => {
}
return (
<form onSubmit={handleSubmit}>
<h4>{t('Create account')}</h4>
<Show when={submitError()}>
<div class={styles.authInfo}>
<ul>
<li class={styles.warn}>{submitError()}</li>
</ul>
<>
<Show when={!isSuccess()}>
<form onSubmit={handleSubmit}>
<h4>{t('Create account')}</h4>
<Show when={submitError()}>
<div class={styles.authInfo}>
<ul>
<li class={styles.warn}>{submitError()}</li>
</ul>
</div>
</Show>
<div class="pretty-form__item">
<input
id="name"
name="name"
type="text"
placeholder={t('Full name')}
autocomplete=""
onInput={(event) => handleNameInput(event.currentTarget.value)}
/>
<label for="name">{t('Full name')}</label>
</div>
<Show when={validationErrors().name}>
<div class={styles.validationError}>{validationErrors().name}</div>
</Show>
<div class="pretty-form__item">
<input
id="email"
name="email"
autocomplete="email"
type="text"
value={email()}
placeholder={t('Email')}
onInput={(event) => handleEmailInput(event.currentTarget.value)}
onBlur={handleEmailBlur}
/>
<label for="email">{t('Email')}</label>
</div>
<Show when={validationErrors().email}>
<div class={styles.validationError}>{validationErrors().email}</div>
</Show>
<Show when={emailChecks()[email()]}>
<div class={styles.validationError}>
{t("This email is already taken. If it's you")},{' '}
<a
href="#"
onClick={(event) => {
event.preventDefault()
changeSearchParam('mode', 'login')
}}
>
{t('enter')}
</a>
</div>
</Show>
<div class="pretty-form__item">
<input
id="password"
name="password"
autocomplete="current-password"
type="password"
placeholder={t('Password')}
onInput={(event) => handlePasswordInput(event.currentTarget.value)}
/>
<label for="password">{t('Password')}</label>
</div>
<Show when={validationErrors().password}>
<div class={styles.validationError}>{validationErrors().password}</div>
</Show>
<div>
<button class={clsx('button', styles.submitButton)} disabled={isSubmitting()} type="submit">
{isSubmitting() ? '...' : t('Join')}
</button>
</div>
<SocialProviders />
<div class={styles.authControl}>
<span class={styles.authLink} onClick={() => changeSearchParam('mode', 'login')}>
{t('I have an account')}
</span>
</div>
</form>
</Show>
<Show when={isSuccess()}>
<div class={styles.title}>{t('Almost done! Check your email.')}</div>
<div class={styles.text}>{t("We've sent you a message with a link to enter our website.")}</div>
<div>
<button class={clsx('button', styles.submitButton)} onClick={() => hideModal()}>
{t('Back to main page')}
</button>
</div>
</Show>
<div class="pretty-form__item">
<input
id="name"
name="name"
type="text"
placeholder={t('Full name')}
autocomplete=""
onInput={(event) => handleNameInput(event.currentTarget.value)}
/>
<label for="name">{t('Full name')}</label>
</div>
<Show when={validationErrors().name}>
<div class={styles.validationError}>{validationErrors().name}</div>
</Show>
<div class="pretty-form__item">
<input
id="email"
name="email"
autocomplete="email"
type="text"
value={email()}
placeholder={t('Email')}
onInput={(event) => handleEmailInput(event.currentTarget.value)}
onBlur={handleEmailBlur}
/>
<label for="email">{t('Email')}</label>
</div>
<Show when={validationErrors().email}>
<div class={styles.validationError}>{validationErrors().email}</div>
</Show>
<Show when={emailChecks()[email()]}>
<div class={styles.validationError}>
{t("This email is already taken. If it's you")},{' '}
<a
href="#"
onClick={(event) => {
event.preventDefault()
changeSearchParam('mode', 'login')
}}
>
{t('enter')}
</a>
</div>
</Show>
<div class="pretty-form__item">
<input
id="password"
name="password"
autocomplete="current-password"
type="password"
placeholder={t('Password')}
onInput={(event) => handlePasswordInput(event.currentTarget.value)}
/>
<label for="password">{t('Password')}</label>
</div>
<Show when={validationErrors().password}>
<div class={styles.validationError}>{validationErrors().password}</div>
</Show>
<div>
<button class={clsx('button', styles.submitButton)} disabled={isSubmitting()} type="submit">
{isSubmitting() ? '...' : t('Join')}
</button>
</div>
<SocialProviders />
<div class={styles.authControl}>
<span class={styles.authLink} onClick={() => changeSearchParam('mode', 'login')}>
{t('I have an account')}
</span>
</div>
</form>
</>
)
}

View File

@ -1,5 +1,5 @@
import { Show } from 'solid-js/web'
import { createEffect, createMemo } from 'solid-js'
import { Dynamic } from 'solid-js/web'
import { Component, createEffect, createMemo } from 'solid-js'
import { t } from '../../../utils/intl'
import { hideModal } from '../../../stores/ui'
import { handleClientRouteLinkClick, useRouter } from '../../../stores/router'
@ -8,15 +8,24 @@ import styles from './AuthModal.module.scss'
import { LoginForm } from './LoginForm'
import { RegisterForm } from './RegisterForm'
import { ForgotPasswordForm } from './ForgotPasswordForm'
import { ConfirmEmail } from './ConfirmEmail'
import { EmailConfirm } from './EmailConfirm'
import type { AuthModalMode, AuthModalSearchParams } from './types'
import { ConfirmOAuth } from './ConfirmOAuth'
const AUTH_MODAL_MODES: Record<AuthModalMode, Component> = {
login: LoginForm,
register: RegisterForm,
'forgot-password': ForgotPasswordForm,
'confirm-email': EmailConfirm
}
export const AuthModal = () => {
let rootRef: HTMLDivElement
const { searchParams } = useRouter<AuthModalSearchParams>()
const mode = createMemo<AuthModalMode>(() => searchParams().mode || 'login')
const mode = createMemo<AuthModalMode>(() => {
return AUTH_MODAL_MODES[searchParams().mode] ? searchParams().mode : 'login'
})
createEffect((oldMode) => {
if (oldMode !== mode()) {
@ -28,12 +37,12 @@ export const AuthModal = () => {
<div
ref={rootRef}
class={clsx('row', styles.view)}
classList={{ [styles.signUp]: mode() === 'register' || mode().startsWith('confirm-') }}
classList={{ [styles.signUp]: mode() === 'register' || mode() === 'confirm-email' }}
>
<div class={clsx('col-sm-6', 'd-md-none', styles.authImage)}>
<div
class={styles.authImageText}
classList={{ [styles.hidden]: mode() !== 'register' && !mode().startsWith('confirm-') }}
classList={{ [styles.hidden]: mode() !== 'register' && mode() !== 'confirm-email' }}
>
<h2>{t('Discours')}</h2>
<h4>{t(`Join the global community of authors!`)}</h4>
@ -60,21 +69,7 @@ export const AuthModal = () => {
</div>
</div>
<div class={clsx('col-sm-6', styles.auth)}>
<Show when={mode() === 'login'}>
<LoginForm />
</Show>
<Show when={mode() === 'register'}>
<RegisterForm />
</Show>
<Show when={mode() === 'forgot-password'}>
<ForgotPasswordForm />
</Show>
<Show when={mode() === 'confirm-email'}>
<ConfirmEmail />
</Show>
<Show when={mode() === 'confirm-oauth'}>
<ConfirmOAuth />
</Show>
<Dynamic component={AUTH_MODAL_MODES[mode()]} />
</div>
</div>
)

View File

@ -1,4 +1,4 @@
export type AuthModalMode = 'login' | 'register' | 'confirm-email' | 'confirm-oauth' | 'forgot-password'
export type AuthModalMode = 'login' | 'register' | 'confirm-email' | 'forgot-password'
export type AuthModalSearchParams = {
mode: AuthModalMode

View File

@ -0,0 +1,6 @@
.center {
display: flex;
justify-content: center;
align-items: center;
height: 420px;
}

View File

@ -0,0 +1,18 @@
import './Confirmed.scss'
import { onMount } from 'solid-js'
import { t } from '../../utils/intl'
export const Confirmed = (props: { token?: string }) => {
onMount(() => {
const token = props.token ?? document.cookie.split(';').at(0).replace('token=', '')
window.addEventListener('mousemove', () => window.close())
window.addEventListener('keydown', () => window.close())
window.addEventListener('click', () => window.close())
localStorage.setItem('token', token)
})
return (
<>
<div class="center">{t('You was successfully authorized')}</div>
</>
)
}

View File

@ -35,18 +35,6 @@
}
}
.popupShare {
opacity: 1;
transition: opacity 0.3s;
z-index: 1;
.headerScrolledTop & {
opacity: 0;
transition: opacity 0.3s, z-index 0s 0.3s;
z-index: -1;
}
}
.headerFixed {
position: fixed;
top: 0;
@ -327,18 +315,6 @@
}
}
.userControl {
opacity: 1;
transition: opacity 0.3s;
z-index: 1;
.headerWithTitle.headerScrolledBottom & {
transition: opacity 0.3s, z-index 0s 0.3s;
opacity: 0;
z-index: -1;
}
}
.articleControls {
display: flex;
justify-content: flex-end;
@ -348,18 +324,14 @@
transform: translateY(-50%);
width: 100%;
.icon {
margin-left: 1.6rem;
opacity: 0.6;
transition: opacity 0.3s;
}
.control {
cursor: pointer;
border: 0;
img {
vertical-align: middle;
}
a {
border: none;
.icon {
opacity: 0.6;
transition: opacity 0.3s;
}
&:hover {
background: none;
@ -370,4 +342,141 @@
}
}
}
.control + .control {
margin-left: 1.6rem;
}
img {
vertical-align: middle;
}
}
.userControl {
align-items: baseline;
display: flex;
opacity: 1;
transition: opacity 0.3s;
z-index: 1;
.headerWithTitle.headerScrolledBottom & {
transition: opacity 0.3s, z-index 0s 0.3s;
opacity: 0;
z-index: -1;
}
@include font-size(1.7rem);
justify-content: flex-end;
@include media-breakpoint-down(md) {
padding: divide($container-padding-x, 2);
}
.userpic {
margin-right: 0;
img {
height: 100%;
width: 100%;
}
}
}
.userControlItem {
align-items: center;
border: 2px solid #f6f6f6;
border-radius: 100%;
display: flex;
height: 2.4em;
justify-content: center;
margin-left: divide($container-padding-x, 2);
position: relative;
width: 2.4em;
@include media-breakpoint-up(sm) {
margin-left: 1.2rem;
}
.circlewrap {
height: 23px;
min-width: 23px;
width: 23px;
}
.button,
a {
border: none;
height: auto;
margin: 0;
padding: 0;
&:hover {
background: none;
&::before {
background-color: #000;
}
img {
filter: invert(1);
}
}
img {
filter: invert(0);
transition: filter 0.3s;
}
&::before {
background-color: #fff;
border-radius: 100%;
content: '';
height: 100%;
left: 0;
position: absolute;
top: 0;
transition: background-color 0.3s;
width: 100%;
}
}
img {
height: 20px;
vertical-align: middle;
width: auto;
}
.textLabel {
display: none;
}
}
.userControlItemInbox,
.userControlItemSearch {
@include media-breakpoint-down(sm) {
display: none;
}
}
.userControlItemWritePost {
width: auto;
@include media-breakpoint-up(lg) {
.icon {
display: none;
}
.textLabel {
display: inline;
padding: 0 1.2rem;
position: relative;
z-index: 1;
}
}
&,
a::before {
border-radius: 1.2em;
}
}

View File

@ -1,19 +1,22 @@
import { For, Show, createSignal, createMemo, createEffect, onMount, onCleanup } from 'solid-js'
import Private from './Private'
import Notifications from './Notifications'
import { Icon } from './Icon'
import { Modal } from './Modal'
import { Popup } from './Popup'
import { AuthModal } from './AuthModal'
import { t } from '../../utils/intl'
import { useModalStore, showModal, useWarningsStore } from '../../stores/ui'
import { useAuthStore } from '../../stores/auth'
import { handleClientRouteLinkClick, router, Routes, useRouter } from '../../stores/router'
import styles from './Header.module.scss'
import stylesPopup from './Popup.module.scss'
import privateStyles from './Private.module.scss'
import { getPagePath } from '@nanostores/router'
import { getLogger } from '../../utils/logger'
import { clsx } from 'clsx'
import { SharePopup } from '../Article/SharePopup'
import { ProfilePopup } from './ProfilePopup'
import Userpic from '../Author/Userpic'
import type { Author } from '../../graphql/types.gen'
const log = getLogger('header')
const resources: { name: string; route: keyof Routes }[] = [
{ name: t('zine'), route: 'home' },
@ -32,6 +35,9 @@ export const Header = (props: Props) => {
const [getIsScrolled, setIsScrolled] = createSignal(false)
const [fixed, setFixed] = createSignal(false)
const [visibleWarnings, setVisibleWarnings] = createSignal(false)
const [isSharePopupVisible, setIsSharePopupVisible] = createSignal(false)
const [isProfilePopupVisible, setIsProfilePopupVisible] = createSignal(false)
// stores
const { warnings } = useWarningsStore()
const { session } = useAuthStore()
@ -41,13 +47,11 @@ export const Header = (props: Props) => {
// methods
const toggleWarnings = () => setVisibleWarnings(!visibleWarnings())
const toggleFixed = () => setFixed(!fixed())
const toggleFixed = () => setFixed((oldFixed) => !oldFixed)
// effects
createEffect(() => {
const isFixed = fixed() || (modal() && modal() !== 'share');
document.body.classList.toggle('fixed', isFixed);
document.body.classList.toggle(styles.fixed, isFixed && !modal());
document.body.classList.toggle('fixed', fixed() || modal() !== null)
document.body.classList.toggle(styles.fixed, fixed() && !modal())
})
// derived
@ -85,7 +89,8 @@ export const Header = (props: Props) => {
classList={{
[styles.headerFixed]: props.isHeaderFixed,
[styles.headerScrolledTop]: !getIsScrollingBottom() && getIsScrolled(),
[styles.headerScrolledBottom]: getIsScrollingBottom() && getIsScrolled(),
[styles.headerScrolledBottom]:
(getIsScrollingBottom() && getIsScrolled() && !isProfilePopupVisible()) || isSharePopupVisible(),
[styles.headerWithTitle]: Boolean(props.title)
}}
>
@ -94,41 +99,6 @@ export const Header = (props: Props) => {
</Modal>
<div class={clsx(styles.mainHeaderInner, 'wide-container')}>
<Popup name="share" class={clsx(styles.popupShare, stylesPopup.popupShare)}>
<ul class="nodash">
<li>
<a href="#">
<Icon name="vk-white" class={stylesPopup.icon} />
VK
</a>
</li>
<li>
<a href="#">
<Icon name="facebook-white" class={stylesPopup.icon} />
Facebook
</a>
</li>
<li>
<a href="#">
<Icon name="twitter-white" class={stylesPopup.icon} />
Twitter
</a>
</li>
<li>
<a href="#">
<Icon name="telegram-white" class={stylesPopup.icon} />
Telegram
</a>
</li>
<li>
<a href="#">
<Icon name="link-white" class={stylesPopup.icon} />
{t('Copy link')}
</a>
</li>
</ul>
</Popup>
<nav class={clsx(styles.headerInner, 'row')} classList={{ fixed: fixed() }}>
<div class={clsx(styles.mainLogo, 'col-auto')}>
<a href={getPagePath(router, 'home')} onClick={handleClientRouteLinkClick}>
@ -162,8 +132,15 @@ export const Header = (props: Props) => {
</ul>
</div>
<div class={styles.usernav}>
<div class={clsx(privateStyles.userControl, styles.userControl, 'col')}>
<div class={privateStyles.userControlItem}>
<div class={clsx(styles.userControl, styles.userControl, 'col')}>
<div class={clsx(styles.userControlItem, styles.userControlItemWritePost)}>
<a href="/create">
<span class={styles.textLabel}>{t('Create post')}</span>
<Icon name="pencil" class={styles.icon} />
</a>
</div>
<div class={styles.userControlItem}>
<a href="#" onClick={handleBellIconClick}>
<div>
<Icon name="bell-white" counter={authorized() ? warnings().length : 1} />
@ -172,7 +149,7 @@ export const Header = (props: Props) => {
</div>
<Show when={visibleWarnings()}>
<div class={clsx(privateStyles.userControlItem, 'notifications')}>
<div class={clsx(styles.userControlItem, 'notifications')}>
<Notifications />
</div>
</Show>
@ -180,31 +157,56 @@ export const Header = (props: Props) => {
<Show
when={authorized()}
fallback={
<div class={clsx(privateStyles.userControlItem, 'loginbtn')}>
<div class={clsx(styles.userControlItem, 'loginbtn')}>
<a href="?modal=auth&mode=login" onClick={handleClientRouteLinkClick}>
<Icon name="user-anonymous" />
</a>
</div>
}
>
<Private />
<div class={clsx(styles.userControlItem, styles.userControlItemInbox)}>
<a href="/inbox">
{/*FIXME: replace with route*/}
<div classList={{ entered: page().path === '/inbox' }}>
<Icon name="inbox-white" counter={session()?.news?.unread || 0} />
</div>
</a>
</div>
<ProfilePopup
onVisibilityChange={(isVisible) => {
setIsProfilePopupVisible(isVisible)
}}
containerCssClass={styles.control}
trigger={
<div class={styles.userControlItem}>
<button class={styles.button}>
<div classList={{ entered: page().path === `/${session().user?.slug}` }}>
<Userpic user={session().user as Author} class={styles.userpic} />
</div>
</button>
</div>
}
/>
</Show>
</div>
<Show when={props.title}>
<div class={styles.articleControls}>
<button
onClick={() => {
// FIXME: Popup
showModal('share')
<SharePopup
onVisibilityChange={(isVisible) => {
setIsSharePopupVisible(isVisible)
}}
>
<Icon name="share-outline" class={styles.icon} />
</button>
<a href="#comments">
containerCssClass={styles.control}
trigger={<Icon name="share-outline" class={styles.icon} />}
/>
<a href="#comments" class={styles.control}>
<Icon name="comments-outline" class={styles.icon} />
</a>
<Icon name="pencil-outline" class={styles.icon} />
<Icon name="bookmark" class={styles.icon} />
<a href="#" class={styles.control} onClick={(event) => event.preventDefault()}>
<Icon name="pencil-outline" class={styles.icon} />
</a>
<a href="#" class={styles.control} onClick={(event) => event.preventDefault()}>
<Icon name="bookmark" class={styles.icon} />
</a>
</div>
</Show>
</div>

View File

@ -1,4 +1,5 @@
.icon {
line-height: 1;
position: relative;
}

View File

@ -1,8 +1,11 @@
import { createEffect, createSignal, onCleanup, onMount, Show } from 'solid-js'
import type { JSX } from 'solid-js'
import { getLogger } from '../../utils/logger'
import './Modal.scss'
import { hideModal, useModalStore } from '../../stores/ui'
const log = getLogger('modal')
interface ModalProps {
name: string
children: JSX.Element
@ -31,7 +34,7 @@ export const Modal = (props: ModalProps) => {
createEffect(() => {
setVisible(modal() === props.name)
console.debug(`[auth.modal] ${props.name} is ${modal() === props.name ? 'visible' : 'hidden'}`)
log.debug(`${props.name} is ${modal() === props.name ? 'visible' : 'hidden'}`)
})
return (

View File

@ -1,6 +1,6 @@
import { For, Portal, Show } from 'solid-js/web'
import { Portal } from 'solid-js/web'
import { useWarningsStore } from '../../stores/ui'
import { createMemo } from 'solid-js'
import { createMemo, For, Show } from 'solid-js'
export default () => {
const { warnings } = useWarningsStore()

View File

@ -1,10 +1,25 @@
.container {
position: relative;
}
.popup {
background: #fff;
border: 2px solid #000;
top: calc(100% + 8px);
opacity: 1;
&.horizontalAnchorCenter {
left: 50%;
transform: translateX(-50%);
}
&.horizontalAnchorRight {
right: 0;
}
@include font-size(1.6rem);
padding: 2.4rem 2.4rem 2.4rem 1.6rem;
padding: 2.4rem;
position: absolute;
z-index: 10;
@ -14,7 +29,6 @@
li {
margin-bottom: 1.6rem;
padding-left: 3.6rem;
position: relative;
&:last-child {
@ -24,23 +38,36 @@
a {
border: none;
white-space: nowrap;
&:hover {
img {
filter: invert(0);
}
}
}
img {
filter: invert(1);
max-height: 2rem;
max-width: 2rem;
transition: filter 0.3s;
}
.icon {
left: 1.5rem;
position: absolute;
top: 50%;
transform: translate(-50%, -50%);
display: inline-block;
width: 3.6rem;
}
}
.popupShare {
right: 1em;
top: 4.5rem;
}
// TODO: animation
// .popup {
// opacity: 1;
// transition: opacity 0.3s;
// z-index: 1;
// &.visible {
// opacity: 0;
// transition: opacity 0.3s, z-index 0s 0.3s;
// z-index: -1;
// }
// }

View File

@ -1,31 +1,61 @@
import { createEffect, createSignal, JSX, onMount, Show } from 'solid-js'
import style from './Popup.module.scss'
import { hideModal, useModalStore } from '../../stores/ui'
import { createEffect, createSignal, JSX, onCleanup, onMount, Show } from 'solid-js'
import styles from './Popup.module.scss'
import { clsx } from 'clsx'
interface PopupProps {
name: string
type HorizontalAnchor = 'center' | 'right'
export type PopupProps = {
containerCssClass?: string
trigger: JSX.Element
children: JSX.Element
class?: string
onVisibilityChange?: (isVisible) => void
horizontalAnchor?: HorizontalAnchor
}
export const Popup = (props: PopupProps) => {
const { modal } = useModalStore()
const [isVisible, setIsVisible] = createSignal(false)
const horizontalAnchor: HorizontalAnchor = props.horizontalAnchor || 'center'
createEffect(() => {
if (props.onVisibilityChange) {
props.onVisibilityChange(isVisible())
}
})
let container: HTMLDivElement | undefined
const handleClickOutside = (event: MouseEvent & { target: Element }) => {
if (!isVisible()) {
return
}
if (event.target === container || container?.contains(event.target)) {
return
}
setIsVisible(false)
}
onMount(() => {
window.addEventListener('keydown', (e: KeyboardEvent) => {
if (e.key === 'Escape') hideModal()
})
document.addEventListener('click', handleClickOutside, { capture: true })
onCleanup(() => document.removeEventListener('click', handleClickOutside, { capture: true }))
})
const [visible, setVisible] = createSignal(false)
createEffect(() => {
setVisible(modal() === props.name)
})
const toggle = () => setIsVisible((oldVisible) => !oldVisible)
return (
<Show when={visible()}>
<div class={clsx(style.popup, props.class)}>{props.children}</div>
</Show>
<span class={clsx(styles.container, props.containerCssClass)} ref={container}>
<span onClick={toggle}>{props.trigger}</span>
<Show when={isVisible()}>
<div
class={clsx(styles.popup, {
[styles.horizontalAnchorCenter]: horizontalAnchor === 'center',
[styles.horizontalAnchorRight]: horizontalAnchor === 'right'
})}
>
{props.children}
</div>
</Show>
</span>
)
}

View File

@ -1,100 +0,0 @@
.userControl {
align-items: baseline;
display: flex;
@include font-size(1.7rem);
justify-content: flex-end;
@include media-breakpoint-down(md) {
padding: divide($container-padding-x, 2);
}
.circlewrap {
margin-right: 0;
}
}
.userControlItem {
align-items: center;
border: 2px solid #f6f6f6;
border-radius: 100%;
display: flex;
height: 2.4em;
justify-content: center;
margin-left: divide($container-padding-x, 2);
position: relative;
width: 2.4em;
@include media-breakpoint-up(sm) {
margin-left: 1.2rem;
}
.circlewrap {
height: 23px;
min-width: 23px;
width: 23px;
}
a {
border: none;
&:hover {
background: none;
&::before {
background-color: #000;
}
img {
filter: invert(1);
}
}
img {
filter: invert(0);
transition: filter 0.3s;
}
&::before {
background-color: #fff;
border-radius: 100%;
content: '';
height: 100%;
left: 0;
position: absolute;
top: 0;
transition: background-color 0.3s;
width: 100%;
}
}
img {
height: 20px;
vertical-align: middle;
width: auto;
}
.textLabel {
display: none;
}
}
.userControlItemWritePost {
@include media-breakpoint-up(lg) {
.icon {
display: none;
}
.textLabel {
display: inline;
}
}
}
.userControlItemInbox,
.userControlItemSearch {
@include media-breakpoint-down(sm) {
display: none;
}
}

View File

@ -1,39 +0,0 @@
import type { Author } from '../../graphql/types.gen'
import Userpic from '../Author/Userpic'
import { Icon } from './Icon'
import styles from './Private.module.scss'
import { useAuthStore } from '../../stores/auth'
import { useRouter } from '../../stores/router'
import { clsx } from 'clsx'
export default () => {
const { session } = useAuthStore()
const { page } = useRouter()
return (
<div class={clsx(styles.userControl, 'col')}>
<div class={clsx(styles.userControlItem, styles.userControlItemWritePost)}>
<a href="/create">
<span class={styles.textLabel}>опубликовать материал</span>
<Icon name="pencil" />
</a>
</div>
<div class={clsx(styles.userControlItem, styles.userControlItemInbox)}>
<a href="/inbox">
{/*FIXME: replace with route*/}
<div classList={{ entered: page().path === '/inbox' }}>
<Icon name="inbox-white" counter={session()?.news?.unread || 0} />
</div>
</a>
</div>
<div class={styles.userControlItem}>
<a href={`/${session().user?.slug}`}>
{/*FIXME: replace with route*/}
<div classList={{ entered: page().path === `/${session().user?.slug}` }}>
<Userpic user={session().user as Author} />
</div>
</a>
</div>
</div>
)
}

View File

@ -1,10 +1,9 @@
import { For } from 'solid-js/web'
import { AuthorCard } from '../Author/Card'
import type { Author } from '../../graphql/types.gen'
import { t } from '../../utils/intl'
import { hideModal } from '../../stores/ui'
import { useAuthStore, signOut } from '../../stores/auth'
import { createMemo } from 'solid-js'
import { createMemo, For } from 'solid-js'
const quit = () => {
signOut()

View File

@ -0,0 +1,44 @@
import { Popup, PopupProps } from './Popup'
import { signOut, useAuthStore } from '../../stores/auth'
type ProfilePopupProps = Omit<PopupProps, 'children'>
export const ProfilePopup = (props: ProfilePopupProps) => {
const { session } = useAuthStore()
return (
<Popup {...props} horizontalAnchor="right">
<ul class="nodash">
<li>
<a href={`/${session().user?.slug}`}>Профиль</a>
</li>
<li>
<a href="#">Черновики</a>
</li>
<li>
<a href="#">Подписки</a>
</li>
<li>
<a href="#">Комментарии</a>
</li>
<li>
<a href="#">Закладки</a>
</li>
<li>
<a href="#">Настройки</a>
</li>
<li>
<a
href="#"
onClick={(event) => {
event.preventDefault()
signOut()
}}
>
Выйти из&nbsp;аккаунта
</a>
</li>
</ul>
</Popup>
)
}

View File

@ -1,8 +1,8 @@
import { MainLayout } from '../Layouts/MainLayout'
import { AuthorView } from '../Views/Author'
import { AuthorView, PRERENDERED_ARTICLES_COUNT } from '../Views/Author'
import type { PageProps } from '../types'
import { createMemo, createSignal, onCleanup, onMount, Show } from 'solid-js'
import { loadArticlesForAuthors, resetSortedArticles } from '../../stores/zine/articles'
import { loadAuthorArticles, resetSortedArticles } from '../../stores/zine/articles'
import { useRouter } from '../../stores/router'
import { loadAuthor } from '../../stores/zine/authors'
import { Loading } from '../Loading'
@ -27,7 +27,7 @@ export const AuthorPage = (props: PageProps) => {
return
}
await loadArticlesForAuthors({ authorSlugs: [slug()] })
await loadAuthorArticles({ authorSlug: slug(), limit: PRERENDERED_ARTICLES_COUNT })
await loadAuthor({ slug: slug() })
setIsLoaded(true)

View File

@ -1,30 +1,14 @@
import { MainLayout } from '../Layouts/MainLayout'
import { FeedView } from '../Views/Feed'
import type { PageProps } from '../types'
import { createSignal, onCleanup, onMount, Show } from 'solid-js'
import { loadRecentArticles, resetSortedArticles } from '../../stores/zine/articles'
import { Loading } from '../Loading'
export const FeedPage = (props: PageProps) => {
const [isLoaded, setIsLoaded] = createSignal(Boolean(props.feedArticles))
onMount(async () => {
if (isLoaded()) {
return
}
await loadRecentArticles({ limit: 50, offset: 0 })
setIsLoaded(true)
})
import { onCleanup } from 'solid-js'
import { resetSortedArticles } from '../../stores/zine/articles'
export const FeedPage = () => {
onCleanup(() => resetSortedArticles())
return (
<MainLayout>
<Show when={isLoaded()} fallback={<Loading />}>
<FeedView articles={props.feedArticles} />
</Show>
<FeedView />
</MainLayout>
)
}

View File

@ -1,4 +1,4 @@
import { HomeView } from '../Views/Home'
import { HomeView, PRERENDERED_ARTICLES_COUNT } from '../Views/Home'
import { MainLayout } from '../Layouts/MainLayout'
import type { PageProps } from '../types'
import { createSignal, onCleanup, onMount, Show } from 'solid-js'
@ -14,7 +14,7 @@ export const HomePage = (props: PageProps) => {
return
}
await loadPublishedArticles({ limit: 5, offset: 0 })
await loadPublishedArticles({ limit: PRERENDERED_ARTICLES_COUNT, offset: 0 })
await loadRandomTopics()
setIsLoaded(true)

View File

@ -1,8 +1,8 @@
import { MainLayout } from '../Layouts/MainLayout'
import { TopicView } from '../Views/Topic'
import { PRERENDERED_ARTICLES_COUNT, TopicView } from '../Views/Topic'
import type { PageProps } from '../types'
import { createMemo, createSignal, onCleanup, onMount, Show } from 'solid-js'
import { loadArticlesForTopics, resetSortedArticles } from '../../stores/zine/articles'
import { loadTopicArticles, resetSortedArticles } from '../../stores/zine/articles'
import { useRouter } from '../../stores/router'
import { loadTopic } from '../../stores/zine/topics'
import { Loading } from '../Loading'
@ -27,7 +27,7 @@ export const TopicPage = (props: PageProps) => {
return
}
await loadArticlesForTopics({ topicSlugs: [slug()] })
await loadTopicArticles({ topicSlug: slug(), limit: PRERENDERED_ARTICLES_COUNT, offset: 0 })
await loadTopic({ slug: slug() })
setIsLoaded(true)

View File

@ -54,28 +54,29 @@ export const ManifestPage = () => {
<div class="col-lg-10 offset-md-1">
<p>
Дискурс&nbsp;&mdash; независимый художественно-аналитический журнал с&nbsp;горизонтальной редакцией,
основанный на&nbsp;принципах свободы слова, прямой демократии и&nbsp;совместного редактирования.
Дискурс создаётся открытым медиасообществом ученых, журналистов, музыкантов, писателей,
предпринимателей, философов, инженеров, художников и&nbsp;специалистов со&nbsp;всего мира,
объединившихся, чтобы вместе делать общий журнал и&nbsp;объяснять с&nbsp;разных точек
зрения мозаичную картину современности.
Дискурс&nbsp;&mdash; независимый художественно-аналитический журнал с&nbsp;горизонтальной
редакцией, основанный на&nbsp;принципах свободы слова, прямой демократии и&nbsp;совместного
редактирования. Дискурс создаётся открытым медиасообществом ученых, журналистов, музыкантов,
писателей, предпринимателей, философов, инженеров, художников и&nbsp;специалистов
со&nbsp;всего мира, объединившихся, чтобы вместе делать общий журнал и&nbsp;объяснять
с&nbsp;разных точек зрения мозаичную картину современности.
</p>
<p>
Мы&nbsp;пишем о&nbsp;культуре, науке и&nbsp;обществе, рассказываем о&nbsp;новых идеях и&nbsp;современном искусстве,
публикуем статьи, исследования, репортажи, интервью людей, чью прямую речь стоит услышать,
и&nbsp;работы художников из&nbsp;разных стран&nbsp;&mdash; от&nbsp;фильмов и&nbsp;музыки
до&nbsp;живописи и&nbsp;фотографии. Помогая друг другу делать публикации качественнее
и&nbsp;общим голосованием выбирая лучшие материалы для журнала, мы&nbsp;создаём новую
горизонтальную журналистику, чтобы честно рассказывать о&nbsp;важном и&nbsp;интересном.
Мы&nbsp;пишем о&nbsp;культуре, науке и&nbsp;обществе, рассказываем о&nbsp;новых идеях
и&nbsp;современном искусстве, публикуем статьи, исследования, репортажи, интервью людей, чью
прямую речь стоит услышать, и&nbsp;работы художников из&nbsp;разных стран&nbsp;&mdash;
от&nbsp;фильмов и&nbsp;музыки до&nbsp;живописи и&nbsp;фотографии. Помогая друг другу делать
публикации качественнее и&nbsp;общим голосованием выбирая лучшие материалы для журнала,
мы&nbsp;создаём новую горизонтальную журналистику, чтобы честно рассказывать о&nbsp;важном
и&nbsp;интересном.
</p>
<p>
Редакция Дискурса открыта для всех: у&nbsp;нас нет цензуры, запретных тем и&nbsp;идеологических рамок.
Каждый может <a href="/create">прислать материал</a> в&nbsp;журнал
и&nbsp;<a href="/about/guide">присоединиться к&nbsp;редакции</a>. Предоставляя трибуну
для независимой журналистики и&nbsp;художественных проектов, мы&nbsp;помогаем людям
рассказывать свои истории так, чтобы они были услышаны. Мы&nbsp;убеждены: чем больше
голосов будет звучать на&nbsp;Дискурсе, тем громче в&nbsp;полифонии мнений будет слышна истина.
Редакция Дискурса открыта для всех: у&nbsp;нас нет цензуры, запретных тем
и&nbsp;идеологических рамок. Каждый может <a href="/create">прислать материал</a>{' '}
в&nbsp;журнал и&nbsp;<a href="/about/guide">присоединиться к&nbsp;редакции</a>. Предоставляя
трибуну для независимой журналистики и&nbsp;художественных проектов, мы&nbsp;помогаем людям
рассказывать свои истории так, чтобы они были услышаны. Мы&nbsp;убеждены: чем больше голосов
будет звучать на&nbsp;Дискурсе, тем громче в&nbsp;полифонии мнений будет слышна истина.
</p>
</div>
@ -91,23 +92,26 @@ export const ManifestPage = () => {
</p>
<h3 id="contribute">Предлагать материалы</h3>
<p>
<a href="/create">Создавайте</a> свои статьи и&nbsp;художественные работы&nbsp;&mdash; лучшие из них будут
опубликованы в&nbsp;журнале. Дискурс&nbsp;&mdash; некоммерческое издание, авторы публикуются
в&nbsp;журнале на&nbsp;общественных началах, получая при этом <a href="/create?collab=true">поддержку</a> редакции,
право голоса, множество других возможностей и&nbsp;читателей по&nbsp;всему миру.
<a href="/create">Создавайте</a> свои статьи и&nbsp;художественные работы&nbsp;&mdash;
лучшие из них будут опубликованы в&nbsp;журнале. Дискурс&nbsp;&mdash; некоммерческое
издание, авторы публикуются в&nbsp;журнале на&nbsp;общественных началах, получая при этом{' '}
<a href="/create?collab=true">поддержку</a> редакции, право голоса, множество других
возможностей и&nbsp;читателей по&nbsp;всему миру.
</p>
<h3 id="donate">Поддерживать проект</h3>
<p>Дискурс существует на&nbsp;пожертвования читателей. Если вам нравится журнал, пожалуйста,</p>
<p>
<a href="/about/help">поддержите</a> нашу работу. Ваши пожертвования пойдут на&nbsp;выпуск новых
материалов, оплату серверов, труда программистов, дизайнеров и&nbsp;редакторов.
Дискурс существует на&nbsp;пожертвования читателей. Если вам нравится журнал, пожалуйста,
</p>
<p>
<a href="/about/help">поддержите</a> нашу работу. Ваши пожертвования пойдут на&nbsp;выпуск
новых материалов, оплату серверов, труда программистов, дизайнеров и&nbsp;редакторов.
</p>
<h3 id="cooperation">Сотрудничать с&nbsp;журналом</h3>
<p>
Мы всегда открыты для сотрудничества и&nbsp;рады единомышленникам. Если вы хотите помогать
журналу с&nbsp;редактурой, корректурой, иллюстрациями, переводами, версткой, подкастами,
мероприятиями, фандрайзингом или как-то ещё&nbsp;&mdash; скорее пишите нам
на&nbsp;<a href="mailto:welcome@discours.io">welcome@discours.io</a>.
мероприятиями, фандрайзингом или как-то ещё&nbsp;&mdash; скорее пишите нам на&nbsp;
<a href="mailto:welcome@discours.io">welcome@discours.io</a>.
</p>
<p>
Если вы представляете некоммерческую организацию и&nbsp;хотите сделать с&nbsp;нами
@ -116,25 +120,26 @@ export const ManifestPage = () => {
</p>
<p>
Если вы разработчик и&nbsp;хотите помогать с&nbsp;развитием сайта Дискурса,{' '}
<a href="mailto:services@discours.io">присоединяйтесь к&nbsp;IT-команде самиздата</a>. Открытый
код платформы для независимой журналистики, а&nbsp;также всех наших спецпроектов
и&nbsp;медиаинструментов находится <a href="https://github.com/Discours">в&nbsp;свободном доступе на&nbsp;GitHub</a>.
<a href="mailto:services@discours.io">присоединяйтесь к&nbsp;IT-команде самиздата</a>.
Открытый код платформы для независимой журналистики, а&nbsp;также всех наших спецпроектов
и&nbsp;медиаинструментов находится{' '}
<a href="https://github.com/Discours">в&nbsp;свободном доступе на&nbsp;GitHub</a>.
</p>
<h3 id="follow">Как еще можно помочь</h3>
<p>
Советуйте Дискурс друзьям и&nbsp;знакомым. Обсуждайте и&nbsp;распространяйте наши
публикации&nbsp;&mdash; все материалы открытой редакции можно читать и&nbsp;перепечатывать
бесплатно. Подпишитесь на&nbsp;самиздат{' '}
<a href="https://vk.com/discoursio">ВКонтакте</a>,
бесплатно. Подпишитесь на&nbsp;самиздат <a href="https://vk.com/discoursio">ВКонтакте</a>,
в&nbsp;<a href="https://facebook.com/discoursio">Фейсбуке</a>
и&nbsp;в&nbsp;<a href="https://t.me/discoursio">Телеграме</a>, а&nbsp;также
на&nbsp;<Opener name="subscribe">рассылку лучших материалов</Opener>,
чтобы не&nbsp;пропустить ничего интересного.
и&nbsp;в&nbsp;<a href="https://t.me/discoursio">Телеграме</a>, а&nbsp;также на&nbsp;
<Opener name="subscribe">рассылку лучших материалов</Opener>, чтобы не&nbsp;пропустить
ничего интересного.
</p>
<p>
<a href="https://forms.gle/9UnHBAz9Q3tjH5dAA">Рассказывайте о&nbsp;впечатлениях</a>
от&nbsp;материалов открытой редакции, <Opener name="feedback">делитесь идеями</Opener>,
интересными темами, о&nbsp;которых хотели бы узнать больше, и&nbsp;историями, которые нужно рассказать.
интересными темами, о&nbsp;которых хотели бы узнать больше, и&nbsp;историями, которые нужно
рассказать.
</p>
</div>
@ -145,9 +150,9 @@ export const ManifestPage = () => {
<div class="col-lg-10 offset-md-1">
Если вы хотите предложить материал, сотрудничать, рассказать о&nbsp;проблеме, которую нужно
осветить, сообщить об&nbsp;ошибке или баге, что-то обсудить, уточнить или посоветовать,
пожалуйста, <Opener name="feedback">напишите нам здесь</Opener> или
на&nbsp;почту <a href="mailto:welcome@discours.io">welcome@discours.io</a>. Мы обязательно
ответим и&nbsp;постараемся реализовать все хорошие задумки.
пожалуйста, <Opener name="feedback">напишите нам здесь</Opener> или на&nbsp;почту{' '}
<a href="mailto:welcome@discours.io">welcome@discours.io</a>. Мы обязательно ответим
и&nbsp;постараемся реализовать все хорошие задумки.
</div>
</div>
</div>

View File

@ -1,10 +1,11 @@
// FIXME: breaks on vercel, research
// import 'solid-devtools'
import { hideModal, MODALS, setLocale, showModal } from '../stores/ui'
import { Component, createEffect, createMemo } from 'solid-js'
import { MODALS, setLocale, showModal } from '../stores/ui'
import { Component, createEffect, createMemo, onMount } from 'solid-js'
import { Routes, useRouter } from '../stores/router'
import { Dynamic, isServer } from 'solid-js/web'
import { getLogger } from '../utils/logger'
import type { PageProps } from './types'
@ -26,6 +27,7 @@ import { ProjectsPage } from './Pages/about/ProjectsPage'
import { TermsOfUsePage } from './Pages/about/TermsOfUsePage'
import { ThanksPage } from './Pages/about/ThanksPage'
import { CreatePage } from './Pages/CreatePage'
import { renewSession } from '../stores/auth'
// TODO: lazy load
// const HomePage = lazy(() => import('./Pages/HomePage'))
@ -47,6 +49,7 @@ import { CreatePage } from './Pages/CreatePage'
// const ThanksPage = lazy(() => import('./Pages/about/ThanksPage'))
// const CreatePage = lazy(() => import('./Pages/about/CreatePage'))
const log = getLogger('root')
type RootSearchParams = {
modal: string
@ -83,6 +86,10 @@ export const Root = (props: PageProps) => {
}
})
onMount(() => {
renewSession()
})
const pageComponent = createMemo(() => {
const result = pagesMap[page().route]

View File

@ -1,13 +1,16 @@
import { capitalize, plural } from '../../utils'
import { Show } from 'solid-js/web'
import style from './Card.module.scss'
import { createMemo } from 'solid-js'
import { createMemo, Show } from 'solid-js'
import type { Topic } from '../../graphql/types.gen'
import { FollowingEntity } from '../../graphql/types.gen'
import { t } from '../../utils/intl'
import { locale } from '../../stores/ui'
import { useAuthStore } from '../../stores/auth'
import { follow, unfollow } from '../../stores/zine/common'
import { getLogger } from '../../utils/logger'
const log = getLogger('TopicCard')
interface TopicProps {
topic: Topic
compact?: boolean

View File

@ -1,5 +1,4 @@
import { createMemo } from 'solid-js'
import { Show } from 'solid-js/web'
import { createMemo, Show } from 'solid-js'
import type { Topic } from '../../graphql/types.gen'
import { FollowingEntity } from '../../graphql/types.gen'
import './Full.scss'

View File

@ -1,17 +1,18 @@
import { Show, createMemo } from 'solid-js'
import { Show, createMemo, createSignal, For, onMount } from 'solid-js'
import type { Author, Shout } from '../../graphql/types.gen'
import Row2 from '../Feed/Row2'
import Row3 from '../Feed/Row3'
// import Beside from '../Feed/Beside'
import AuthorFull from '../Author/Full'
import { Row2 } from '../Feed/Row2'
import { Row3 } from '../Feed/Row3'
import { AuthorFull } from '../Author/Full'
import { t } from '../../utils/intl'
import { useAuthorsStore } from '../../stores/zine/authors'
import { useArticlesStore } from '../../stores/zine/articles'
import { loadAuthorArticles, useArticlesStore } from '../../stores/zine/articles'
import '../../styles/Topic.scss'
import { useTopicsStore } from '../../stores/zine/topics'
import { useRouter } from '../../stores/router'
import Beside from '../Feed/Beside'
import { Beside } from '../Feed/Beside'
import { restoreScrollPosition, saveScrollPosition } from '../../utils/scroll'
import { splitToPages } from '../../utils/splitToPages'
// TODO: load reactions on client
type AuthorProps = {
@ -26,16 +27,37 @@ type AuthorPageSearchParams = {
by: '' | 'viewed' | 'rating' | 'commented' | 'recent'
}
export const PRERENDERED_ARTICLES_COUNT = 12
const LOAD_MORE_PAGE_SIZE = 9 // Row3 + Row3 + Row3
export const AuthorView = (props: AuthorProps) => {
const { sortedArticles } = useArticlesStore({
sortedArticles: props.authorArticles
})
const { authorEntities } = useAuthorsStore({ authors: [props.author] })
const { topicsByAuthor } = useTopicsStore()
const [isLoadMoreButtonVisible, setIsLoadMoreButtonVisible] = createSignal(false)
const author = createMemo(() => authorEntities()[props.authorSlug])
const { searchParams, changeSearchParam } = useRouter<AuthorPageSearchParams>()
const loadMore = async () => {
saveScrollPosition()
const { hasMore } = await loadAuthorArticles({
authorSlug: author().slug,
limit: LOAD_MORE_PAGE_SIZE,
offset: sortedArticles().length
})
setIsLoadMoreButtonVisible(hasMore)
restoreScrollPosition()
}
onMount(async () => {
if (sortedArticles().length === PRERENDERED_ARTICLES_COUNT) {
loadMore()
}
})
const title = createMemo(() => {
const m = searchParams().by
if (m === 'viewed') return t('Top viewed')
@ -44,6 +66,10 @@ export const AuthorView = (props: AuthorProps) => {
return t('Top recent')
})
const pages = createMemo<Shout[][]>(() =>
splitToPages(sortedArticles(), PRERENDERED_ARTICLES_COUNT, LOAD_MORE_PAGE_SIZE)
)
return (
<div class="container author-page">
<Show when={author()} fallback={<div class="center">{t('Loading')}</div>}>
@ -83,31 +109,39 @@ export const AuthorView = (props: AuthorProps) => {
</div>
<h3 class="col-12">{title()}</h3>
<div class="row">
<Show when={sortedArticles().length > 0}>
<Beside
title={t('Topics which supported by author')}
values={topicsByAuthor()[author().slug].slice(0, 5)}
beside={sortedArticles()[0]}
wrapper={'topic'}
topicShortDescription={true}
isTopicCompact={true}
isTopicInRow={true}
iconButton={true}
/>
<Row3 articles={sortedArticles().slice(1, 4)} />
<Beside
title={t('Topics which supported by author')}
values={topicsByAuthor()[author().slug].slice(0, 5)}
beside={sortedArticles()[0]}
wrapper={'topic'}
topicShortDescription={true}
isTopicCompact={true}
isTopicInRow={true}
iconButton={true}
/>
<Row3 articles={sortedArticles().slice(1, 4)} />
<Row2 articles={sortedArticles().slice(4, 6)} />
<Row3 articles={sortedArticles().slice(6, 9)} />
<Row3 articles={sortedArticles().slice(9, 12)} />
<Show when={sortedArticles().length > 4}>
<Row2 articles={sortedArticles().slice(4, 6)} />
</Show>
<For each={pages()}>
{(page) => (
<>
<Row3 articles={page.slice(0, 3)} />
<Row3 articles={page.slice(3, 6)} />
<Row3 articles={page.slice(6, 9)} />
</>
)}
</For>
<Show when={sortedArticles().length > 6}>
<Row3 articles={sortedArticles().slice(6, 9)} />
</Show>
<Show when={sortedArticles().length > 9}>
<Row3 articles={sortedArticles().slice(9, 12)} />
</Show>
<Show when={isLoadMoreButtonVisible()}>
<p class="load-more-container">
<button class="button" onClick={loadMore}>
{t('Load more')}
</button>
</p>
</Show>
</div>
</Show>

View File

@ -1,6 +1,6 @@
import { createMemo, For, Show } from 'solid-js'
import type { Shout, Reaction } from '../../graphql/types.gen'
import { createMemo, createSignal, For, onMount, Show } from 'solid-js'
import '../../styles/Feed.scss'
import stylesBeside from '../../components/Feed/Beside.module.scss'
import { Icon } from '../Nav/Icon'
import { byCreated, sortBy } from '../../utils/sortby'
import { TopicCard } from '../Topic/Card'
@ -16,11 +16,6 @@ import { useAuthorsStore } from '../../stores/zine/authors'
import { useTopicsStore } from '../../stores/zine/topics'
import { useTopAuthorsStore } from '../../stores/zine/topAuthors'
interface FeedProps {
articles: Shout[]
reactions?: Reaction[]
}
// const AUTHORSHIP_REACTIONS = [
// ReactionKind.Accept,
// ReactionKind.Reject,
@ -28,9 +23,11 @@ interface FeedProps {
// ReactionKind.Ask
// ]
export const FeedView = (props: FeedProps) => {
export const FEED_PAGE_SIZE = 20
export const FeedView = () => {
// state
const { sortedArticles } = useArticlesStore({ sortedArticles: props.articles })
const { sortedArticles } = useArticlesStore()
const reactions = useReactionsStore()
const { sortedAuthors } = useAuthorsStore()
const { topTopics } = useTopicsStore()
@ -39,6 +36,8 @@ export const FeedView = (props: FeedProps) => {
const topReactions = createMemo(() => sortBy(reactions(), byCreated))
const [isLoadMoreButtonVisible, setIsLoadMoreButtonVisible] = createSignal(false)
// const expectingFocus = createMemo<Shout[]>(() => {
// // 1 co-author notifications needs
// // TODO: list of articles where you are co-author
@ -52,13 +51,15 @@ export const FeedView = (props: FeedProps) => {
// return []
// })
// eslint-disable-next-line unicorn/consistent-function-scoping
const loadMore = () => {
// const limit = props.limit || 50
// const offset = props.offset || 0
// FIXME
loadRecentArticles({ limit: 50, offset: 0 })
const loadMore = async () => {
const { hasMore } = await loadRecentArticles({ limit: FEED_PAGE_SIZE, offset: sortedArticles().length })
setIsLoadMoreButtonVisible(hasMore)
}
onMount(() => {
loadMore()
})
return (
<>
<div class="container feed">
@ -90,7 +91,7 @@ export const FeedView = (props: FeedProps) => {
{(article) => <ArticleCard article={article} settings={{ isFeedMode: true }} />}
</For>
<div class="beside-column-title">
<div class={stylesBeside.besideColumnTitle}>
<h4>{t('Popular authors')}</h4>
<a href="/user/list">
{t('All authors')}
@ -98,7 +99,7 @@ export const FeedView = (props: FeedProps) => {
</a>
</div>
<ul class="beside-column">
<ul class={stylesBeside.besideColumn}>
<For each={topAuthors().slice(0, 5)}>
{(author) => (
<li>
@ -112,10 +113,6 @@ export const FeedView = (props: FeedProps) => {
{(article) => <ArticleCard article={article} settings={{ isFeedMode: true }} />}
</For>
</Show>
<p class="load-more-container">
<button class="button">{t('Load more')}</button>
</p>
</div>
<aside class="col-md-3">
@ -135,12 +132,13 @@ export const FeedView = (props: FeedProps) => {
</Show>
</aside>
</div>
<p class="load-more-container">
<button class="button" onClick={loadMore}>
{t('Load more')}
</button>
</p>
<Show when={isLoadMoreButtonVisible()}>
<p class="load-more-container">
<button class="button" onClick={loadMore}>
{t('Load more')}
</button>
</p>
</Show>
</div>
</>
)

View File

@ -1,12 +1,12 @@
import { createMemo, For, onMount, Show } from 'solid-js'
import { createMemo, createSignal, For, onMount, Show } from 'solid-js'
import Banner from '../Discours/Banner'
import { NavTopics } from '../Nav/Topics'
import { Row5 } from '../Feed/Row5'
import Row3 from '../Feed/Row3'
import Row2 from '../Feed/Row2'
import Row1 from '../Feed/Row1'
import { Row3 } from '../Feed/Row3'
import { Row2 } from '../Feed/Row2'
import { Row1 } from '../Feed/Row1'
import Hero from '../Discours/Hero'
import Beside from '../Feed/Beside'
import { Beside } from '../Feed/Beside'
import RowShort from '../Feed/RowShort'
import Slider from '../Feed/Slider'
import Group from '../Feed/Group'
@ -23,12 +23,14 @@ import {
import { useTopAuthorsStore } from '../../stores/zine/topAuthors'
import { locale } from '../../stores/ui'
import { restoreScrollPosition, saveScrollPosition } from '../../utils/scroll'
import { splitToPages } from '../../utils/splitToPages'
type HomeProps = {
randomTopics: Topic[]
recentPublishedArticles: Shout[]
}
const PRERENDERED_ARTICLES_COUNT = 5
export const PRERENDERED_ARTICLES_COUNT = 5
const CLIENT_LOAD_ARTICLES_COUNT = 29
const LOAD_MORE_PAGE_SIZE = 16 // Row1 + Row3 + Row2 + Beside (3 + 1) + Row1 + Row 2 + Row3
@ -46,14 +48,20 @@ export const HomeView = (props: HomeProps) => {
const { randomTopics, topTopics } = useTopicsStore({
randomTopics: props.randomTopics
})
const [isLoadMoreButtonVisible, setIsLoadMoreButtonVisible] = createSignal(false)
const { topAuthors } = useTopAuthorsStore()
onMount(() => {
onMount(async () => {
loadTopArticles()
loadTopMonthArticles()
if (sortedArticles().length < PRERENDERED_ARTICLES_COUNT + CLIENT_LOAD_ARTICLES_COUNT) {
loadPublishedArticles({ limit: CLIENT_LOAD_ARTICLES_COUNT, offset: sortedArticles().length })
const { hasMore } = await loadPublishedArticles({
limit: CLIENT_LOAD_ARTICLES_COUNT,
offset: sortedArticles().length
})
setIsLoadMoreButtonVisible(hasMore)
}
})
@ -82,22 +90,23 @@ export const HomeView = (props: HomeProps) => {
const loadMore = async () => {
saveScrollPosition()
await loadPublishedArticles({ limit: LOAD_MORE_PAGE_SIZE, offset: sortedArticles().length })
const { hasMore } = await loadPublishedArticles({
limit: LOAD_MORE_PAGE_SIZE,
offset: sortedArticles().length
})
setIsLoadMoreButtonVisible(hasMore)
restoreScrollPosition()
}
const pages = createMemo<Shout[][]>(() => {
return sortedArticles()
.slice(PRERENDERED_ARTICLES_COUNT + CLIENT_LOAD_ARTICLES_COUNT)
.reduce((acc, article, index) => {
if (index % LOAD_MORE_PAGE_SIZE === 0) {
acc.push([])
}
acc[acc.length - 1].push(article)
return acc
}, [] as Shout[][])
})
const pages = createMemo<Shout[][]>(() =>
splitToPages(
sortedArticles(),
PRERENDERED_ARTICLES_COUNT + CLIENT_LOAD_ARTICLES_COUNT,
LOAD_MORE_PAGE_SIZE
)
)
return (
<Show when={locale() && sortedArticles().length > 0}>
@ -170,11 +179,13 @@ export const HomeView = (props: HomeProps) => {
)}
</For>
<p class="load-more-container">
<button class="button" onClick={loadMore}>
{t('Load more')}
</button>
</p>
<Show when={isLoadMoreButtonVisible()}>
<p class="load-more-container">
<button class="button" onClick={loadMore}>
{t('Load more')}
</button>
</p>
</Show>
</Show>
)
}

View File

@ -1,16 +1,18 @@
import { For, Show, createMemo } from 'solid-js'
import { For, Show, createMemo, onMount, createSignal } from 'solid-js'
import type { Shout, Topic } from '../../graphql/types.gen'
import Row3 from '../Feed/Row3'
import Row2 from '../Feed/Row2'
import Beside from '../Feed/Beside'
import { Row3 } from '../Feed/Row3'
import { Row2 } from '../Feed/Row2'
import { Beside } from '../Feed/Beside'
import { ArticleCard } from '../Feed/Card'
import '../../styles/Topic.scss'
import { FullTopic } from '../Topic/Full'
import { t } from '../../utils/intl'
import { useRouter } from '../../stores/router'
import { useTopicsStore } from '../../stores/zine/topics'
import { useArticlesStore } from '../../stores/zine/articles'
import { loadPublishedArticles, useArticlesStore } from '../../stores/zine/articles'
import { useAuthorsStore } from '../../stores/zine/authors'
import { restoreScrollPosition, saveScrollPosition } from '../../utils/scroll'
import { splitToPages } from '../../utils/splitToPages'
type TopicsPageSearchParams = {
by: 'comments' | '' | 'recent' | 'viewed' | 'rating' | 'commented'
@ -22,9 +24,14 @@ interface TopicProps {
topicSlug: string
}
export const PRERENDERED_ARTICLES_COUNT = 21
const LOAD_MORE_PAGE_SIZE = 9 // Row3 + Row3 + Row3
export const TopicView = (props: TopicProps) => {
const { searchParams, changeSearchParam } = useRouter<TopicsPageSearchParams>()
const [isLoadMoreButtonVisible, setIsLoadMoreButtonVisible] = createSignal(false)
const { sortedArticles } = useArticlesStore({ sortedArticles: props.topicArticles })
const { topicEntities } = useTopicsStore({ topics: [props.topic] })
@ -32,6 +39,24 @@ export const TopicView = (props: TopicProps) => {
const topic = createMemo(() => topicEntities()[props.topicSlug])
const loadMore = async () => {
saveScrollPosition()
const { hasMore } = await loadPublishedArticles({
limit: LOAD_MORE_PAGE_SIZE,
offset: sortedArticles().length
})
setIsLoadMoreButtonVisible(hasMore)
restoreScrollPosition()
}
onMount(async () => {
if (sortedArticles().length === PRERENDERED_ARTICLES_COUNT) {
loadMore()
}
})
const title = createMemo(() => {
const m = searchParams().by
if (m === 'viewed') return t('Top viewed')
@ -40,6 +65,10 @@ export const TopicView = (props: TopicProps) => {
return t('Top recent')
})
const pages = createMemo<Shout[][]>(() =>
splitToPages(sortedArticles(), PRERENDERED_ARTICLES_COUNT, LOAD_MORE_PAGE_SIZE)
)
return (
<div class="topic-page container">
<Show when={topic()}>
@ -110,6 +139,24 @@ export const TopicView = (props: TopicProps) => {
<Row3 articles={sortedArticles().slice(15, 18)} />
<Row3 articles={sortedArticles().slice(18, 21)} />
</Show>
<For each={pages()}>
{(page) => (
<>
<Row3 articles={page.slice(0, 3)} />
<Row3 articles={page.slice(3, 6)} />
<Row3 articles={page.slice(6, 9)} />
</>
)}
</For>
<Show when={isLoadMoreButtonVisible()}>
<p class="load-more-container">
<button class="button" onClick={loadMore}>
{t('Load more')}
</button>
</p>
</Show>
</div>
</Show>
</div>

View File

@ -8,7 +8,6 @@ export type PageProps = {
authorArticles?: Shout[]
topicArticles?: Shout[]
homeArticles?: Shout[]
feedArticles?: Shout[]
author?: Author
allAuthors?: Author[]
topic?: Topic

View File

@ -1,8 +1,7 @@
import { gql } from '@urql/core'
export default gql`
mutation CreateShoutMutations($shout: ShoutInput!) {
mutation CreateShoutMutation($shout: ShoutInput!) {
createShout(input: $shout) {
error
shout {

View File

@ -1,6 +1,5 @@
import { gql } from '@urql/core'
export default gql`
mutation DeleteShoutMutation($shout: String!) {
deleteShout(slug: $shout) {

View File

@ -1,8 +1,8 @@
import { gql } from '@urql/core'
export default gql`
mutation ConfirmEmailMutation($code: String!) {
confirmEmail(code: $code) {
mutation ConfirmEmailMutation($token: String!) {
confirmEmail(token: $token) {
error
token
user {

View File

@ -1,6 +1,5 @@
import { gql } from '@urql/core'
export default gql`
mutation DeleteReactionMutation($id: Int!) {
deleteReaction(id: $id) {

View File

@ -171,7 +171,7 @@ export type Mutation = {
}
export type MutationConfirmEmailArgs = {
token: Scalars['String']
code: Scalars['String']
}
export type MutationCreateChatArgs = {

View File

@ -47,7 +47,7 @@
"Horizontal collaborative journalistic platform": "Горизонтальная платформа для коллаборативной журналистики",
"How it works": "Как это работает",
"How to write an article": "Как написать статью",
"I have an account": "У меня есть аккаунт",
"I have an account": "У меня есть аккаунт!",
"I have no account yet": "У меня еще нет аккаунта",
"I know the password": "Я знаю пароль",
"Join our maillist": "Чтобы получать рассылку лучших публикаций, просто укажите свою почту",
@ -157,7 +157,13 @@
"Restore password": "Восстановить пароль",
"Hooray! Welcome!": "Ура! Добро пожаловать!",
"You've confirmed email": "Вы подтвердили почту",
"You've confirmed your account": "Вы подтвердили свою учётную запись",
"This email is already taken. If it's you": "Такой email уже зарегистрирован. Если это вы",
"enter": "войдите"
"enter": "войдите",
"Go to main page": "Перейти на главную",
"Back to main page": "Вернуться на главную",
"Almost done! Check your email.": "Почти готово! Осталось подтвердить вашу почту.",
"We've sent you a message with a link to enter our website.": "Мы выслали вам письмо с ссылкой на почту. Перейдите по ссылке в письме, чтобы войти на сайт.",
"Send link again": "Прислать ссылку ещё раз",
"Link sent, check your email": "Ссылка отправлена, проверьте почту",
"Create post": "Создать публикацию"
}

View File

@ -3,9 +3,10 @@ import { Root } from '../../../components/Root'
import Zine from '../../../layouts/zine.astro'
import { apiClient } from '../../../utils/apiClient'
import { initRouter } from '../../../stores/router'
import { PRERENDERED_ARTICLES_COUNT } from '../../../components/Views/Author'
const slug = Astro.params.slug.toString()
const articles = await apiClient.getArticlesForAuthors({ authorSlugs: [slug], limit: 50 })
const articles = await apiClient.getArticlesForAuthors({ authorSlugs: [slug], limit: PRERENDERED_ARTICLES_COUNT })
const author = articles[0].authors.find((a) => a.slug === slug)
const { pathname, search } = Astro.url

View File

@ -1,4 +1,12 @@
---
import { Confirmed } from '../../components/Nav/Confirmed'
import { t } from '../../utils/intl'
const token = Astro.params.token?.toString() || ''
return Astro.redirect('/?modal=confirm-oauth&token=' + token)
---
<html>
<head><title>{t('Discours')}</title></head>
<body>
<Confirmed token={token} />
</body>
</html>

View File

@ -0,0 +1,10 @@
---
import { t } from '../../utils/intl'
import { Confirmed } from '../../components/Nav/Confirmed'
---
<html>
<head><title>{t('Discours')}</title></head>
<body>
<Confirmed />
</body>
</html>

View File

@ -1,16 +1,12 @@
---
import { Root } from '../../components/Root'
import Zine from '../../layouts/zine.astro'
import { apiClient } from '../../utils/apiClient'
import { initRouter } from '../../stores/router'
const { pathname, search } = Astro.url
initRouter(pathname, search)
const articles = await apiClient.getRecentArticles({ limit: 50 })
---
<Zine>
<Root feedArticles={articles} client:load />
<Root client:load />
</Zine>

View File

@ -3,14 +3,14 @@ import Zine from '../layouts/zine.astro'
import { Root } from '../components/Root'
import { apiClient } from '../utils/apiClient'
import { initRouter } from '../stores/router'
import { PRERENDERED_ARTICLES_COUNT } from '../components/Views/Home'
const randomTopics = await apiClient.getRandomTopics({ amount: 12 })
const articles = await apiClient.getRecentPublishedArticles({ limit: 5 })
const articles = await apiClient.getRecentPublishedArticles({ limit: PRERENDERED_ARTICLES_COUNT })
const { pathname, search } = Astro.url
initRouter(pathname, search)
Astro.response.headers.set('Cache-Control', 's-maxage=1, stale-while-revalidate')
---

View File

@ -2,9 +2,10 @@
import { Root } from '../../components/Root'
import Zine from '../../layouts/zine.astro'
import { apiClient } from '../../utils/apiClient'
import { PRERENDERED_ARTICLES_COUNT } from '../../components/Views/Topic'
const slug = Astro.params.slug?.toString() || ''
const articles = await apiClient.getArticlesForTopics({ topicSlugs: [slug], limit: 50 })
const articles = await apiClient.getArticlesForTopics({ topicSlugs: [slug], limit: PRERENDERED_ARTICLES_COUNT })
const topic = articles[0].topics.find(({ slug: topicSlug }) => topicSlug === slug)
import { initRouter } from '../../stores/router'

View File

@ -1,3 +1,3 @@
---
return Astro.redirect('/?modal=auth&mode=welcome')
return Astro.redirect('/?modal=auth&mode=register')
---

View File

@ -11,7 +11,6 @@ export const signIn = async (params) => {
setToken(authResult.token)
console.debug('signed in')
}
export const signOut = () => {
// TODO: call backend to revoke token
setSession(null)
@ -54,7 +53,7 @@ export const register = async ({
})
}
export const signSendLink = async ({ email, lang }: { email: string, lang: string }) => {
export const signSendLink = async ({ email, lang }: { email: string; lang: string }) => {
await apiClient.authSendLink({ email, lang })
}

View File

@ -4,7 +4,7 @@ import { useRouter } from './router'
//export const locale = persistentAtom<string>('locale', 'ru')
export const [locale, setLocale] = createSignal('ru')
export type ModalType = 'auth' | 'subscribe' | 'feedback' | 'share' | 'thank' | 'donate'
export type ModalType = 'auth' | 'subscribe' | 'feedback' | 'thank' | 'donate'
type WarnKind = 'error' | 'warn' | 'info'
export interface Warning {
@ -17,7 +17,6 @@ export const MODALS: Record<ModalType, ModalType> = {
auth: 'auth',
subscribe: 'subscribe',
feedback: 'feedback',
share: 'share',
thank: 'thank',
donate: 'donate'
}

View File

@ -123,40 +123,109 @@ const addSortedArticles = (articles: Shout[]) => {
setSortedArticles((prevSortedArticles) => [...prevSortedArticles, ...articles])
}
export const loadFeed = async ({
limit,
offset
}: {
limit: number
offset?: number
}): Promise<{ hasMore: boolean }> => {
// TODO: load actual feed
return await loadRecentArticles({ limit, offset })
}
export const loadRecentArticles = async ({
limit,
offset
}: {
limit?: number
limit: number
offset?: number
}): Promise<void> => {
const newArticles = await apiClient.getRecentArticles({ limit, offset })
}): Promise<{ hasMore: boolean }> => {
const newArticles = await apiClient.getRecentArticles({ limit: limit + 1, offset })
const hasMore = newArticles.length === limit + 1
if (hasMore) {
newArticles.splice(-1)
}
addArticles(newArticles)
addSortedArticles(newArticles)
return { hasMore }
}
export const loadPublishedArticles = async ({
limit,
offset
offset = 0
}: {
limit?: number
limit: number
offset?: number
}): Promise<void> => {
const newArticles = await apiClient.getPublishedArticles({ limit, offset })
}): Promise<{ hasMore: boolean }> => {
const newArticles = await apiClient.getPublishedArticles({ limit: limit + 1, offset })
const hasMore = newArticles.length === limit + 1
if (hasMore) {
newArticles.splice(-1)
}
addArticles(newArticles)
addSortedArticles(newArticles)
return { hasMore }
}
export const loadArticlesForAuthors = async ({ authorSlugs }: { authorSlugs: string[] }): Promise<void> => {
const articles = await apiClient.getArticlesForAuthors({ authorSlugs, limit: 50 })
addArticles(articles)
setSortedArticles(articles)
export const loadAuthorArticles = async ({
authorSlug,
limit,
offset = 0
}: {
authorSlug: string
limit: number
offset?: number
}): Promise<{ hasMore: boolean }> => {
const newArticles = await apiClient.getArticlesForAuthors({
authorSlugs: [authorSlug],
limit: limit + 1,
offset
})
const hasMore = newArticles.length === limit + 1
if (hasMore) {
newArticles.splice(-1)
}
addArticles(newArticles)
addSortedArticles(newArticles)
return { hasMore }
}
export const loadArticlesForTopics = async ({ topicSlugs }: { topicSlugs: string[] }): Promise<void> => {
const articles = await apiClient.getArticlesForTopics({ topicSlugs, limit: 50 })
addArticles(articles)
setSortedArticles(articles)
export const loadTopicArticles = async ({
topicSlug,
limit,
offset
}: {
topicSlug: string
limit: number
offset: number
}): Promise<{ hasMore: boolean }> => {
const newArticles = await apiClient.getArticlesForTopics({
topicSlugs: [topicSlug],
limit: limit + 1,
offset
})
const hasMore = newArticles.length === limit + 1
if (hasMore) {
newArticles.splice(-1)
}
addArticles(newArticles)
addSortedArticles(newArticles)
return { hasMore }
}
export const resetSortedArticles = () => {

Some files were not shown because too many files have changed in this diff Show More